This commit is contained in:
Alexandre de Souza 2025-10-23 15:24:41 -03:00
parent a0e4a894b1
commit 0bbed3d9e1
No known key found for this signature in database
GPG key ID: E39228FFBA346545
7 changed files with 261 additions and 81 deletions

View file

@ -625,7 +625,7 @@ defmodule Livebook.Hubs.TeamClient do
deployed_by: app_deployment.deployed_by,
deployed_at: DateTime.from_gregorian_seconds(app_deployment.deployed_at),
authorization_groups: authorization_groups,
app_folder_id: app_deployment.app_folder_id
app_folder_id: nullify(app_deployment.app_folder_id)
}
end
@ -645,7 +645,7 @@ defmodule Livebook.Hubs.TeamClient do
%Teams.AuthorizationGroup{
provider_id: authorization_group.provider_id,
group_name: authorization_group.group_name,
app_folder_ids: authorization_group.app_folder_ids
app_folder_id: nullify(authorization_group.app_folder_id)
}
end
end
@ -931,7 +931,7 @@ defmodule Livebook.Hubs.TeamClient do
remove_app_folder(state, app_folder)
end
defp handle_event(:file_system_deleted, %{id: id}, state) do
defp handle_event(:app_folder_deleted, %{id: id}, state) do
with {:ok, app_folder} <- fetch_app_folder(id, state) do
handle_event(:app_folder_deleted, app_folder, state)
end

View file

@ -4,13 +4,13 @@ defmodule Livebook.Teams.AuthorizationGroup do
@type t :: %__MODULE__{
provider_id: String.t() | nil,
group_name: String.t() | nil,
app_folder_ids: list(String.t())
app_folder_id: String.t() | nil
}
@primary_key false
embedded_schema do
field :provider_id, :string
field :group_name, :string
field :app_folder_ids, {:array, :string}
field :app_folder_id, :string
end
end

View file

@ -547,7 +547,7 @@ defmodule LivebookWeb.FormComponents do
]}
{@rest}
>
<option :if={@prompt} value="">{@prompt}</option>
<option :if={@prompt} value="" disabled selected>{@prompt}</option>
<%!-- TODO: remove to_string/1 when fixed upstream, see https://github.com/phoenixframework/phoenix_html/issues/444#issuecomment-2713061480 --%>
{Phoenix.HTML.Form.options_for_select(@options, to_string(@value))}
</select>

View file

