Add page listing apps (#1733)

This commit is contained in:
Jonatan Kłosko 2023-02-28 15:08:49 +01:00 committed by GitHub
parent a36b0f8d48
commit 1676d5469e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 569 additions and 173 deletions

View file

@ -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.
"""

View file

@ -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

View file

@ -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

View file

@ -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 """

View 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

View file

@ -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

View 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

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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 ->

View file

@ -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>
"""

View file

@ -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

View file

@ -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}

View file

@ -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

View 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

View file

@ -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, _, _}}