From 1676d5469ed864d1b24da8871b206a99e178c561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Tue, 28 Feb 2023 15:08:49 +0100 Subject: [PATCH] Add page listing apps (#1733) --- lib/livebook/apps.ex | 24 ++- lib/livebook/session.ex | 61 +++++-- lib/livebook/session/data.ex | 42 ++++- .../components/core_components.ex | 34 ++++ lib/livebook_web/live/app_helpers.ex | 47 ++++++ lib/livebook_web/live/app_live.ex | 1 - lib/livebook_web/live/apps_live.ex | 155 ++++++++++++++++++ lib/livebook_web/live/home_live.ex | 25 +-- lib/livebook_web/live/layout_helpers.ex | 1 + lib/livebook_web/live/session_helpers.ex | 25 +++ lib/livebook_web/live/session_live.ex | 34 +--- .../live/session_live/app_info_component.ex | 97 ++++------- .../live/session_live/cell_component.ex | 50 ++---- lib/livebook_web/router.ex | 2 + test/livebook/session/data_test.exs | 56 ++++++- test/livebook/session_test.exs | 4 +- test/livebook_web/live/apps_live_test.exs | 72 ++++++++ test/livebook_web/live/session_live_test.exs | 12 +- 18 files changed, 569 insertions(+), 173 deletions(-) create mode 100644 lib/livebook_web/live/app_helpers.ex create mode 100644 lib/livebook_web/live/apps_live.ex create mode 100644 test/livebook_web/live/apps_live_test.exs 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 %> + +
+
+ <.labeled_text label="URL"> + <%= if @session.app_info.registered do %> + + <%= url(~p"/apps/#{@session.app_info.slug}") %> + + <% else %> + - + <% end %> + +
+
+
+ + + <.remix_icon icon="terminal-line" class="text-lg" /> + + + <%= if @session.app_info.registered do %> + + + + <% else %> + + + + <% end %> +
+
+ """ + 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"""
@@ -693,17 +689,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do <%= render_slot(@inner_block) %> *
- - - - - + <.status_indicator variant={@variant} />
""" 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, _, _}}