Separate apps from auth and improve apps navigation (#2322)

This commit is contained in:
Jonatan Kłosko 2023-11-06 09:08:28 +01:00 committed by GitHub
parent a05d6c5059
commit 07aaea11b4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 443 additions and 384 deletions

View file

@ -24,9 +24,7 @@ defmodule LivebookWeb.AuthController do
def index(conn, _params) do
render(conn, "index.html",
errors: [],
auth_mode: Livebook.Config.auth_mode(),
any_apps?: any_apps?(),
empty_apps_path?: Livebook.Apps.empty_apps_path?()
auth_mode: Livebook.Config.auth_mode()
)
end
@ -55,9 +53,7 @@ defmodule LivebookWeb.AuthController do
render(conn, "index.html",
errors: errors,
auth_mode: auth_mode,
any_apps?: any_apps?(),
empty_apps_path?: Livebook.Apps.empty_apps_path?()
auth_mode: auth_mode
)
end
@ -74,8 +70,4 @@ defmodule LivebookWeb.AuthController do
end)
|> halt()
end
defp any_apps?() do
Livebook.Apps.list_apps() != []
end
end

View file

@ -1,99 +1,61 @@
<div class="h-screen w-full flex flex-col sm:flex-row">
<div class="w-full h-full px-4 py-8 bg-gray-900 flex justify-center items-center">
<div class="max-w-[400px] w-full flex flex-col">
<a href={~p"/"} class="mb-2 -ml-2">
<img src={~p"/images/logo.png"} height="96" width="96" alt="livebook" />
</a>
<div class="mb-2 text-xl text-gray-100 font-medium">
Authentication required
</div>
<div class="mb-8 text-sm text-gray-200 space-y-2">
<p :if={@auth_mode == :password}>
Type password to access the Livebook.
</p>
<p :if={@auth_mode == :token}>
Please check out the console for authentication URL or type the token directly
here.
</p>
<p :if={@auth_mode == :token}>
To use password authentication, set the <code>LIVEBOOK_PASSWORD</code>
environment variable.
</p>
</div>
<div class="text-gray-50 w-full">
<form method="post" class="flex flex-col w-full">
<input type="hidden" value={Phoenix.Controller.get_csrf_token()} name="_csrf_token" />
<div phx-feedback-for={@auth_mode} class={[@errors != [] && "show-errors"]}>
<input
:if={@auth_mode == :password}
type="password"
name="password"
class="px-4 py-2 w-full text-gray-300 placeholder-gray-400 border border-gray-500 rounded-lg bg-transparent phx-form-error:border-red-600 phx-form-error:text-red-600 phx-form-error:placeholder-red-600"
placeholder="Password"
autofocus
/>
<input
:if={@auth_mode == :token}
type="text"
name="token"
class="px-4 py-2 w-full text-gray-300 placeholder-gray-400 border border-gray-500 rounded-lg bg-transparent phx-form-error:border-red-600 phx-form-error:text-red-600 phx-form-error:placeholder-red-600"
placeholder="Token"
autofocus
/>
<span
:for={error <- @errors}
class="mt-1 hidden text-red-600 text-sm phx-form-error:block"
>
<%= translate_error(error) %>
</span>
</div>
<button
type="submit"
class="mt-4 py-2 w-full rounded-lg text-gray-200 bg-blue-600 hover:bg-blue-700 focus:bg-blue-700"
>
<span>Authenticate</span>
<.remix_icon icon="arrow-right-line" class="ml-1 align-middle" />
</button>
</form>
</div>
<div class="h-screen w-full px-4 py-8 bg-gray-900 flex justify-center items-center">
<div class="max-w-[400px] w-full flex flex-col">
<a href={~p"/"} class="mb-2 -ml-2">
<img src={~p"/images/logo.png"} height="96" width="96" alt="livebook" />
</a>
<div class="mb-2 text-xl text-gray-100 font-medium">
Authentication required
</div>
</div>
<div
:if={@any_apps? or @empty_apps_path?}
class="w-full h-full px-4 py-8 flex justify-center items-center"
>
<div class="w-full flex flex-col items-center">
<div class="text-gray-700 text-xl font-medium">
Apps
</div>
<div :if={@any_apps?} class="w-full mt-5 mx-auto max-w-[400px]">
<%= live_render(@conn, LivebookWeb.AuthAppListLive) %>
</div>
<div :if={@empty_apps_path?} class="mt-5 text-gray-600">
<div>
No app notebooks found. <br />Follow these steps to list your apps here:
<div class="mb-8 text-sm text-gray-200 space-y-2">
<p :if={@auth_mode == :password}>
Type password to access the Livebook.
</p>
<p :if={@auth_mode == :token}>
Please check out the console for authentication URL or type the token directly
here.
</p>
<p :if={@auth_mode == :token}>
To use password authentication, set the <code>LIVEBOOK_PASSWORD</code>
environment variable.
</p>
</div>
<div class="text-gray-50 w-full">
<form method="post" class="flex flex-col w-full">
<input type="hidden" value={Phoenix.Controller.get_csrf_token()} name="_csrf_token" />
<div phx-feedback-for={@auth_mode} class={[@errors != [] && "show-errors"]}>
<input
:if={@auth_mode == :password}
type="password"
name="password"
class="px-4 py-2 w-full text-gray-300 placeholder-gray-400 border border-gray-500 rounded-lg bg-transparent phx-form-error:border-red-600 phx-form-error:text-red-600 phx-form-error:placeholder-red-600"
placeholder="Password"
autofocus
/>
<input
:if={@auth_mode == :token}
type="text"
name="token"
class="px-4 py-2 w-full text-gray-300 placeholder-gray-400 border border-gray-500 rounded-lg bg-transparent phx-form-error:border-red-600 phx-form-error:text-red-600 phx-form-error:placeholder-red-600"
placeholder="Token"
autofocus
/>
<span
:for={error <- @errors}
class="mt-1 hidden text-red-600 text-sm phx-form-error:block"
>
<%= translate_error(error) %>
</span>
</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-sub 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>
<button
type="submit"
class="mt-4 py-2 w-full rounded-lg text-gray-200 bg-blue-600 hover:bg-blue-700 focus:bg-blue-700"
>
<span>Authenticate</span>
<.remix_icon icon="arrow-right-line" class="ml-1 align-middle" />
</button>
</form>
</div>
</div>
</div>

