diff --git a/lib/livebook/apps.ex b/lib/livebook/apps.ex
index b953ab8f5..1dfac6599 100644
--- a/lib/livebook/apps.ex
+++ b/lib/livebook/apps.ex
@@ -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.
"""
diff --git a/lib/livebook/session.ex b/lib/livebook/session.ex
index eaf59d650..c0f4d70ba 100644
--- a/lib/livebook/session.ex
+++ b/lib/livebook/session.ex
@@ -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
diff --git a/lib/livebook/session/data.ex b/lib/livebook/session/data.ex
index 1770d1913..8159d6898 100644
--- a/lib/livebook/session/data.ex
+++ b/lib/livebook/session/data.ex
@@ -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
diff --git a/lib/livebook_web/components/core_components.ex b/lib/livebook_web/components/core_components.ex
index 2398b4a3a..80ab87f16 100644
--- a/lib/livebook_web/components/core_components.ex
+++ b/lib/livebook_web/components/core_components.ex
@@ -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"""
+
+
+
+
+
+ """
+ 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 """
diff --git a/lib/livebook_web/live/app_helpers.ex b/lib/livebook_web/live/app_helpers.ex
new file mode 100644
index 000000000..63339ee61
--- /dev/null
+++ b/lib/livebook_web/live/app_helpers.ex
@@ -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"""
+
+
<%= @text %>
+ <.status_indicator variant={@variant} />
+
+ """
+ end
+end
diff --git a/lib/livebook_web/live/app_live.ex b/lib/livebook_web/live/app_live.ex
index a2b493219..2393147f9 100644
--- a/lib/livebook_web/live/app_live.ex
+++ b/lib/livebook_web/live/app_live.ex
@@ -127,7 +127,6 @@ defmodule LivebookWeb.AppLive do
-
<.modal
diff --git a/lib/livebook_web/live/apps_live.ex b/lib/livebook_web/live/apps_live.ex
new file mode 100644
index 000000000..fcd7a1019
--- /dev/null
+++ b/lib/livebook_web/live/apps_live.ex
@@ -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"""
+
+
+
+
+
+ <%= if @sessions == [] do %>
+ No apps currently running.
+ <% else %>
+ These apps are currently running.
+ <% end %>
+
+
+
+
+
+ <%= "/" <> slug %>
+
+
+ <%= for {session, idx} <- Enum.with_index(sessions) do %>
+
0} class="ml-4 border-l-2 border-gray-300 border-dashed h-6">
+ <.app_box session={session} />
+ <% end %>
+
+
+
+
+
+ """
+ end
+
+ defp app_box(assigns) do
+ ~H"""
+
+
+
+ <.labeled_text label="Status">
+ <.app_status status={@session.app_info.status} />
+
+
+
+ <.labeled_text label="Name">
+ <%= @session.notebook_name %>
+
+
+
+
+
+
+ """
+ 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
diff --git a/lib/livebook_web/live/home_live.ex b/lib/livebook_web/live/home_live.ex
index 0320f5363..f92eaabb8 100644
--- a/lib/livebook_web/live/home_live.ex
+++ b/lib/livebook_web/live/home_live.ex
@@ -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
diff --git a/lib/livebook_web/live/layout_helpers.ex b/lib/livebook_web/live/layout_helpers.ex
index f8ae12fcf..b44cac42a 100644
--- a/lib/livebook_web/live/layout_helpers.ex
+++ b/lib/livebook_web/live/layout_helpers.ex
@@ -99,6 +99,7 @@ defmodule LivebookWeb.LayoutHelpers do
<.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"
diff --git a/lib/livebook_web/live/session_helpers.ex b/lib/livebook_web/live/session_helpers.ex
index 198ffcc1a..c5a728dd0 100644
--- a/lib/livebook_web/live/session_helpers.ex
+++ b/lib/livebook_web/live/session_helpers.ex
@@ -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
diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex
index 1922d4a24..f04145af2 100644
--- a/lib/livebook_web/live/session_live.ex
+++ b/lib/livebook_web/live/session_live.ex
@@ -550,7 +550,7 @@ defmodule LivebookWeb.SessionLive do
- <.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"""
"""
end
- defp session_status(%{status: :stale} = assigns) do
+ defp section_status(%{status: :stale} = assigns) do
~H"""
"""
end
- defp session_status(assigns), do: ~H""
-
- defp status_indicator(assigns) do
- assigns = assign_new(assigns, :animated_circle_class, fn -> nil end)
-
- ~H"""
-
-
-
-
-
-
-
- """
- 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
diff --git a/lib/livebook_web/live/session_live/app_info_component.ex b/lib/livebook_web/live/session_live/app_info_component.ex
index 5d29d0a37..7838510fc 100644
--- a/lib/livebook_web/live/session_live/app_info_component.ex
+++ b/lib/livebook_web/live/session_live/app_info_component.ex
@@ -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
-
+
<.labeled_text label="Status">
- <.status status={app.status} />
+ <.app_status status={app.status} />
<.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" />
-
-
-
+ <%= if app.registered do %>
+
+
+
+ <% else %>
+
+
+
+ <% end %>
@@ -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"""
-
-
<%= @text %>
-
-
-
-
-
-
- """
- 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 ->
diff --git a/lib/livebook_web/live/session_live/cell_component.ex b/lib/livebook_web/live/session_live/cell_component.ex
index 83a57d513..a900147e7 100644
--- a/lib/livebook_web/live/session_live/cell_component.ex
+++ b/lib/livebook_web/live/session_live/cell_component.ex
@@ -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}>
-
+
"""
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
-
+
"""
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
-
+
"""
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
-
+
"""
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
-
+
"""
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"""
"""
diff --git a/lib/livebook_web/router.ex b/lib/livebook_web/router.ex
index 8d3e0d652..afe87088c 100644
--- a/lib/livebook_web/router.ex
+++ b/lib/livebook_web/router.ex
@@ -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
diff --git a/test/livebook/session/data_test.exs b/test/livebook/session/data_test.exs
index 565fe3353..8008e9a04 100644
--- a/test/livebook/session/data_test.exs
+++ b/test/livebook/session/data_test.exs
@@ -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}
diff --git a/test/livebook/session_test.exs b/test/livebook/session_test.exs
index a1930e397..3a0f7942b 100644
--- a/test/livebook/session_test.exs
+++ b/test/livebook/session_test.exs
@@ -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
diff --git a/test/livebook_web/live/apps_live_test.exs b/test/livebook_web/live/apps_live_test.exs
new file mode 100644
index 000000000..691e06fa0
--- /dev/null
+++ b/test/livebook_web/live/apps_live_test.exs
@@ -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
diff --git a/test/livebook_web/live/session_live_test.exs b/test/livebook_web/live/session_live_test.exs
index 20ec501a8..e54fb8150 100644
--- a/test/livebook_web/live/session_live_test.exs
+++ b/test/livebook_web/live/session_live_test.exs
@@ -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, _, _}}