Packages
livebook
0.18.0
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
@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(
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 render(assigns) do
~H"""
<div class="h-full flex flex-col overflow-y-auto bg-white">
<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={@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={{app_folder, id, icon, apps} <- @grouped_apps} id={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">
<.remix_icon icon={icon} class="text-gray-500/80 text-xs leading-none" />
<span class="text-sm text-gray-500 flex-1 tracking-widest leading-none">
{app_folder}
</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 flex-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 flex-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 flex-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 flex-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 flex-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" => app_folder_id}, socket) do
{:noreply,
socket
|> assign(selected_app_folder: app_folder_id)
|> apply_filters()}
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 @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} ->
{"No folder", "ungrouped-apps", "function-line", apps}
{id, apps} ->
app_folder_name = Enum.find_value(app_folders, &(&1.id == id && &1.name))
{app_folder_name, "app-folder-#{id}", "folder-line", apps}
end)
|> Enum.sort_by(fn {name, _, _, _} ->
if name == "No folder", do: {1, name}, else: {0, 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, app_folder_id) do
Enum.filter(apps, fn
%{app_spec: %{app_folder_id: id}} -> id == app_folder_id
_otherwise -> false
end)
end
end