@ -4,27 +4,21 @@ defmodule LivebookWeb.AppsLive do
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Livebook.Teams.Broadcasts.subscribe(:app_server)
Livebook.Teams.Broadcasts.subscribe([:app_server, :app_folders])
Livebook.Apps.subscribe()
end
apps = Livebook.Apps.list_authorized_apps(socket.assigns.current_user)
empty_apps_path? = Livebook.Apps.empty_apps_path?()
{:ok,
assign(socket,
apps: apps,
empty_apps_path?: empty_apps_path?,
logout_enabled?:
Livebook.Config.logout_enabled?() and socket.assigns.current_user.email != nil
)}
socket
|> assign(search_term: "", selected_app_folder: "")
|> load_data()}
end
@impl true
def render(assigns) do
~H"""
<div class="h-full flex flex-col overflow-y-auto">
<div class="px-4 py-3 flex items-center justify-between">
<div class="h-full flex flex-col overflow-y-auto bg-gray-50">
<div class="px-6 py-4 bg-white border-b border-gray-200 flex items-center justify-between">
<div class="w-10 h-10">
<.menu id="apps-menu" position="bottom-right" md_position="bottom-left">
<:toggle>
@ -42,79 +36,254 @@ defmodule LivebookWeb.AppsLive do
</.menu>
</div>
<div>
<.link navigate={~p"/apps-dashboard"} class="flex items-center text-blue-600">
<.link
navigate={~p"/apps-dashboard"}
class="flex items-center text-blue-600 hover:text-blue-700 transition-colors"
>
<span class="font-semibold">Dashboard</span>
<.remix_icon icon="arrow-right-line" class="align-middle ml-1" />
</.link>
</div>
</div>
<div class="w-full max-w-screen-lg px-4 md:px-20 py-4 mx-auto">
<div class="flex flex-col items-center">
<h1 class="text-2xl text-gray-800 font-medium">
Apps
</h1>
<div :if={@apps != []} class="w-full mt-5 max-w-[400px]">
<div class="w-full flex flex-col space-y-4">
<.link
:for={app <- apps_listing(@apps)}
navigate={~p"/apps/#{app.slug}"}
class="px-4 py-3 border border-gray-200 rounded-xl text-gray-800 pointer hover:bg-gray-50 flex items-center justify-between"
>
<span class="font-semibold">{app.notebook_name}</span>
<.remix_icon :if={not app.public?} icon="lock-password-line" />
</.link>
<div class="flex-1 px-6 py-6">
<div class="max-w-7xl mx-auto">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Apps</h1>
<p class="text-gray-600">Find and manage your Livebook applications</p>
</div>
<%= if @apps != [] do %>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 mb-6">
<div class="p-6 border-b border-gray-200">
<div class="flex flex-col md:flex-row gap-4">
<div class="flex-1">
<div class="relative">
<.remix_icon
icon="search-line"
class="absolute left-3 bottom-[10px] text-gray-400"
/>
<.text_field
id="search-app"
name="search_term"
label="Search"
placeholder="Search apps..."
value={@search_term}
phx-keyup="search"
phx-debounce="300"
class="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
/>
</div>
</div>
<div class="md:w-48">
<.select_field
id="select-app-folder"
name="app_folder"
label="Folder"
prompt="Select a folder..."
value={@selected_app_folder}
options={@app_folder_options}
phx-change="select_app_folder"
help={
~S'''
Use folders to organize how how apps are displayed.
'''
}
/>
</div>
</div>
</div>
</div>
</div>
<div
:if={@apps == [] and not @empty_apps_path?}
class="mt-5 flex flex-col w-full max-w-[400px]"
>
<.no_entries :if={@apps == []}>
No apps running.
</.no_entries>
</div>
<div :if={@apps == [] and @empty_apps_path?} class="mt-5 text-gray-600">
<div>
No app notebooks found. Follow these steps to list your apps here:
</div>
<ol class="mt-4 pl-4 flex flex-col space-y-1 list-decimal list-inside">
<li>
Open a notebook
</li>
<li>
Click <.remix_icon icon="rocket-line" class="align-baseline text-lg" />
in the sidebar and configure the app as public
</li>
<li>
Save the notebook to the
<span class="font-medium">{Livebook.Config.apps_path()}</span>
folder
</li>
<li>
Relaunch your Livebook app
</li>
</ol>
</div>
<%= if @filtered_apps == [] do %>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
<.remix_icon icon="search-line" class="mx-auto h-12 w-12 text-gray-300 mb-4" />
<h3 class="text-lg font-medium text-gray-900 mb-2">No apps found</h3>
<p class="text-gray-600">Try adjusting your search or filter criteria</p>
</div>
<% else %>
<%= for {group, apps} <- [] do %>
<div class="mb-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4 flex items-center">
<.remix_icon icon="folder-line" class="mr-2" />
{group}
<span class="ml-2 text-sm font-normal text-gray-500">({length(apps)})</span>
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<%= for app <- Enum.sort_by(apps, & &1.notebook_name) do %>
<.link
navigate={~p"/apps/#{app.slug}"}
class="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md hover:border-blue-300 transition-all duration-200 group"
>
<div class="flex items-start justify-between mb-2">
<div class="flex-1 min-w-0">
<h3 class="text-sm font-medium text-gray-900 group-hover:text-blue-600 transition-colors truncate">
{app.notebook_name}
</h3>
</div>
<div class="flex items-center space-x-1 ml-2">
<.remix_icon
:if={not app.public?}
icon="lock-password-line"
class="h-4 w-4 text-gray-400"
/>
<.remix_icon
icon="arrow-right-line"
class="h-4 w-4 text-gray-400 group-hover:text-blue-600 transition-colors"
/>
</div>
</div>
</.link>
<% end %>
</div>
</div>
<% end %>
<% end %>
<% else %>
<%= if @empty_apps_path? do %>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
<.remix_icon icon="folder-add-line" class="mx-auto h-16 w-16 text-gray-300 mb-6" />
<h3 class="text-xl font-semibold text-gray-900 mb-4">No app notebooks found</h3>
<p class="text-gray-600 mb-6 max-w-md mx-auto">
Follow these steps to list your apps here:
</p>
<div class="bg-gray-50 rounded-lg p-6 text-left max-w-md mx-auto">
<ol class="space-y-3 text-sm text-gray-700">
<li class="flex items-start">
<span class="inline-flex items-center justify-center w-6 h-6 bg-blue-100 text-blue-600 rounded-full text-xs font-medium mr-3 mt-0.5">
1
</span>
Open a notebook
</li>
<li class="flex items-start">
<span class="inline-flex items-center justify-center w-6 h-6 bg-blue-100 text-blue-600 rounded-full text-xs font-medium mr-3 mt-0.5">
2
</span>
<div>
Click
<.remix_icon icon="rocket-line" class="inline align-baseline text-base" />
in the sidebar and configure the app as public
</div>
</li>
<li class="flex items-start">
<span class="inline-flex items-center justify-center w-6 h-6 bg-blue-100 text-blue-600 rounded-full text-xs font-medium mr-3 mt-0.5">
3
</span>
<div>
Save the notebook to the
<span class="font-medium bg-gray-100 px-1 rounded text-xs">
{Livebook.Config.apps_path()}
</span>
folder
</div>
</li>
<li class="flex items-start">
<span class="inline-flex items-center justify-center w-6 h-6 bg-blue-100 text-blue-600 rounded-full text-xs font-medium mr-3 mt-0.5">
4
</span>
Relaunch your Livebook app
</li>
</ol>
</div>
</div>
<% else %>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
<.remix_icon icon="file-line" class="mx-auto h-16 w-16 text-gray-300 mb-6" />
<h3 class="text-xl font-semibold text-gray-900 mb-2">No apps running</h3>
<p class="text-gray-600">Start some apps to see them listed here</p>
</div>
<% end %>
<% end %>
</div>
</div>
</div>
"""
end
@impl true
def handle_event("search", %{"value" => search_term}, socket) do
{:noreply,
socket
|> assign(search_term: search_term)
|> load_data()}
end
def handle_event("select_app_folder", %{"value" => app_folder}, socket) do
{:noreply,
socket
|> assign(selected_app_folder: app_folder)
|> load_data()}
end
@impl true
def handle_info({type, _app} = event, socket)
when type in [:app_created, :app_updated, :app_closed] do
{:noreply, update(socket, :apps, &LivebookWeb.AppComponents.update_app_list(&1, event))}
apps = LivebookWeb.AppComponents.update_app_list(socket.assigns.apps, event)
{:noreply, load_data(socket, apps)}
end
def handle_info({:server_authorization_updated, _}, socket) do
apps = Livebook.Apps.list_authorized_apps(socket.assigns.current_user)
{:noreply, assign(socket, :apps, apps)}
{:noreply, load_data(socket)}
end
def handle_info({type, _app_folder}, socket)
when type in [:app_folder_created, :app_folder_updated, :app_folder_deleted] do
{:noreply, load_data(socket)}
end
def handle_info(_message, socket), do: {:noreply, socket}
defp apps_listing(apps) do
Enum.sort_by(apps, & &1.notebook_name)
defp load_data(socket, apps \\ nil) do
apps = apps || Livebook.Apps.list_authorized_apps(socket.assigns.current_user)
filtered_apps =
filter_apps(apps, socket.assigns.search_term, socket.assigns.selected_app_folder)
empty_apps_path? = Livebook.Apps.empty_apps_path?()
app_folders =
Enum.flat_map(Livebook.Hubs.get_hubs(), fn
%{id: "team-" <> _} = team -> Livebook.Teams.get_app_folders(team)
_ -> []
end)
app_folder_options =
for app_folder <- app_folders do
{app_folder.name, "#{app_folder.hub_id}:#{app_folder.id}"}
end
assign(socket,
apps: apps,
app_folders: app_folders,
app_folder_options: [{"All", "all"} | app_folder_options],
filtered_apps: filtered_apps,
empty_apps_path?: empty_apps_path?,
logout_enabled?:
Livebook.Config.logout_enabled?() and socket.assigns.current_user.email != nil
)
end
defp filter_apps(apps, term, app_folder) do
apps
|> search_apps(term)
|> filter_by_app_folder(app_folder)
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, "all"), do: apps
defp filter_by_app_folder(apps, _app_folder) do
apps
end
end