View file

@ -263,4 +263,23 @@ defmodule LivebookWeb.AppHelpers do
defp zta_metadata(zta_provider) do
Enum.find(Livebook.Config.identity_providers(), &(&1.type == zta_provider))
end
@doc """
Updates app list with the given apps event.
"""
def update_app_list(apps, event)
def update_app_list(apps, {:app_created, app}) do
if app in apps, do: apps, else: [app | apps]
end
def update_app_list(apps, {:app_updated, app}) do
Enum.map(apps, fn other ->
if other.slug == app.slug, do: app, else: other
end)
end
def update_app_list(apps, {:app_closed, app}) do
Enum.reject(apps, &(&1.slug == app.slug))
end
end

View file

@ -49,7 +49,7 @@ defmodule LivebookWeb.AppLive do
</div>
</div>
<.modal id="sessions-modal" show width={:big} patch={~p"/"}>
<.modal id="sessions-modal" show width={:big} patch={~p"/apps"}>
<div class="p-6 max-w-4xl flex flex-col space-y-3">
<h3 class="text-2xl font-semibold text-gray-800">
<%= @app.notebook_name %>

View file

@ -107,12 +107,18 @@ defmodule LivebookWeb.AppSessionLive do
<.remix_icon icon="arrow-down-s-line" />
</button>
</:toggle>
<.menu_item>
<.menu_item :if={@livebook_authenticated?}>
<.link navigate={~p"/"} role="menuitem">
<.remix_icon icon="home-6-line" />
<span>Home</span>
</.link>
</.menu_item>
<.menu_item>
<.link navigate={~p"/apps"} role="menuitem">
<.remix_icon icon="layout-grid-fill" />
<span>Apps</span>
</.link>
</.menu_item>
<.menu_item :if={@data_view.multi_session}>
<.link navigate={~p"/apps/#{@data_view.slug}"} role="menuitem">
<.remix_icon icon="play-list-add-line" />

View file

