Packages
livebook
0.19.8
0.19.8
0.19.7
0.19.6
0.19.5
0.19.4
0.19.3
0.19.2
0.19.1
0.19.0
0.18.6
0.18.5
0.18.4
0.18.3
0.18.2
0.18.1
0.18.0
0.17.3
0.17.2
0.17.1
0.17.0
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.7
0.14.6
0.14.5
0.14.4
0.14.3
0.14.2
0.14.1
0.14.0
0.14.0-rc.1
0.14.0-rc.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.1
0.12.0
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.0
0.9.3
0.9.2
0.9.1
0.9.0
0.8.2
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.6.3
0.6.2
0.6.1
0.6.0
0.5.2
0.5.1
0.5.0
0.4.1
0.4.0
0.3.2
0.3.1
0.3.0
0.2.3
0.2.2
0.2.1
0.2.0
0.1.2
0.1.1
0.1.0
Automate code & data workflows with interactive notebooks
Current section
Files
Jump to
Current section
Files
lib/livebook_web/live/apps_live.ex
defmodule LivebookWeb.AppsLive do
use LivebookWeb, :live_view
import LivebookWeb.AppComponents
@app_folder_events [
:app_folder_created,
:app_folder_updated,
:app_folder_deleted
]
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Livebook.Teams.Broadcasts.subscribe([:app_server, :app_folders])
Livebook.Apps.subscribe()
end
empty_apps_path? = Livebook.Apps.empty_apps_path?()
{:ok,
socket
|> assign(
apps_banner: Livebook.Config.apps_banner(),
search_term: "",
selected_app_folder: "",
apps: Livebook.Apps.list_authorized_apps(socket.assigns.current_user),
empty_apps_path?: empty_apps_path?,
logout_enabled?:
Livebook.Config.logout_enabled?() and socket.assigns.current_user.email != nil
)
|> load_app_folders()
|> apply_filters()}
end
@impl true
def handle_params(%{"folder" => id}, _url, socket) do
{:noreply,
socket
|> assign(selected_app_folder: id)
|> apply_filters()}
end
def handle_params(_params, _url, socket) do
{:noreply,
socket
|> assign(selected_app_folder: "")
|> apply_filters()}
end
@impl true
def render(assigns) do
~H"""
<div class="h-full flex flex-col overflow-y-auto bg-white">
<.apps_banner value={@apps_banner} />
<div class="border-b border-gray-200/70 bg-white/80 backdrop-blur-sm shadow-sm">
<div class="max-w-6xl mx-auto px-10 py-3.5 flex items-center justify-between">
<div class="w-10 h-10">
<.menu id="apps-menu" position="bottom-right" md_position="bottom-left">
<:toggle>
<button class="flex items-center text-gray-900">
<img src={~p"/images/logo.png"} height="40" width="40" alt="logo livebook" />
<.remix_icon :if={@logout_enabled?} icon="arrow-down-s-line" />
</button>
</:toggle>
<.menu_item :if={@logout_enabled?}>
<button phx-click="logout" role="menuitem">
<.remix_icon icon="logout-box-line" />
<span>Logout</span>
</button>
</.menu_item>
</.menu>
</div>
<div>
<.link
navigate={~p"/apps-dashboard"}
class="inline-flex items-center gap-1.5 text-gray-500 text-xs font-medium px-3 py-1.5 rounded-md hover:bg-gray-100/80 hover:text-gray-700 transition-all duration-200"
>
<span>Dashboard</span>
<.remix_icon
icon="arrow-right-line"
class="text-xs text-gray-400"
/>
</.link>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto">
<div class="max-w-6xl mx-auto px-12 py-12 pb-24">
<div class="mb-6">
<h1 class="text-3xl font-semibold text-gray-900 tracking-tight leading-none m-0">
Apps
</h1>
</div>
<%= if @apps != [] do %>
<div class="flex flex-col md:flex-row gap-4 mb-10">
<div class="flex-1">
<div class="relative">
<.remix_icon
icon="search-line"
class="absolute left-3 bottom-2 text-gray-400"
/>
<.text_field
id="search-app"
name="search_term"
placeholder="Search by app name..."
value={@search_term}
phx-keyup="search"
phx-debounce="300"
class="w-full pl-10 text-base"
autofocus
/>
</div>
</div>
<div :if={@show_app_folders?} class="md:w-48">
<form id="select-app-folder-form" phx-change="select_app_folder" phx-nosubmit>
<.select_field
id="select-app-folder"
name="app_folder"
prompt="All folders"
value={@selected_app_folder}
options={@app_folder_options}
/>
</form>
</div>
</div>
<div :if={@show_app_folders? and @selected_app_folder != ""}>
<div class="flex mb-8 pb-2.5">
<.link id="all-app-folders" patch={~p"/apps"}>
<div class="flex items-center gap-2.5 text-gray-500 hover:text-gray-900">
<.remix_icon icon="arrow-left-line" class="text-sm leading-none" />
<span class="text-sm flex-1 tracking-widest leading-none">
Back to all folders
</span>
</div>
</.link>
</div>
</div>
<div :if={@filtered_apps == []} class="text-center py-20 px-5">
<div class="text-5xl text-gray-300 mb-4">
<.remix_icon icon="windy-line" />
</div>
<div class="text-base font-semibold text-gray-900 mb-2">No apps found</div>
<div class="text-sm text-gray-500">Try adjusting your search or filter</div>
</div>
<div :if={@filtered_apps != []} class="flex flex-col gap-12">
<div :for={{id, app_folder, icon, apps} <- @grouped_apps} id={"app-folder-#{id}"}>
<%= if @show_app_folders? do %>
<div class="flex items-center gap-2.5 mb-5 pb-2.5 border-b border-gray-200/70 group text-gray-500/80 hover:text-gray-700">
<.link id={"app-folder-#{id}-permalink"} patch={~p"/apps?folder=#{id}"}>
<div class="flex items-center gap-2.5">
<.remix_icon icon={icon} class="text-xs leading-none" />
<span class="text-sm flex-1 tracking-widest leading-none">
{app_folder}
</span>
</div>
</.link>
<span
class="invisible group-hover:visible"
data-tooltip="Copied to clipboard"
aria-label="copy to clipboard"
phx-click={
JS.dispatch("lb:clipcopy", to: "#app-folder-#{id}-permalink")
|> JS.transition("tooltip right", time: 2000)
}
>
<button>
<.remix_icon icon="links-line" class="text-smleading-none py-1" />
</button>
</span>
</div>
<% end %>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5">
<.link
:for={app <- apps_listing(apps)}
id={"app-#{app.slug}"}
navigate={~p"/apps/#{app.slug}"}
class="group block border border-gray-200 rounded-lg px-4 py-4 bg-white shadow-sm hover:shadow-lg hover:border-gray-300 hover:-translate-y-1 transition-all duration-200 ease-out cursor-pointer"
>
<div class="flex items-center justify-between gap-3">
<span class="text-base font-semibold text-gray-800 flex-1 truncate group-hover:text-gray-900">
{app.notebook_name}
</span>
<div class="flex items-center gap-1.5 shrink-0">
<.remix_icon
:if={not app.public?}
icon="lock-password-line"
class="text-sm text-gray-300"
/>
</div>
</div>
</.link>
</div>
</div>
</div>
<% else %>
<div class="max-w-2xl mx-auto mt-16 text-center">
<div :if={@empty_apps_path?}>
<div class="mb-8">
<.remix_icon icon="windy-line" class="text-gray-300 text-5xl mb-4" />
<h3 class="text-lg font-semibold text-gray-900 mb-2">No apps found</h3>
<p class="text-sm text-gray-500 mb-6">
Follow these steps to list your apps here:
</p>
</div>
<div class="max-w-md mx-auto">
<ol class="space-y-3">
<li class="flex items-start gap-3 text-left text-sm text-gray-700 leading-relaxed">
<span class="inline-flex items-center justify-center w-6 h-6 bg-gray-100 text-gray-500 rounded-full text-xs font-semibold shrink-0">
1
</span>
<span>Open a notebook</span>
</li>
<li class="flex items-start gap-3 text-left text-sm text-gray-700 leading-relaxed">
<span class="inline-flex items-center justify-center w-6 h-6 bg-gray-100 text-gray-500 rounded-full text-xs font-semibold shrink-0 mt-px">
2
</span>
<div class="flex gap-x-1 items-center flex-wrap">
Click <.remix_icon icon="rocket-line" class="inline align-middle text-base" />
in the sidebar and configure the app as public
</div>
</li>
<li class="flex items-start gap-3 text-left text-sm text-gray-700 leading-relaxed">
<span class="inline-flex items-center justify-center w-6 h-6 bg-gray-100 text-gray-500 rounded-full text-xs font-semibold shrink-0 mt-px">
3
</span>
<div class="flex gap-x-1 items-center flex-wrap">
Save the notebook to the
<code class="inline-block px-1.5 py-0.5 bg-gray-100 text-gray-900 rounded text-xs font-mono leading-none">
{Livebook.Config.apps_path()}
</code>
folder
</div>
</li>
<li class="flex items-start gap-3 text-left text-sm text-gray-700 leading-relaxed">
<span class="inline-flex items-center justify-center w-6 h-6 bg-gray-100 text-gray-500 rounded-full text-xs font-semibold shrink-0">
4
</span>
<span>Relaunch your Livebook app</span>
</li>
</ol>
</div>
</div>
<div :if={not @empty_apps_path?} class="text-center">
<.remix_icon icon="windy-line" class="text-gray-300 text-3xl mb-4" />
<h3 class="text-lg font-semibold text-gray-900">No apps running</h3>
</div>
</div>
<% end %>
</div>
</div>
</div>
"""
end
@impl true
def handle_event("search", %{"value" => search_term}, socket) do
{:noreply,
socket
|> assign(search_term: search_term)
|> apply_filters()}
end
def handle_event("select_app_folder", %{"app_folder" => id}, socket) do
if id == "" do
{:noreply, push_patch(socket, to: ~p"/apps")}
else
{:noreply, push_patch(socket, to: ~p"/apps?folder=#{id}")}
end
end
@impl true
def handle_info({type, _app}, socket) when type in [:app_created, :app_updated, :app_closed] do
{:noreply,
socket
|> assign(apps: Livebook.Apps.list_authorized_apps(socket.assigns.current_user))
|> apply_filters()}
end
def handle_info({:server_authorization_updated, _}, socket) do
{:noreply,
socket
|> assign(
apps: Livebook.Apps.list_authorized_apps(socket.assigns.current_user),
logout_enabled?:
Livebook.Config.logout_enabled?() and socket.assigns.current_user.email != nil
)
|> apply_filters()}
end
def handle_info({type, _app_folder}, socket) when type in @app_folder_events do
{:noreply,
socket
|> load_app_folders()
|> apply_filters()}
end
def handle_info(_message, socket), do: {:noreply, socket}
defp apps_listing(apps) do
Enum.sort_by(apps, & &1.notebook_name)
end
def load_app_folders(socket) do
app_folders =
Enum.flat_map(Livebook.Hubs.get_hubs(), &Livebook.Hubs.Provider.get_app_folders/1)
app_folder_options =
for app_folder <- app_folders do
{app_folder.name, app_folder.id}
end
assign(socket, app_folders: app_folders, app_folder_options: app_folder_options)
end
defp apply_filters(socket) do
apps = socket.assigns.apps
app_folders = socket.assigns.app_folders
filtered_apps =
filter_apps(apps, socket.assigns.search_term, socket.assigns.selected_app_folder)
grouped_apps =
filtered_apps
|> Enum.group_by(fn
%{app_spec: %{app_folder_id: id}} -> Enum.find_value(app_folders, &(&1.id == id && id))
_ -> nil
end)
|> Enum.map(fn
{nil, apps} ->
{"ungrouped", "No folder", "function-line", apps}
{id, apps} ->
app_folder_name = Enum.find_value(app_folders, &(&1.id == id && &1.name))
{id, app_folder_name, "folder-line", apps}
end)
|> Enum.sort_by(fn {id, name, _, _} ->
if id != "ungrouped", do: {0, name}, else: {1, name}
end)
show_app_folders? = Enum.any?(apps, &is_struct(&1.app_spec, Livebook.Apps.TeamsAppSpec))
assign(socket,
grouped_apps: grouped_apps,
filtered_apps: filtered_apps,
show_app_folders?: show_app_folders?
)
end
defp filter_apps(apps, term, app_folder_id) do
apps
|> search_apps(term)
|> filter_by_app_folder(app_folder_id)
end
defp search_apps(apps, ""), do: apps
defp search_apps(apps, term) do
term = String.downcase(term)
Enum.filter(apps, fn app ->
String.contains?(String.downcase(app.notebook_name), term) or
String.contains?(app.slug, term)
end)
end
defp filter_by_app_folder(apps, ""), do: apps
defp filter_by_app_folder(apps, "ungrouped") do
Enum.filter(apps, fn
%{app_spec: %{app_folder_id: nil}} -> true
_otherwise -> false
end)
end
defp filter_by_app_folder(apps, app_folder_id) do
Enum.filter(apps, fn
%{app_spec: %{app_folder_id: id}} -> id == app_folder_id
_otherwise -> false
end)
end
end