View file

@ -1819,6 +1819,14 @@ defmodule LivebookWeb.SessionLive do
# have to traverse the whole template tree and no diff is sent to the client.
defp data_to_view(data) do
changed_input_ids = Session.Data.changed_input_ids(data)
hub = Livebook.Hubs.fetch_hub!(data.notebook.hub_id)
app_folders =
if data.notebook.teams_enabled do
Livebook.Teams.get_app_folders(hub)
else
[]
end
%{
file: data.file,
@ -1861,7 +1869,7 @@ defmodule LivebookWeb.SessionLive do
section_views: section_views(data.notebook.sections, data, changed_input_ids),
bin_entries: data.bin_entries,
secrets: data.secrets,
hub: Livebook.Hubs.fetch_hub!(data.notebook.hub_id),
hub: hub,
hub_secrets: data.hub_secrets,
any_session_secrets?:
Session.Data.session_secrets(data.secrets, data.notebook.hub_id) != [],
@ -1869,7 +1877,9 @@ defmodule LivebookWeb.SessionLive do
quarantine_file_entry_names: data.notebook.quarantine_file_entry_names,
app_settings: data.notebook.app_settings,
deployed_app_slug: data.deployed_app_slug,
deployment_group_id: data.notebook.deployment_group_id
deployment_group_id: data.notebook.deployment_group_id,
app_folders: app_folders,
teams_enabled: data.notebook.teams_enabled
}
end