@ -0,0 +1,270 @@
defmodule LivebookWeb.AppsDashboardLive do
use LivebookWeb, :live_view
import LivebookWeb.AppHelpers
alias LivebookWeb.LayoutHelpers
on_mount LivebookWeb.SidebarHook
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Livebook.Apps.subscribe()
end
apps = Livebook.Apps.list_apps()
{:ok, assign(socket, apps: apps, page_title: "Apps - Livebook")}
end
@impl true
def render(assigns) do
~H"""
<LayoutHelpers.layout
current_page={~p"/apps"}
current_user={@current_user}
saved_hubs={@saved_hubs}
>
<div class="p-4 md:px-12 md:py-7 max-w-screen-lg mx-auto">
<div class="flex items-center justify-between">
<LayoutHelpers.title text="Apps" />
<.link navigate={~p"/apps"} class="flex items-center text-blue-600">
<span class="font-semibold">Listing</span>
<.remix_icon icon="arrow-right-line" class="align-middle ml-1" />
</.link>
</div>
<div class="mt-10">
<.app_list apps={@apps} />
</div>
</div>
</LayoutHelpers.layout>
"""
end
defp app_list(%{apps: []} = assigns) do
~H"""
<.no_entries>
You do not have any apps running. <br />
You can deploy new apps by opening a notebook and clicking
<.remix_icon icon="rocket-line" class="align-top text-lg" /> in the sidebar.
</.no_entries>
"""
end
defp app_list(assigns) do
~H"""
<div class="flex flex-col space-y-4">
<div :for={app <- Enum.sort_by(@apps, & &1.slug)} data-app-slug={app.slug}>
<a
phx-click={JS.toggle(to: "[data-app-slug=#{app.slug}] .toggle")}
class="flex items-center justify-between break-all mb-2 text-gray-800 font-medium text-xl hover:cursor-pointer"
>
<%= "/" <> app.slug %>
<.remix_icon icon="arrow-drop-up-line" class="text-3xl text-gray-400 toggle" />
<.remix_icon icon="arrow-drop-down-line" class="text-3xl text-gray-400 hidden toggle" />
</a>
<div class="toggle">
<div class="mt-4 flex flex-col gap-3">
<.message_box :for={warning <- app.warnings} kind={:warning} message={warning} />
</div>
<div class="flex-col mb-8">
<div class="p-4 border-x border-t border-gray-200 rounded-t-lg ">
<div class="uppercase text-gray-500 text-sm font-medium leading-normal tracking-wider">
App Info
</div>
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-[minmax(0,_2fr)_minmax(0,_2fr)_minmax(0,_1fr)_minmax(0,_1fr)_minmax(0,_1fr)] gap-4 mt-3">
<div class="break-words">
<.labeled_text label="Name">
<%= app.notebook_name %>
</.labeled_text>
</div>
<div class="break-all">
<.labeled_text label="URL">
<a href={~p"/apps/#{app.slug}"}>
<%= ~p"/apps/#{app.slug}" %>
</a>
</.labeled_text>
</div>
<div>
<.labeled_text label="Latest version" one_line>
v<%= app.version %>
</.labeled_text>
</div>
<div>
<.labeled_text label="Session type" one_line>
<%= if(app.multi_session, do: "Multi", else: "Single") %>
</.labeled_text>
</div>
<div class="flex flex-col md:flex-row md:items-center justify-start lg:justify-end">
<span class="tooltip top" data-tooltip="Terminate">
<button
class="icon-button text-right"
aria-label="terminate app"
phx-click={JS.push("terminate_app", value: %{slug: app.slug})}
>
<.remix_icon icon="delete-bin-6-line" class="text-lg" />
</button>
</span>
</div>
</div>
</div>
<div class="border border-gray-200 rounded-b-lg overflow-auto tiny-scrollbar whitespace-none">
<%= if Enum.any?(app.sessions) do %>
<div class="uppercase text-gray-500 text-sm font-medium leading-normal tracking-wider px-4 pt-4 pb-3">
Running sessions
</div>
<.grid rows={app.sessions}>
<:col :let={app_session} label="Status">
<a
aria-label="debug app"
href={app_session.app_status == :error && ~p"/sessions/#{app_session.id}"}
target="_blank"
>
<.app_status status={app_session.app_status} />
</a>
</:col>
<:col :let={app_session} label="Uptime">
<%= format_datetime_relatively(app_session.created_at) %>
</:col>
<:col :let={app_session} label="Version">
v<%= app_session.version %>
</:col>
<:col :let={app_session} label="Clients">
<%= app_session.client_count %>
</:col>
<:actions :let={app_session}>
<span class="tooltip left" data-tooltip="Open">
<a
class={[
"icon-button",
app_session.app_status.lifecycle != :active && "disabled"
]}
aria-label="open app"
href={~p"/apps/#{app.slug}/#{app_session.id}"}
>
<.remix_icon icon="link" class="text-lg" />
</a>
</span>
<span class="tooltip left" data-tooltip="Debug">
<a
class="icon-button"
aria-label="debug app"
href={~p"/sessions/#{app_session.id}"}
>
<.remix_icon icon="terminal-line" class="text-lg" />
</a>
</span>
<%= if app_session.app_status.lifecycle == :active do %>
<span class="tooltip left" data-tooltip="Deactivate">
<button
class="icon-button"
aria-label="deactivate app session"
phx-click={
JS.push("deactivate_app_session",
value: %{slug: app.slug, session_id: app_session.id}
)
}
>
<.remix_icon icon="stop-circle-line" class="text-lg" />
</button>
</span>
<% else %>
<span class="tooltip left" data-tooltip="Terminate">
<button
class="icon-button"
aria-label="terminate app session"
phx-click={
JS.push("terminate_app_session",
value: %{slug: app.slug, session_id: app_session.id}
)
}
>
<.remix_icon icon="delete-bin-6-line" class="text-lg" />
</button>
</span>
<% end %>
</:actions>
</.grid>
<% else %>
<div class="p-4 uppercase text-gray-500 text-sm font-medium leading-normal tracking-wider">
No running sessions
</div>
<% end %>
</div>
</div>
</div>
</div>
</div>
"""
end
defp grid(assigns) do
~H"""
<div class="min-w-[650px]">
<div class="px-2 pb-2">
<div class="grid grid-cols-[minmax(0,_0.5fr)_minmax(0,_0.75fr)_minmax(0,_0.5fr)_minmax(0,_0.5fr)_minmax(0,_0.5fr)] md:grid-cols-[minmax(0,_2fr)_minmax(0,_2fr)_minmax(0,_1fr)_minmax(0,_1fr)_minmax(0,_1fr)] gap-4 px-2">
<div
:for={col <- @col}
class={["text-gray-500 text-sm font-normal", align_to_class(col[:align])]}
>
<%= col[:label] %>
</div>
</div>
<div :for={row <- @rows} class="whitespace-nowrap px-2 hover:bg-gray-50 hover:rounded-md">
<div class="grid grid-cols-[minmax(0,_0.5fr)_minmax(0,_0.75fr)_minmax(0,_0.5fr)_minmax(0,_0.5fr)_minmax(0,_0.5fr)] md:grid-cols-[minmax(0,_2fr)_minmax(0,_2fr)_minmax(0,_1fr)_minmax(0,_1fr)_minmax(0,_1fr)] gap-4">
<div
:for={col <- @col}
class={["py-2 text-gray-800 text-sm font-semibold", align_to_class(col[:align])]}
>
<%= render_slot(col, row) %>
</div>
<div class="py-2 flex flex-row items-center justify-end gap-2">
<%= render_slot(@actions, row) %>
</div>
</div>
</div>
</div>
</div>
"""
end
defp align_to_class(:right), do: "text-right"
defp align_to_class(:center), do: "text-center"
defp align_to_class(_), do: "text-left"
@impl true
def handle_info({type, _app} = event, socket)
when type in [:app_created, :app_updated, :app_closed] do
{:noreply, update(socket, :apps, &update_app_list(&1, event))}
end
def handle_info(_message, socket), do: {:noreply, socket}
@impl true
def handle_event("terminate_app", %{"slug" => slug}, socket) do
app = Enum.find(socket.assigns.apps, &(&1.slug == slug))
{:noreply, confirm_app_termination(socket, app.pid)}
end
def handle_event("terminate_app_session", %{"slug" => slug, "session_id" => session_id}, socket) do
app_session = find_app_session(socket.assigns.apps, slug, session_id)
Livebook.Session.close(app_session.pid)
{:noreply, socket}
end
def handle_event(
"deactivate_app_session",
%{"slug" => slug, "session_id" => session_id},
socket
) do
app_session = find_app_session(socket.assigns.apps, slug, session_id)
Livebook.Session.app_deactivate(app_session.pid)
{:noreply, socket}
end
defp find_app_session(apps, slug, session_id) do
app = Enum.find(apps, &(&1.slug == slug))
Enum.find(app.sessions, &(&1.id == session_id))
end
end

