mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-25 04:46:04 +08:00
wip
This commit is contained in:
parent
a0e4a894b1
commit
0bbed3d9e1
7 changed files with 261 additions and 81 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue