mirror of
https://github.com/livebook-dev/livebook.git
synced 2024-12-24 08:24:52 +08:00
Add page listing apps (#1733)
This commit is contained in:
parent
a36b0f8d48
commit
1676d5469e
18 changed files with 569 additions and 173 deletions
|
@ -4,7 +4,7 @@ defmodule Livebook.Apps do
|
|||
alias Livebook.Session
|
||||
|
||||
@doc """
|
||||
Registers a app session under the given slug.
|
||||
Registers an app session under the given slug.
|
||||
|
||||
In case another app is already registered under the given slug,
|
||||
this function atomically replaces the registration and instructs
|
||||
|
@ -21,7 +21,7 @@ defmodule Livebook.Apps do
|
|||
|
||||
pid ->
|
||||
:global.unregister_name(name)
|
||||
Session.app_shutdown(pid)
|
||||
Session.app_unregistered(pid)
|
||||
end
|
||||
|
||||
:yes = :global.register_name(name, session_pid)
|
||||
|
@ -30,6 +30,26 @@ defmodule Livebook.Apps do
|
|||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Unregisters an app session from the given slug.
|
||||
"""
|
||||
@spec unregister(pid(), String.t()) :: :ok
|
||||
def unregister(session_pid, slug) do
|
||||
name = name(slug)
|
||||
|
||||
:global.trans({{:app_registration, name}, node()}, fn ->
|
||||
case :global.whereis_name(name) do
|
||||
:undefined ->
|
||||
:ok
|
||||
|
||||
^session_pid ->
|
||||
:global.unregister_name(name)
|
||||
end
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if app with the given slug exists.
|
||||
"""
|
||||
|
|
|
@ -55,7 +55,8 @@ defmodule Livebook.Session do
|
|||
:mode,
|
||||
:images_dir,
|
||||
:created_at,
|
||||
:memory_usage
|
||||
:memory_usage,
|
||||
:app_info
|
||||
]
|
||||
|
||||
use GenServer, restart: :temporary
|
||||
|
@ -81,7 +82,8 @@ defmodule Livebook.Session do
|
|||
mode: Data.session_mode(),
|
||||
images_dir: FileSystem.File.t(),
|
||||
created_at: DateTime.t(),
|
||||
memory_usage: memory_usage()
|
||||
memory_usage: memory_usage(),
|
||||
app_info: app_info() | nil
|
||||
}
|
||||
|
||||
@type state :: %{
|
||||
|
@ -109,6 +111,12 @@ defmodule Livebook.Session do
|
|||
system: Livebook.SystemResources.memory()
|
||||
}
|
||||
|
||||
@type app_info :: %{
|
||||
slug: String.t(),
|
||||
status: Data.app_status(),
|
||||
registered: boolean()
|
||||
}
|
||||
|
||||
@typedoc """
|
||||
An id assigned to every running session process.
|
||||
"""
|
||||
|
@ -681,9 +689,20 @@ defmodule Livebook.Session do
|
|||
The shutdown is graceful, so the app only terminates once all of the
|
||||
currently connected clients leave.
|
||||
"""
|
||||
@spec app_shutdown(pid()) :: :ok
|
||||
def app_shutdown(pid) do
|
||||
GenServer.cast(pid, {:app_shutdown, self()})
|
||||
@spec app_unregistered(pid()) :: :ok
|
||||
def app_unregistered(pid) do
|
||||
GenServer.cast(pid, {:app_unregistered, self()})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends a app stop request to the server.
|
||||
|
||||
This results in the app being unregistered under the given slug,
|
||||
however it is still running.
|
||||
"""
|
||||
@spec app_stop(pid()) :: :ok
|
||||
def app_stop(pid) do
|
||||
GenServer.cast(pid, {:app_stop, self()})
|
||||
end
|
||||
|
||||
## Callbacks
|
||||
|
@ -1209,8 +1228,13 @@ defmodule Livebook.Session do
|
|||
{:noreply, handle_operation(state, operation)}
|
||||
end
|
||||
|
||||
def handle_cast({:app_shutdown, _client_pid}, state) do
|
||||
operation = {:app_shutdown, @client_id}
|
||||
def handle_cast({:app_unregistered, _client_pid}, state) do
|
||||
operation = {:app_unregistered, @client_id}
|
||||
{:noreply, handle_operation(state, operation)}
|
||||
end
|
||||
|
||||
def handle_cast({:app_stop, _client_pid}, state) do
|
||||
operation = {:app_stop, @client_id}
|
||||
{:noreply, handle_operation(state, operation)}
|
||||
end
|
||||
|
||||
|
@ -1476,7 +1500,15 @@ defmodule Livebook.Session do
|
|||
mode: state.data.mode,
|
||||
images_dir: images_dir_from_state(state),
|
||||
created_at: state.created_at,
|
||||
memory_usage: state.memory_usage
|
||||
memory_usage: state.memory_usage,
|
||||
app_info:
|
||||
if state.data.mode == :app do
|
||||
%{
|
||||
slug: state.data.notebook.app_settings.slug,
|
||||
status: state.data.app_data.status,
|
||||
registered: state.data.app_data.registered
|
||||
}
|
||||
end
|
||||
}
|
||||
end
|
||||
|
||||
|
@ -1767,7 +1799,7 @@ defmodule Livebook.Session do
|
|||
state
|
||||
end
|
||||
|
||||
defp after_operation(state, _prev_state, {:app_shutdown, _client_id}) do
|
||||
defp after_operation(state, _prev_state, {:app_unregistered, _client_id}) do
|
||||
broadcast_app_message(state.session_id, {:app_registration_changed, state.session_id, false})
|
||||
|
||||
state
|
||||
|
@ -1875,14 +1907,21 @@ defmodule Livebook.Session do
|
|||
status = state.data.app_data.status
|
||||
broadcast_app_message(state.session_id, {:app_status_changed, state.session_id, status})
|
||||
|
||||
state
|
||||
notify_update(state)
|
||||
end
|
||||
|
||||
defp handle_action(state, :app_register) do
|
||||
Livebook.Apps.register(self(), state.data.notebook.app_settings.slug)
|
||||
broadcast_app_message(state.session_id, {:app_registration_changed, state.session_id, true})
|
||||
|
||||
state
|
||||
notify_update(state)
|
||||
end
|
||||
|
||||
defp handle_action(state, :app_unregister) do
|
||||
Livebook.Apps.unregister(self(), state.data.notebook.app_settings.slug)
|
||||
broadcast_app_message(state.session_id, {:app_registration_changed, state.session_id, false})
|
||||
|
||||
notify_update(state)
|
||||
end
|
||||
|
||||
defp handle_action(state, :app_recover) do
|
||||
|
|
|
@ -153,7 +153,7 @@ defmodule Livebook.Session.Data do
|
|||
registered: boolean()
|
||||
}
|
||||
|
||||
@type app_status :: :booting | :running | :error | :shutting_down
|
||||
@type app_status :: :booting | :running | :error | :shutting_down | :stopped
|
||||
|
||||
@type app_data :: %{
|
||||
status: app_status(),
|
||||
|
@ -216,7 +216,8 @@ defmodule Livebook.Session.Data do
|
|||
| {:set_app_status, client_id(), Livebook.Session.id(), app_status()}
|
||||
| {:set_app_registered, client_id(), Livebook.Session.id(), boolean()}
|
||||
| {:delete_app, client_id(), Livebook.Session.id()}
|
||||
| {:app_shutdown, client_id()}
|
||||
| {:app_unregistered, client_id()}
|
||||
| {:app_stop, client_id()}
|
||||
|
||||
@type action ::
|
||||
:connect_runtime
|
||||
|
@ -881,12 +882,24 @@ defmodule Livebook.Session.Data do
|
|||
end
|
||||
end
|
||||
|
||||
def apply_operation(data, {:app_shutdown, _client_id}) do
|
||||
def apply_operation(data, {:app_unregistered, _client_id}) do
|
||||
with :app <- data.mode,
|
||||
true <- data.app_data.registered do
|
||||
data
|
||||
|> with_actions()
|
||||
|> app_unregistered()
|
||||
|> app_maybe_terminate()
|
||||
|> wrap_ok()
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
def apply_operation(data, {:app_stop, _client_id}) do
|
||||
with :app <- data.mode do
|
||||
data
|
||||
|> with_actions()
|
||||
|> app_shutdown()
|
||||
|> app_maybe_terminate()
|
||||
|> app_stop()
|
||||
|> wrap_ok()
|
||||
else
|
||||
_ -> :error
|
||||
|
@ -1772,12 +1785,27 @@ defmodule Livebook.Session.Data do
|
|||
set!(data_actions, apps: apps)
|
||||
end
|
||||
|
||||
defp app_shutdown(data_actions) do
|
||||
defp app_unregistered(data_actions) do
|
||||
data_actions
|
||||
|> set_app_data!(status: :shutting_down, registered: false)
|
||||
|> add_action(:app_broadcast_status)
|
||||
end
|
||||
|
||||
defp app_stop({data, _} = data_actions) do
|
||||
data_actions =
|
||||
data_actions
|
||||
|> set_app_data!(status: :stopped)
|
||||
|> add_action(:app_broadcast_status)
|
||||
|
||||
if data.app_data.registered do
|
||||
data_actions
|
||||
|> set_app_data!(registered: false)
|
||||
|> add_action(:app_unregister)
|
||||
else
|
||||
data_actions
|
||||
end
|
||||
end
|
||||
|
||||
defp app_maybe_terminate({data, _} = data_actions) do
|
||||
if data.mode == :app and data.app_data.status == :shutting_down and data.clients_map == %{} do
|
||||
add_action(data_actions, :app_terminate)
|
||||
|
@ -2327,7 +2355,7 @@ defmodule Livebook.Session.Data do
|
|||
do: data_actions
|
||||
|
||||
defp app_compute_status({data, _} = data_actions)
|
||||
when data.app_data.status == :shutting_down,
|
||||
when data.app_data.status in [:shutting_down, :stopped],
|
||||
do: data_actions
|
||||
|
||||
defp app_compute_status({data, _} = data_actions) do
|
||||
|
|
|
@ -554,6 +554,40 @@ defmodule LivebookWeb.CoreComponents do
|
|||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an status indicator circle.
|
||||
"""
|
||||
attr :variant, :atom,
|
||||
required: true,
|
||||
values: [:success, :warning, :error, :inactive, :waiting, :progressing]
|
||||
|
||||
def status_indicator(assigns) do
|
||||
~H"""
|
||||
<span class="relative flex h-3 w-3">
|
||||
<span
|
||||
:if={animated_circle_class(@variant)}
|
||||
class={[
|
||||
animated_circle_class(@variant),
|
||||
"animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
|
||||
]}
|
||||
>
|
||||
</span>
|
||||
<span class={[circle_class(@variant), "relative inline-flex rounded-full h-3 w-3"]}></span>
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp circle_class(:success), do: "bg-green-bright-400"
|
||||
defp circle_class(:warning), do: "bg-yellow-bright-200"
|
||||
defp circle_class(:error), do: "bg-red-400"
|
||||
defp circle_class(:inactive), do: "bg-gray-500"
|
||||
defp circle_class(:waiting), do: "bg-gray-400"
|
||||
defp circle_class(:progressing), do: "bg-blue-500"
|
||||
|
||||
defp animated_circle_class(:waiting), do: "bg-gray-300"
|
||||
defp animated_circle_class(:progressing), do: "bg-blue-400"
|
||||
defp animated_circle_class(_other), do: nil
|
||||
|
||||
# JS commands
|
||||
|
||||
@doc """
|
||||
|
|
47
lib/livebook_web/live/app_helpers.ex
Normal file
47
lib/livebook_web/live/app_helpers.ex
Normal file
|
@ -0,0 +1,47 @@
|
|||
defmodule LivebookWeb.AppHelpers do
|
||||
use LivebookWeb, :html
|
||||
|
||||
@doc """
|
||||
Renders app status with indicator.
|
||||
"""
|
||||
attr :status, :atom, required: true
|
||||
|
||||
def app_status(%{status: :booting} = assigns) do
|
||||
~H"""
|
||||
<.app_status_indicator text="Booting" variant={:progressing} />
|
||||
"""
|
||||
end
|
||||
|
||||
def app_status(%{status: :running} = assigns) do
|
||||
~H"""
|
||||
<.app_status_indicator text="Running" variant={:success} />
|
||||
"""
|
||||
end
|
||||
|
||||
def app_status(%{status: :error} = assigns) do
|
||||
~H"""
|
||||
<.app_status_indicator text="Error" variant={:error} />
|
||||
"""
|
||||
end
|
||||
|
||||
def app_status(%{status: :shutting_down} = assigns) do
|
||||
~H"""
|
||||
<.app_status_indicator text="Shutting down" variant={:inactive} />
|
||||
"""
|
||||
end
|
||||
|
||||
def app_status(%{status: :stopped} = assigns) do
|
||||
~H"""
|
||||
<.app_status_indicator text="Stopped" variant={:inactive} />
|
||||
"""
|
||||
end
|
||||
|
||||
defp app_status_indicator(assigns) do
|
||||
~H"""
|
||||
<div class="flex items-center space-x-2">
|
||||
<div><%= @text %></div>
|
||||
<.status_indicator variant={@variant} />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
|
@ -127,7 +127,6 @@ defmodule LivebookWeb.AppLive do
|
|||
<div style="height: 80vh"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden md:flex invisible w-[80px]"></div>
|
||||
</div>
|
||||
|
||||
<.modal
|
||||
|
|
155
lib/livebook_web/live/apps_live.ex
Normal file
155
lib/livebook_web/live/apps_live.ex
Normal file
|
@ -0,0 +1,155 @@
|
|||
defmodule LivebookWeb.AppsLive do
|
||||
use LivebookWeb, :live_view
|
||||
|
||||
import LivebookWeb.AppHelpers
|
||||
import LivebookWeb.SessionHelpers
|
||||
|
||||
alias LivebookWeb.LayoutHelpers
|
||||
alias Livebook.Sessions
|
||||
|
||||
on_mount LivebookWeb.SidebarHook
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
if connected?(socket) do
|
||||
Livebook.Sessions.subscribe()
|
||||
end
|
||||
|
||||
sessions =
|
||||
Sessions.list_sessions()
|
||||
|> Enum.filter(&(&1.mode == :app))
|
||||
|> Enum.sort_by(& &1.created_at, {:desc, DateTime})
|
||||
|
||||
{:ok, assign(socket, sessions: sessions, page_title: "Livebook - Apps")}
|
||||
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 space-y-4">
|
||||
<div>
|
||||
<LayoutHelpers.title text="Apps" />
|
||||
<p class="mt-4 mb-8 text-gray-700">
|
||||
<%= if @sessions == [] do %>
|
||||
No apps currently running.
|
||||
<% else %>
|
||||
These apps are currently running.
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-6">
|
||||
<div :for={{slug, sessions} <- group_apps(@sessions)}>
|
||||
<div class="mb-2 text-gray-800 font-medium text-lg">
|
||||
<%= "/" <> slug %>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<%= for {session, idx} <- Enum.with_index(sessions) do %>
|
||||
<div :if={idx > 0} class="ml-4 border-l-2 border-gray-300 border-dashed h-6"></div>
|
||||
<.app_box session={session} />
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutHelpers.layout>
|
||||
"""
|
||||
end
|
||||
|
||||
defp app_box(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class="border border-gray-200 rounded-lg flex justify-between p-4"
|
||||
data-app-slug={@session.app_info.slug}
|
||||
>
|
||||
<div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-8 w-full max-w-2xl">
|
||||
<div class="flex-1">
|
||||
<.labeled_text label="Status">
|
||||
<.app_status status={@session.app_info.status} />
|
||||
</.labeled_text>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<.labeled_text label="Name">
|
||||
<%= @session.notebook_name %>
|
||||
</.labeled_text>
|
||||
</div>
|
||||
<div class="flex-1 grow-[2]">
|
||||
<.labeled_text label="URL">
|
||||
<%= if @session.app_info.registered do %>
|
||||
<a href={url(~p"/apps/#{@session.app_info.slug}")} target="_blank">
|
||||
<%= url(~p"/apps/#{@session.app_info.slug}") %>
|
||||
</a>
|
||||
<% else %>
|
||||
-
|
||||
<% end %>
|
||||
</.labeled_text>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row gap-2">
|
||||
<span class="tooltip top" data-tooltip="Debug">
|
||||
<a
|
||||
class="icon-button"
|
||||
aria-label="debug app"
|
||||
href={~p"/sessions/#{@session.id}"}
|
||||
target="_blank"
|
||||
>
|
||||
<.remix_icon icon="terminal-line" class="text-lg" />
|
||||
</a>
|
||||
</span>
|
||||
<%= if @session.app_info.registered do %>
|
||||
<span class="tooltip top" data-tooltip="Stop">
|
||||
<button
|
||||
class="icon-button"
|
||||
aria-label="stop app"
|
||||
phx-click={JS.push("stop_app", value: %{session_id: @session.id})}
|
||||
>
|
||||
<.remix_icon icon="stop-circle-line" class="text-lg" />
|
||||
</button>
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="tooltip top" data-tooltip="Terminate">
|
||||
<button
|
||||
class="icon-button"
|
||||
aria-label="terminate app"
|
||||
phx-click={JS.push("terminate_app", value: %{session_id: @session.id})}
|
||||
>
|
||||
<.remix_icon icon="delete-bin-6-line" class="text-lg" />
|
||||
</button>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({type, session} = event, socket)
|
||||
when type in [:session_created, :session_updated, :session_closed] and session.mode == :app do
|
||||
{:noreply, update(socket, :sessions, &update_session_list(&1, event))}
|
||||
end
|
||||
|
||||
def handle_info(_message, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def handle_event("terminate_app", %{"session_id" => session_id}, socket) do
|
||||
session = Enum.find(socket.assigns.sessions, &(&1.id == session_id))
|
||||
Livebook.Session.close(session.pid)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("stop_app", %{"session_id" => session_id}, socket) do
|
||||
session = Enum.find(socket.assigns.sessions, &(&1.id == session_id))
|
||||
Livebook.Session.app_stop(session.pid)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp group_apps(sessions) do
|
||||
sessions
|
||||
|> Enum.group_by(& &1.app_info.slug)
|
||||
|> Enum.sort_by(&elem(&1, 0))
|
||||
end
|
||||
end
|
|
@ -369,26 +369,11 @@ defmodule LivebookWeb.HomeLive do
|
|||
{:noreply, assign(socket, file: file, file_info: file_info)}
|
||||
end
|
||||
|
||||
def handle_info({:session_created, session}, socket) do
|
||||
if session in socket.assigns.sessions do
|
||||
{:noreply, socket}
|
||||
else
|
||||
{:noreply, assign(socket, sessions: [session | socket.assigns.sessions])}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info({:session_updated, session}, socket) do
|
||||
sessions =
|
||||
Enum.map(socket.assigns.sessions, fn other ->
|
||||
if other.id == session.id, do: session, else: other
|
||||
end)
|
||||
|
||||
{:noreply, assign(socket, sessions: sessions)}
|
||||
end
|
||||
|
||||
def handle_info({:session_closed, session}, socket) do
|
||||
sessions = Enum.reject(socket.assigns.sessions, &(&1.id == session.id))
|
||||
{:noreply, assign(socket, sessions: sessions)}
|
||||
@impl true
|
||||
def handle_info({type, session} = event, socket)
|
||||
when type in [:session_created, :session_updated, :session_closed] and
|
||||
session.mode == :default do
|
||||
{:noreply, update(socket, :sessions, &update_session_list(&1, event))}
|
||||
end
|
||||
|
||||
def handle_info({:import_content, content, session_opts}, socket) do
|
||||
|
|
|
@ -99,6 +99,7 @@ defmodule LivebookWeb.LayoutHelpers do
|
|||
</div>
|
||||
<.sidebar_link title="Home" icon="home-6-line" to={~p"/"} current={@current_page} />
|
||||
<.sidebar_link title="Learn" icon="article-line" to={~p"/learn"} current={@current_page} />
|
||||
<.sidebar_link title="Apps" icon="rocket-line" to={~p"/apps"} current={@current_page} />
|
||||
<.sidebar_link
|
||||
title="Settings"
|
||||
icon="settings-3-line"
|
||||
|
|
|
@ -73,4 +73,29 @@ defmodule LivebookWeb.SessionHelpers do
|
|||
|
||||
def uses_memory?(%{runtime: %{total: total}}) when total > 0, do: true
|
||||
def uses_memory?(_), do: false
|
||||
|
||||
@doc """
|
||||
Updates a list of sessions based on the given `Sessions` event.
|
||||
"""
|
||||
@spec update_session_list(
|
||||
list(Session.t()),
|
||||
{:session_created | :session_updated | :session_closed, Session.t()}
|
||||
) :: list(Session.t())
|
||||
def update_session_list(sessions, {:session_created, session}) do
|
||||
if session in sessions do
|
||||
sessions
|
||||
else
|
||||
[session | sessions]
|
||||
end
|
||||
end
|
||||
|
||||
def update_session_list(sessions, {:session_updated, session}) do
|
||||
Enum.map(sessions, fn other ->
|
||||
if other.id == session.id, do: session, else: other
|
||||
end)
|
||||
end
|
||||
|
||||
def update_session_list(sessions, {:session_closed, session}) do
|
||||
Enum.reject(sessions, &(&1.id == session.id))
|
||||
end
|
||||
end
|
||||
|
|
|
@ -550,7 +550,7 @@ defmodule LivebookWeb.SessionLive do
|
|||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<.session_status
|
||||
<.section_status
|
||||
status={elem(section_item.status, 0)}
|
||||
cell_id={elem(section_item.status, 1)}
|
||||
/>
|
||||
|
@ -720,44 +720,23 @@ defmodule LivebookWeb.SessionLive do
|
|||
"""
|
||||
end
|
||||
|
||||
defp session_status(%{status: :evaluating} = assigns) do
|
||||
defp section_status(%{status: :evaluating} = assigns) do
|
||||
~H"""
|
||||
<button data-el-focus-cell-button data-target={@cell_id}>
|
||||
<.status_indicator circle_class="bg-blue-500" animated_circle_class="bg-blue-400">
|
||||
</.status_indicator>
|
||||
<.status_indicator variant={:progressing} />
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
defp session_status(%{status: :stale} = assigns) do
|
||||
defp section_status(%{status: :stale} = assigns) do
|
||||
~H"""
|
||||
<button data-el-focus-cell-button data-target={@cell_id}>
|
||||
<.status_indicator circle_class="bg-yellow-bright-200"></.status_indicator>
|
||||
<.status_indicator variant={:warning} />
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
defp session_status(assigns), do: ~H""
|
||||
|
||||
defp status_indicator(assigns) do
|
||||
assigns = assign_new(assigns, :animated_circle_class, fn -> nil end)
|
||||
|
||||
~H"""
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="flex relative h-3 w-3">
|
||||
<span
|
||||
:if={@animated_circle_class}
|
||||
class={[
|
||||
@animated_circle_class,
|
||||
"animate-ping absolute inline-flex h-3 w-3 rounded-full opacity-75"
|
||||
]}
|
||||
>
|
||||
</span>
|
||||
<span class={[@circle_class, "relative inline-flex rounded-full h-3 w-3"]}></span>
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
defp section_status(assigns), do: ~H""
|
||||
|
||||
defp settings_component_for(%Cell.Code{}),
|
||||
do: LivebookWeb.SessionLive.CodeCellSettingsComponent
|
||||
|
@ -2055,4 +2034,5 @@ defmodule LivebookWeb.SessionLive do
|
|||
defp app_status_color(:running), do: "bg-green-bright-400"
|
||||
defp app_status_color(:error), do: "bg-red-400"
|
||||
defp app_status_color(:shutting_down), do: "bg-gray-500"
|
||||
defp app_status_color(:stopped), do: "bg-gray-500"
|
||||
end
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
defmodule LivebookWeb.SessionLive.AppInfoComponent do
|
||||
use LivebookWeb, :live_component
|
||||
|
||||
use LivebookWeb, :verified_routes
|
||||
import LivebookWeb.AppHelpers
|
||||
|
||||
alias Livebook.Notebook.AppSettings
|
||||
|
||||
|
@ -105,10 +105,10 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do
|
|||
Deployments
|
||||
</h3>
|
||||
<div class="mt-2 flex flex-col space-y-4">
|
||||
<div :for={app <- @apps} class="border border-gray-200 pb-0 rounded-lg">
|
||||
<div :for={app <- @apps} class="border border-gray-200 rounded-lg">
|
||||
<div class="p-4 flex flex-col space-y-3">
|
||||
<.labeled_text label="Status">
|
||||
<.status status={app.status} />
|
||||
<.app_status status={app.status} />
|
||||
</.labeled_text>
|
||||
<.labeled_text label="URL" one_line>
|
||||
<%= if app.registered do %>
|
||||
|
@ -131,17 +131,31 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do
|
|||
<.remix_icon icon="terminal-line" class="text-lg" />
|
||||
</a>
|
||||
</span>
|
||||
<span class="tooltip top" data-tooltip="Shutdown">
|
||||
<button
|
||||
class="icon-button"
|
||||
aria-label="shutdown app"
|
||||
phx-click={
|
||||
JS.push("shutdown_app", value: %{session_id: app.session_id}, target: @myself)
|
||||
}
|
||||
>
|
||||
<.remix_icon icon="delete-bin-6-line" class="text-lg" />
|
||||
</button>
|
||||
</span>
|
||||
<%= if app.registered do %>
|
||||
<span class="tooltip top" data-tooltip="Stop">
|
||||
<button
|
||||
class="icon-button"
|
||||
aria-label="stop app"
|
||||
phx-click={
|
||||
JS.push("stop_app", value: %{session_id: app.session_id}, target: @myself)
|
||||
}
|
||||
>
|
||||
<.remix_icon icon="stop-circle-line" class="text-lg" />
|
||||
</button>
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="tooltip top" data-tooltip="Terminate">
|
||||
<button
|
||||
class="icon-button"
|
||||
aria-label="terminate app"
|
||||
phx-click={
|
||||
JS.push("terminate_app", value: %{session_id: app.session_id}, target: @myself)
|
||||
}
|
||||
>
|
||||
<.remix_icon icon="delete-bin-6-line" class="text-lg" />
|
||||
</button>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -168,53 +182,6 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do
|
|||
"""
|
||||
end
|
||||
|
||||
defp status(%{status: :booting} = assigns) do
|
||||
~H"""
|
||||
<.status_indicator text="Booting" circle_class="bg-blue-500" animated_circle_class="bg-blue-400" />
|
||||
"""
|
||||
end
|
||||
|
||||
defp status(%{status: :running} = assigns) do
|
||||
~H"""
|
||||
<.status_indicator text="Running" circle_class="bg-green-bright-400" />
|
||||
"""
|
||||
end
|
||||
|
||||
defp status(%{status: :error} = assigns) do
|
||||
~H"""
|
||||
<.status_indicator text="Error" circle_class="bg-red-400" />
|
||||
"""
|
||||
end
|
||||
|
||||
defp status(%{status: :shutting_down} = assigns) do
|
||||
~H"""
|
||||
<.status_indicator text="Shutting down" circle_class="bg-gray-500" />
|
||||
"""
|
||||
end
|
||||
|
||||
defp status_indicator(assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> assign_new(:animated_circle_class, fn -> nil end)
|
||||
|
||||
~H"""
|
||||
<div class="flex items-center space-x-2">
|
||||
<div><%= @text %></div>
|
||||
<span class="relative flex h-3 w-3">
|
||||
<span
|
||||
:if={@animated_circle_class}
|
||||
class={[
|
||||
@animated_circle_class,
|
||||
"animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
|
||||
]}
|
||||
>
|
||||
</span>
|
||||
<span class={[@circle_class, "relative inline-flex rounded-full h-3 w-3 bg-blue-500"]}></span>
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"_target" => ["reset"]}, socket) do
|
||||
settings = AppSettings.new()
|
||||
|
@ -261,12 +228,18 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do
|
|||
{:noreply, assign(socket, deploy_confirmation: false)}
|
||||
end
|
||||
|
||||
def handle_event("shutdown_app", %{"session_id" => session_id}, socket) do
|
||||
def handle_event("terminate_app", %{"session_id" => session_id}, socket) do
|
||||
app = Enum.find(socket.assigns.apps, &(&1.session_id == session_id))
|
||||
Livebook.Session.close(app.session_pid)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("stop_app", %{"session_id" => session_id}, socket) do
|
||||
app = Enum.find(socket.assigns.apps, &(&1.session_id == session_id))
|
||||
Livebook.Session.app_stop(app.session_pid)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp slug_taken?(slug, apps) do
|
||||
own? =
|
||||
Enum.any?(apps, fn app ->
|
||||
|
|
|
@ -624,11 +624,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
|
||||
defp cell_status(%{cell_view: %{eval: %{status: :evaluating}}} = assigns) do
|
||||
~H"""
|
||||
<.status_indicator
|
||||
circle_class="bg-blue-500"
|
||||
animated_circle_class="bg-blue-400"
|
||||
change_indicator={true}
|
||||
>
|
||||
<.cell_status_indicator variant={:progressing} change_indicator={true}>
|
||||
<span
|
||||
class="font-mono"
|
||||
id={"#{@id}-cell-timer"}
|
||||
|
@ -637,55 +633,55 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
data-start={DateTime.to_iso8601(@cell_view.eval.evaluation_start)}
|
||||
>
|
||||
</span>
|
||||
</.status_indicator>
|
||||
</.cell_status_indicator>
|
||||
"""
|
||||
end
|
||||
|
||||
defp cell_status(%{cell_view: %{eval: %{status: :queued}}} = assigns) do
|
||||
~H"""
|
||||
<.status_indicator circle_class="bg-gray-400" animated_circle_class="bg-gray-300">
|
||||
<.cell_status_indicator variant={:waiting}>
|
||||
Queued
|
||||
</.status_indicator>
|
||||
</.cell_status_indicator>
|
||||
"""
|
||||
end
|
||||
|
||||
defp cell_status(%{cell_view: %{eval: %{validity: :evaluated}}} = assigns) do
|
||||
~H"""
|
||||
<.status_indicator
|
||||
circle_class={if(@cell_view.eval.errored, do: "bg-red-400", else: "bg-green-bright-400")}
|
||||
<.cell_status_indicator
|
||||
variant={if(@cell_view.eval.errored, do: :error, else: :success)}
|
||||
change_indicator={true}
|
||||
tooltip={evaluated_label(@cell_view.eval.evaluation_time_ms)}
|
||||
>
|
||||
Evaluated
|
||||
</.status_indicator>
|
||||
</.cell_status_indicator>
|
||||
"""
|
||||
end
|
||||
|
||||
defp cell_status(%{cell_view: %{eval: %{validity: :stale}}} = assigns) do
|
||||
~H"""
|
||||
<.status_indicator circle_class="bg-yellow-bright-200" change_indicator={true}>
|
||||
<.cell_status_indicator variant={:warning} change_indicator={true}>
|
||||
Stale
|
||||
</.status_indicator>
|
||||
</.cell_status_indicator>
|
||||
"""
|
||||
end
|
||||
|
||||
defp cell_status(%{cell_view: %{eval: %{validity: :aborted}}} = assigns) do
|
||||
~H"""
|
||||
<.status_indicator circle_class="bg-gray-500">
|
||||
<.cell_status_indicator variant={:inactive}>
|
||||
Aborted
|
||||
</.status_indicator>
|
||||
</.cell_status_indicator>
|
||||
"""
|
||||
end
|
||||
|
||||
defp cell_status(assigns), do: ~H""
|
||||
|
||||
defp status_indicator(assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> assign_new(:animated_circle_class, fn -> nil end)
|
||||
|> assign_new(:change_indicator, fn -> false end)
|
||||
|> assign_new(:tooltip, fn -> nil end)
|
||||
attr :variant, :atom, required: true
|
||||
attr :tooltip, :string, default: nil
|
||||
attr :change_indicator, :boolean, default: false
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
defp cell_status_indicator(assigns) do
|
||||
~H"""
|
||||
<div class={[@tooltip && "tooltip", "bottom distant-medium"]} data-tooltip={@tooltip}>
|
||||
<div class="flex items-center space-x-1">
|
||||
|
@ -693,17 +689,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
<%= render_slot(@inner_block) %>
|
||||
<span :if={@change_indicator} data-el-change-indicator>*</span>
|
||||
</div>
|
||||
<span class="flex relative h-3 w-3">
|
||||
<span
|
||||
:if={@animated_circle_class}
|
||||
class={[
|
||||
@animated_circle_class,
|
||||
"animate-ping absolute inline-flex h-3 w-3 rounded-full opacity-75"
|
||||
]}
|
||||
>
|
||||
</span>
|
||||
<span class={[@circle_class, "relative inline-flex rounded-full h-3 w-3"]}></span>
|
||||
</span>
|
||||
<.status_indicator variant={@variant} />
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
|
|
@ -71,6 +71,8 @@ defmodule LivebookWeb.Router do
|
|||
live "/learn", LearnLive, :page
|
||||
live "/learn/notebooks/:slug", LearnLive, :notebook
|
||||
|
||||
live "/apps", AppsLive, :page
|
||||
|
||||
live "/hub", Hub.NewLive, :new, as: :hub
|
||||
live "/hub/:id", Hub.EditLive, :edit, as: :hub
|
||||
live "/hub/:id/env-var/new", Hub.EditLive, :add_env_var, as: :hub
|
||||
|
|
|
@ -3798,17 +3798,21 @@ defmodule Livebook.Session.DataTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "apply_operation/2 given :app_shutdown" do
|
||||
describe "apply_operation/2 given :app_unregistered" do
|
||||
test "returns an error if not in app mode" do
|
||||
data = Data.new()
|
||||
operation = {:app_shutdown, @cid}
|
||||
operation = {:app_unregistered, @cid}
|
||||
assert :error = Data.apply_operation(data, operation)
|
||||
end
|
||||
|
||||
test "updates app status" do
|
||||
data = Data.new(mode: :app)
|
||||
data =
|
||||
data_after_operations!(Data.new(mode: :app), [
|
||||
{:set_runtime, @cid, connected_noop_runtime()},
|
||||
evaluate_cells_operations(["setup"])
|
||||
])
|
||||
|
||||
operation = {:app_shutdown, @cid}
|
||||
operation = {:app_unregistered, @cid}
|
||||
|
||||
assert {:ok, %{app_data: %{status: :shutting_down}},
|
||||
[:app_broadcast_status, :app_terminate]} = Data.apply_operation(data, operation)
|
||||
|
@ -3817,16 +3821,54 @@ defmodule Livebook.Session.DataTest do
|
|||
test "does not return terminate action if there are clients" do
|
||||
data =
|
||||
data_after_operations!(Data.new(mode: :app), [
|
||||
{:set_runtime, @cid, connected_noop_runtime()},
|
||||
evaluate_cells_operations(["setup"]),
|
||||
{:client_join, @cid, User.new()}
|
||||
])
|
||||
|
||||
operation = {:app_shutdown, @cid}
|
||||
operation = {:app_unregistered, @cid}
|
||||
|
||||
assert {:ok, %{app_data: %{status: :shutting_down}}, [:app_broadcast_status]} =
|
||||
Data.apply_operation(data, operation)
|
||||
end
|
||||
end
|
||||
|
||||
describe "apply_operation/2 given :app_stop" do
|
||||
test "returns an error if not in app mode" do
|
||||
data = Data.new()
|
||||
operation = {:app_stop, @cid}
|
||||
assert :error = Data.apply_operation(data, operation)
|
||||
end
|
||||
|
||||
test "returns an error if app is not registered" do
|
||||
data = Data.new()
|
||||
operation = {:app_unregistered, @cid}
|
||||
assert :error = Data.apply_operation(data, operation)
|
||||
end
|
||||
|
||||
test "updates app status" do
|
||||
data = Data.new(mode: :app)
|
||||
|
||||
operation = {:app_stop, @cid}
|
||||
|
||||
assert {:ok, %{app_data: %{status: :stopped}}, [:app_broadcast_status]} =
|
||||
Data.apply_operation(data, operation)
|
||||
end
|
||||
|
||||
test "returns :app_unregister action when registered" do
|
||||
data =
|
||||
data_after_operations!(Data.new(mode: :app), [
|
||||
{:set_runtime, @cid, connected_noop_runtime()},
|
||||
evaluate_cells_operations(["setup"])
|
||||
])
|
||||
|
||||
operation = {:app_stop, @cid}
|
||||
|
||||
assert {:ok, %{app_data: %{status: :stopped}}, [:app_broadcast_status, :app_unregister]} =
|
||||
Data.apply_operation(data, operation)
|
||||
end
|
||||
end
|
||||
|
||||
describe "apply_operation/2 app status transitions" do
|
||||
test "keeps status as :booting when an intermediate evaluation finishes" do
|
||||
data =
|
||||
|
@ -3918,8 +3960,10 @@ defmodule Livebook.Session.DataTest do
|
|||
test "when the app is shutting down and the last client leaves, returns terminate action" do
|
||||
data =
|
||||
data_after_operations!(Data.new(mode: :app), [
|
||||
{:set_runtime, @cid, connected_noop_runtime()},
|
||||
evaluate_cells_operations(["setup"]),
|
||||
{:client_join, @cid, User.new()},
|
||||
{:app_shutdown, @cid}
|
||||
{:app_unregistered, @cid}
|
||||
])
|
||||
|
||||
operation = {:client_leave, @cid}
|
||||
|
|
|
@ -1086,7 +1086,7 @@ defmodule Livebook.SessionTest do
|
|||
|
||||
assert {:ok, %{id: ^app2_session_id}} = Livebook.Apps.fetch_session_by_slug(slug)
|
||||
|
||||
Session.app_shutdown(app2_session_pid)
|
||||
Session.app_unregistered(app2_session_pid)
|
||||
end
|
||||
|
||||
test "recovers on failure", %{test: test} do
|
||||
|
@ -1122,7 +1122,7 @@ defmodule Livebook.SessionTest do
|
|||
assert_receive {:operation, {:set_app_status, _, ^app_session_id, :booting}}
|
||||
assert_receive {:operation, {:set_app_status, _, ^app_session_id, :running}}
|
||||
|
||||
Session.app_shutdown(app_session_pid)
|
||||
Session.app_unregistered(app_session_pid)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
72
test/livebook_web/live/apps_live_test.exs
Normal file
72
test/livebook_web/live/apps_live_test.exs
Normal file
|
@ -0,0 +1,72 @@
|
|||
defmodule LivebookWeb.AppsLiveTest do
|
||||
use LivebookWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Livebook.{Session, Sessions}
|
||||
|
||||
test "updates UI when app is deployed and terminated", %{conn: conn} do
|
||||
{:ok, view, _} = live(conn, ~p"/apps")
|
||||
|
||||
session = start_session()
|
||||
|
||||
Sessions.subscribe()
|
||||
|
||||
slug = Livebook.Utils.random_short_id()
|
||||
app_settings = %{Livebook.Notebook.AppSettings.new() | slug: slug}
|
||||
Session.set_app_settings(session.pid, app_settings)
|
||||
|
||||
Session.set_notebook_name(session.pid, "My app #{slug}")
|
||||
|
||||
refute render(view) =~ slug
|
||||
|
||||
Session.deploy_app(session.pid)
|
||||
|
||||
assert_receive {:session_created, %{app_info: %{slug: ^slug}}}
|
||||
assert render(view) =~ "My app #{slug}"
|
||||
|
||||
assert_receive {:session_updated, %{app_info: %{slug: ^slug, registered: true}} = app_session}
|
||||
assert render(view) =~ ~p"/apps/#{slug}"
|
||||
|
||||
Session.app_unregistered(app_session.pid)
|
||||
|
||||
assert_receive {:session_closed, %{app_info: %{slug: ^slug}}}
|
||||
refute render(view) =~ slug
|
||||
end
|
||||
|
||||
test "terminating an app", %{conn: conn} do
|
||||
{:ok, view, _} = live(conn, ~p"/apps")
|
||||
|
||||
session = start_session()
|
||||
|
||||
Sessions.subscribe()
|
||||
|
||||
slug = Livebook.Utils.random_short_id()
|
||||
app_settings = %{Livebook.Notebook.AppSettings.new() | slug: slug}
|
||||
Session.set_app_settings(session.pid, app_settings)
|
||||
|
||||
Session.deploy_app(session.pid)
|
||||
assert_receive {:session_created, %{app_info: %{slug: ^slug}}}
|
||||
|
||||
assert_receive {:session_updated, %{app_info: %{slug: ^slug, registered: true}}}
|
||||
|
||||
view
|
||||
|> element(~s/[data-app-slug="#{slug}"] button[aria-label="stop app"]/)
|
||||
|> render_click()
|
||||
|
||||
assert_receive {:session_updated, %{app_info: %{slug: ^slug, registered: false}}}
|
||||
|
||||
view
|
||||
|> element(~s/[data-app-slug="#{slug}"] button[aria-label="terminate app"]/)
|
||||
|> render_click()
|
||||
|
||||
assert_receive {:session_closed, %{app_info: %{slug: ^slug}}}
|
||||
refute render(view) =~ slug
|
||||
end
|
||||
|
||||
defp start_session() do
|
||||
{:ok, session} = Livebook.Sessions.create_session()
|
||||
on_exit(fn -> Session.close(session.pid) end)
|
||||
session
|
||||
end
|
||||
end
|
|
@ -1417,10 +1417,10 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
|
||||
assert_push_event(view, "markdown_renderer:" <> _, %{content: "Hello from the app!"})
|
||||
|
||||
Session.app_shutdown(app_session_pid)
|
||||
Session.app_unregistered(app_session_pid)
|
||||
end
|
||||
|
||||
test "terminating an app", %{conn: conn, session: session} do
|
||||
test "stopping and terminating an app", %{conn: conn, session: session} do
|
||||
Session.subscribe(session.id)
|
||||
|
||||
slug = Livebook.Utils.random_short_id()
|
||||
|
@ -1434,7 +1434,13 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
|
||||
|
||||
view
|
||||
|> element(~s/[data-el-app-info] button[aria-label="shutdown app"]/)
|
||||
|> element(~s/[data-el-app-info] button[aria-label="stop app"]/)
|
||||
|> render_click()
|
||||
|
||||
assert_receive {:operation, {:set_app_registered, _, _, false}}
|
||||
|
||||
view
|
||||
|> element(~s/[data-el-app-info] button[aria-label="terminate app"]/)
|
||||
|> render_click()
|
||||
|
||||
assert_receive {:operation, {:delete_app, _, _}}
|
||||
|
|
Loading…
Reference in a new issue