View file

@ -1,12 +1,6 @@
defmodule LivebookWeb.AppsLive do
use LivebookWeb, :live_view
import LivebookWeb.AppHelpers
alias LivebookWeb.LayoutHelpers
on_mount LivebookWeb.SidebarHook
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
@ -14,208 +8,74 @@ defmodule LivebookWeb.AppsLive do
end
apps = Livebook.Apps.list_apps()
empty_apps_path? = Livebook.Apps.empty_apps_path?()
{:ok, assign(socket, apps: apps, page_title: "Apps - Livebook")}
{:ok, assign(socket, apps: apps, empty_apps_path?: empty_apps_path?)}
end
@impl true
def render(assigns) do
~H"""
<LayoutHelpers.layout
current_page={~p"/apps"}
current_user={@current_user}
saved_hubs={@saved_hubs}
>
<div class="p-4 md:px-12 md:py-7 max-w-screen-lg mx-auto">
<LayoutHelpers.title text="Apps" />
<div class="mt-10">
<.app_list apps={@apps} />
<div class="h-full flex flex-col overflow-y-auto">
<div class="px-4 py-3 flex items-center justify-between">
<div class="w-10 h-10">
<.link navigate={~p"/"}>
<img src={~p"/images/logo.png"} height="40" widthz="40" alt="logo livebook" />
</.link>
</div>
<div>
<.link navigate={~p"/apps-dashboard"} class="flex items-center text-blue-600">
<span class="font-semibold">Dashboard</span>
<.remix_icon icon="arrow-right-line" class="align-middle ml-1" />
</.link>
</div>
</div>
</LayoutHelpers.layout>
"""
end
defp app_list(%{apps: []} = assigns) do
~H"""
<.no_entries>
You do not have any apps running. <br />
You can deploy new apps by opening a notebook and clicking
<.remix_icon icon="rocket-line" class="align-top text-lg" /> in the sidebar.
</.no_entries>
"""
end
defp app_list(assigns) do
~H"""
<div class="flex flex-col space-y-4">
<div :for={app <- Enum.sort_by(@apps, & &1.slug)} data-app-slug={app.slug}>
<a
phx-click={JS.toggle(to: "[data-app-slug=#{app.slug}] .toggle")}
class="flex items-center justify-between break-all mb-2 text-gray-800 font-medium text-xl hover:cursor-pointer"
>
<%= "/" <> app.slug %>
<.remix_icon icon="arrow-drop-up-line" class="text-3xl text-gray-400 toggle" />
<.remix_icon icon="arrow-drop-down-line" class="text-3xl text-gray-400 hidden toggle" />
</a>
<div class="toggle">
<div class="mt-4 flex flex-col gap-3">
<.message_box :for={warning <- app.warnings} kind={:warning} message={warning} />
</div>
<div class="flex-col mb-8">
<div class="p-4 border-x border-t border-gray-200 rounded-t-lg ">
<div class="uppercase text-gray-500 text-sm font-medium leading-normal tracking-wider">
App Info
</div>
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-[minmax(0,_2fr)_minmax(0,_2fr)_minmax(0,_1fr)_minmax(0,_1fr)_minmax(0,_1fr)] gap-4 mt-3">
<div class="break-words">
<.labeled_text label="Name">
<%= app.notebook_name %>
</.labeled_text>
</div>
<div class="break-all">
<.labeled_text label="URL">
<a href={~p"/apps/#{app.slug}"}>
<%= ~p"/apps/#{app.slug}" %>
</a>
</.labeled_text>
</div>
<div>
<.labeled_text label="Latest version" one_line>
v<%= app.version %>
</.labeled_text>
</div>
<div>
<.labeled_text label="Session type" one_line>
<%= if(app.multi_session, do: "Multi", else: "Single") %>
</.labeled_text>
</div>
<div class="flex flex-col md:flex-row md:items-center justify-start lg:justify-end">
<span class="tooltip top" data-tooltip="Terminate">
<button
class="icon-button text-right"
aria-label="terminate app"
phx-click={JS.push("terminate_app", value: %{slug: app.slug})}
>
<.remix_icon icon="delete-bin-6-line" class="text-lg" />
</button>
</span>
</div>
</div>
</div>
<div class="border border-gray-200 rounded-b-lg overflow-auto tiny-scrollbar whitespace-none">
<%= if Enum.any?(app.sessions) do %>
<div class="uppercase text-gray-500 text-sm font-medium leading-normal tracking-wider px-4 pt-4 pb-3">
Running sessions
</div>
<.grid rows={app.sessions}>
<:col :let={app_session} label="Status">
<a
aria-label="debug app"
href={app_session.app_status == :error && ~p"/sessions/#{app_session.id}"}
target="_blank"
>
<.app_status status={app_session.app_status} />
</a>
</:col>
<:col :let={app_session} label="Uptime">
<%= format_datetime_relatively(app_session.created_at) %>
</:col>
<:col :let={app_session} label="Version">
v<%= app_session.version %>
</:col>
<:col :let={app_session} label="Clients">
<%= app_session.client_count %>
</:col>
<:actions :let={app_session}>
<span class="tooltip left" data-tooltip="Open">
<a
class={[
"icon-button",
app_session.app_status.lifecycle != :active && "disabled"
]}
aria-label="open app"
href={~p"/apps/#{app.slug}/#{app_session.id}"}
>
<.remix_icon icon="link" class="text-lg" />
</a>
</span>
<span class="tooltip left" data-tooltip="Debug">
<a
class="icon-button"
aria-label="debug app"
href={~p"/sessions/#{app_session.id}"}
>
<.remix_icon icon="terminal-line" class="text-lg" />
</a>
</span>
<%= if app_session.app_status.lifecycle == :active do %>
<span class="tooltip left" data-tooltip="Deactivate">
<button
class="icon-button"
aria-label="deactivate app session"
phx-click={
JS.push("deactivate_app_session",
value: %{slug: app.slug, session_id: app_session.id}
)
}
>
<.remix_icon icon="stop-circle-line" class="text-lg" />
</button>
</span>
<% else %>
<span class="tooltip left" data-tooltip="Terminate">
<button
class="icon-button"
aria-label="terminate app session"
phx-click={
JS.push("terminate_app_session",
value: %{slug: app.slug, session_id: app_session.id}
)
}
>
<.remix_icon icon="delete-bin-6-line" class="text-lg" />
</button>
</span>
<% end %>
</:actions>
</.grid>
<% else %>
<div class="p-4 uppercase text-gray-500 text-sm font-medium leading-normal tracking-wider">
No running sessions
</div>
<% end %>
<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>
</div>
</div>
</div>
</div>
"""
end
defp grid(assigns) do
~H"""
<div class="min-w-[650px]">
<div class="px-2 pb-2">
<div class="grid grid-cols-[minmax(0,_0.5fr)_minmax(0,_0.75fr)_minmax(0,_0.5fr)_minmax(0,_0.5fr)_minmax(0,_0.5fr)] md:grid-cols-[minmax(0,_2fr)_minmax(0,_2fr)_minmax(0,_1fr)_minmax(0,_1fr)_minmax(0,_1fr)] gap-4 px-2">
<div
:for={col <- @col}
class={["text-gray-500 text-sm font-normal", align_to_class(col[:align])]}
:if={@apps == [] and not @empty_apps_path?}
class="mt-5 flex flex-col w-full max-w-[400px]"
>
<%= col[:label] %>
<.no_entries :if={@apps == []}>
No apps running.
</.no_entries>
</div>
</div>
<div :for={row <- @rows} class="whitespace-nowrap px-2 hover:bg-gray-50 hover:rounded-md">
<div class="grid grid-cols-[minmax(0,_0.5fr)_minmax(0,_0.75fr)_minmax(0,_0.5fr)_minmax(0,_0.5fr)_minmax(0,_0.5fr)] md:grid-cols-[minmax(0,_2fr)_minmax(0,_2fr)_minmax(0,_1fr)_minmax(0,_1fr)_minmax(0,_1fr)] gap-4">
<div
:for={col <- @col}
class={["py-2 text-gray-800 text-sm font-semibold", align_to_class(col[:align])]}
>
<%= render_slot(col, row) %>
</div>
<div class="py-2 flex flex-row items-center justify-end gap-2">
<%= render_slot(@actions, row) %>
<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>
</div>
</div>
@ -223,56 +83,15 @@ defmodule LivebookWeb.AppsLive do
"""
end
defp align_to_class(:right), do: "text-right"
defp align_to_class(:center), do: "text-center"
defp align_to_class(_), do: "text-left"
@impl true
def handle_info({type, _app} = event, socket)
when type in [:app_created, :app_updated, :app_closed] do
{:noreply, update(socket, :apps, &update_app_list(&1, event))}
{:noreply, update(socket, :apps, &LivebookWeb.AppHelpers.update_app_list(&1, event))}
end
def handle_info(_message, socket), do: {:noreply, socket}
@impl true
def handle_event("terminate_app", %{"slug" => slug}, socket) do
app = Enum.find(socket.assigns.apps, &(&1.slug == slug))
{:noreply, confirm_app_termination(socket, app.pid)}
end
def handle_event("terminate_app_session", %{"slug" => slug, "session_id" => session_id}, socket) do
app_session = find_app_session(socket.assigns.apps, slug, session_id)
Livebook.Session.close(app_session.pid)
{:noreply, socket}
end
def handle_event(
"deactivate_app_session",
%{"slug" => slug, "session_id" => session_id},
socket
) do
app_session = find_app_session(socket.assigns.apps, slug, session_id)
Livebook.Session.app_deactivate(app_session.pid)
{:noreply, socket}
end
defp find_app_session(apps, slug, session_id) do
app = Enum.find(apps, &(&1.slug == slug))
Enum.find(app.sessions, &(&1.id == session_id))
end
def update_app_list(apps, {:app_created, app}) do
if app in apps, do: apps, else: [app | apps]
end
def update_app_list(apps, {:app_updated, app}) do
Enum.map(apps, fn other ->
if other.slug == app.slug, do: app, else: other
end)
end
def update_app_list(apps, {:app_closed, app}) do
Enum.reject(apps, &(&1.slug == app.slug))
defp apps_listing(apps) do
Enum.sort_by(apps, & &1.notebook_name)
end
end