View file

@ -12,10 +12,15 @@ defmodule LivebookWeb.SessionLive.AppSettingsComponent do
_ -> AppSettings.change(assigns.settings)
end
app_folder_options =
for app_folder <- assigns.app_folders do
{app_folder.name, app_folder.id}
end
{:ok,
socket
|> assign(assigns)
|> assign(changeset: changeset)}
|> assign(app_folder_options: app_folder_options, changeset: changeset)}
end
@impl true
@ -43,17 +48,11 @@ defmodule LivebookWeb.SessionLive.AppSettingsComponent do
<div class="flex flex-col space-y-4">
<.text_field field={f[:slug]} label="Slug" spellcheck="false" phx-debounce />
<.select_field
:if={@teams_enabled}
field={f[:group]}
label="Folder"
prompt="Select a folder..."
options={[
{"Data Science", "data_science"},
{"Machine Learning", "machine_learning"},
{"Analytics", "analytics"},
{"Visualization", "visualization"},
{"Prototypes", "prototypes"},
{"Utilities", "utilities"}
]}
options={@app_folder_options}
help={
~S'''
Use folders to organize how how apps are displayed.

View file

@ -102,6 +102,8 @@ defmodule LivebookWeb.SessionLive.Render do
settings={@data_view.app_settings}
context={@action_assigns.context}
deployed_app_slug={@data_view.deployed_app_slug}
app_folders={@data_view.app_folders}
teams_enabled={@data_view.teams_enabled}
/>
</.modal>