mirror of
https://github.com/livebook-dev/livebook.git
synced 2024-09-20 10:05:57 +08:00
Separate apps from auth and improve apps navigation (#2322)
This commit is contained in:
parent
a05d6c5059
commit
07aaea11b4
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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" />
|
||||
|
|
270
lib/livebook_web/live/apps_dashboard_live.ex
Normal file
270
lib/livebook_web/live/apps_dashboard_live.ex
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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())
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
2
mix.lock
2
mix.lock
|
@ -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"},
|
||||
|
|
|
@ -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)
|
Loading…
Reference in a new issue