View file

@ -1,34 +0,0 @@
defmodule LivebookWeb.AuthAppListLive do
use LivebookWeb, :live_view
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Livebook.Apps.subscribe()
end
apps = Livebook.Apps.list_apps()
{:ok, assign(socket, apps: apps), layout: false}
end
@impl true
def render(assigns) do
~H"""
<div class="w-full flex flex-col space-y-4">
<.link
:for={app <- visible_apps(@apps)}
href={~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>
"""
end
defp visible_apps(apps) do
Enum.sort_by(apps, & &1.notebook_name)
end
end

View file

@ -60,6 +60,11 @@ defmodule LivebookWeb.AppAuthHook do
end
end
# Skip auth for non-app-specific routes
def on_mount(:default, %{}, _session, socket) do
{:cont, socket}
end
defp livebook_authenticated?(session, socket) do
uri = get_connect_info(socket, :uri)
LivebookWeb.AuthPlug.authenticated?(session, uri.port, Livebook.Config.auth_mode())

View file

@ -98,7 +98,12 @@ defmodule LivebookWeb.LayoutHelpers do
</span>
</div>
<.sidebar_link title="Home" icon="home-6-line" to={~p"/"} current={@current_page} />
<.sidebar_link title="Apps" icon="rocket-line" to={~p"/apps"} current={@current_page} />
<.sidebar_link
title="Apps"
icon="rocket-line"
to={~p"/apps-dashboard"}
current={@current_page}
/>
<.sidebar_link title="Learn" icon="article-line" to={~p"/learn"} current={@current_page} />
<.sidebar_link
title="Settings"

View file

@ -73,6 +73,19 @@ defmodule LivebookWeb.AuthPlug do
end
end
defp redirect_to_authenticate(%{path_info: []} = conn) do
path =
if Livebook.Apps.list_apps() != [] or Livebook.Apps.empty_apps_path?() do
~p"/apps"
else
~p"/authenticate"
end
conn
|> redirect(to: path)
|> halt()
end
defp redirect_to_authenticate(conn) do
conn
|> then(fn

View file

@ -73,7 +73,7 @@ defmodule LivebookWeb.Router do
live "/learn", LearnLive, :page
live "/learn/notebooks/:slug", LearnLive, :notebook
live "/apps", AppsLive, :page
live "/apps-dashboard", AppsDashboardLive, :page
live "/hub", Hub.NewLive, :new, as: :hub
live "/hub/:id", Hub.EditLive, :edit, as: :hub
@ -130,6 +130,8 @@ defmodule LivebookWeb.Router do
live "/apps/:slug/:id", AppSessionLive, :page
live "/apps/:slug/:id/source", AppSessionLive, :source
live "/apps", AppsLive, :page
end
end

View file

@ -31,7 +31,7 @@
"phoenix_html": {:hex, :phoenix_html, "3.3.2", "d6ce982c6d8247d2fc0defe625255c721fb8d5f1942c5ac051f6177bffa5973f", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "44adaf8e667c1c20fb9d284b6b0fa8dc7946ce29e81ce621860aa7e96de9a11d"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.2", "b9e33c950d1ed98494bfbde1c34c6e51c8a4214f3bea3f07ca9a510643ee1387", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "67a598441b5f583d301a77e0298719f9654887d3d8bf14e80ff0b6acf887ef90"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"},
"phoenix_live_view": {:git, "https://github.com/phoenixframework/phoenix_live_view.git", "2df6832a4f93a730e47f25bb39a57b6714f9da32", []},
"phoenix_live_view": {:git, "https://github.com/phoenixframework/phoenix_live_view.git", "7efbe52dd345de7fa3657f56969eafadd8c3ac50", []},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
"phoenix_template": {:hex, :phoenix_template, "1.0.1", "85f79e3ad1b0180abb43f9725973e3b8c2c3354a87245f91431eec60553ed3ef", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "157dc078f6226334c91cb32c1865bf3911686f8bcd6bcff86736f6253e6993ee"},
"plug": {:hex, :plug, "1.15.1", "b7efd81c1a1286f13efb3f769de343236bd8b7d23b4a9f40d3002fc39ad8f74c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "459497bd94d041d98d948054ec6c0b76feacd28eec38b219ca04c0de13c79d30"},

View file

@ -1,4 +1,4 @@
defmodule LivebookWeb.AppsLiveTest do
defmodule LivebookWeb.AppsDashboardLiveTest do
use LivebookWeb.ConnCase, async: true
import Phoenix.LiveViewTest
@ -11,7 +11,7 @@ defmodule LivebookWeb.AppsLiveTest do
app_settings = %{Notebook.AppSettings.new() | slug: slug}
notebook = %{Notebook.new() | app_settings: app_settings, name: "My app #{slug}"}
{:ok, view, _} = live(conn, ~p"/apps")
{:ok, view, _} = live(conn, ~p"/apps-dashboard")
refute render(view) =~ slug
@ -32,7 +32,7 @@ defmodule LivebookWeb.AppsLiveTest do
app_settings = %{Notebook.AppSettings.new() | slug: slug}
notebook = %{Notebook.new() | app_settings: app_settings, name: "My app #{slug}"}
{:ok, view, _} = live(conn, ~p"/apps")
{:ok, view, _} = live(conn, ~p"/apps-dashboard")
Apps.subscribe()
{:ok, app_pid} = Apps.deploy(notebook)
@ -53,7 +53,7 @@ defmodule LivebookWeb.AppsLiveTest do
app_settings = %{Notebook.AppSettings.new() | slug: slug}
notebook = %{Notebook.new() | app_settings: app_settings, name: "My app #{slug}"}
{:ok, view, _} = live(conn, ~p"/apps")
{:ok, view, _} = live(conn, ~p"/apps-dashboard")
Apps.subscribe()
{:ok, app_pid} = Apps.deploy(notebook)