Add initial support for apps (#1709)

This commit is contained in:
Jonatan Kłosko 2023-02-16 13:47:46 +01:00 committed by GitHub
parent b14b792452
commit 40c5044a60
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1486 additions and 43 deletions

View file

@ -29,7 +29,8 @@
@apply bg-white border-gray-300 text-gray-600 hover:bg-gray-100 focus:bg-gray-100;
}
.button-base:disabled {
.button-base:disabled,
.button-base.disabled {
@apply cursor-default pointer-events-none border-transparent bg-gray-100 text-gray-400;
}

View file

@ -213,6 +213,11 @@ solely client-side operations.
@apply hidden;
}
[data-el-session]:not([data-js-side-panel-content="app-info"])
[data-el-app-info] {
@apply hidden;
}
[data-el-session][data-js-side-panel-content="sections-list"]
[data-el-sections-list-toggle] {
@apply text-gray-50 bg-gray-700;
@ -233,6 +238,16 @@ solely client-side operations.
@apply text-gray-50 bg-gray-700;
}
[data-el-session][data-js-side-panel-content="app-info"]
[data-el-app-info-toggle] {
@apply text-gray-50 bg-gray-700;
}
[data-el-session][data-js-side-panel-content="app-info"]
[data-el-app-indicator] {
@apply border-gray-700;
}
[data-el-clients-list-item]:not([data-js-followed]) [data-meta="unfollow"] {
@apply hidden;
}

View file

@ -119,6 +119,10 @@ const Session = {
this.toggleRuntimeInfo()
);
this.getElement("app-info-toggle").addEventListener("click", (event) =>
this.toggleAppInfo()
);
this.getElement("notebook").addEventListener("scroll", (event) =>
this.updateSectionListHighlight()
);
@ -367,6 +371,8 @@ const Session = {
this.toggleSectionsList();
} else if (keyBuffer.tryMatch(["s", "e"])) {
this.toggleSecretsList();
} else if (keyBuffer.tryMatch(["s", "a"])) {
this.toggleAppInfo();
} else if (keyBuffer.tryMatch(["s", "u"])) {
this.toggleClientsList();
} else if (keyBuffer.tryMatch(["s", "r"])) {
@ -714,6 +720,10 @@ const Session = {
this.toggleSidePanelContent("secrets-list");
},
toggleAppInfo() {
this.toggleSidePanelContent("app-info");
},
toggleRuntimeInfo() {
this.toggleSidePanelContent("runtime-info");
},

View file

@ -223,3 +223,15 @@ export function base64ToBuffer(base64) {
return bytes.buffer;
}
export function isFeatureFlagEnabled(feature) {
const features = document
.querySelector("body")
.getAttribute("data-feature-flags", "");
if (features.legnth === 0) {
return false;
} else {
return features.split(",").includes(feature);
}
}

View file

@ -27,9 +27,7 @@ config :livebook, :iframe_port, 4001
config :livebook, :shutdown_callback, {System, :stop, []}
# Feature flags
config :livebook, :feature_flags,
hub: true,
localhost_hub: true
config :livebook, :feature_flags, hub: true
# ## SSL Support
#

View file

@ -24,9 +24,7 @@ end
config :livebook, :data_path, data_path
# Feature flags
config :livebook, :feature_flags,
hub: true,
localhost_hub: true
config :livebook, :feature_flags, hub: true
# Use longnames when running tests in CI, so that no host resolution is required,
# see https://github.com/livebook-dev/livebook/pull/173#issuecomment-819468549

View file

@ -135,6 +135,8 @@ defmodule Livebook do
Livebook.Config.default_runtime!("LIVEBOOK_DEFAULT_RUNTIME") ||
Livebook.Runtime.ElixirStandalone.new()
config :livebook, :default_app_runtime, Livebook.Runtime.ElixirStandalone.new()
config :livebook,
:runtime_modules,
[

57
lib/livebook/apps.ex Normal file
View file

@ -0,0 +1,57 @@
defmodule Livebook.Apps do
@moduledoc false
alias Livebook.Session
@doc """
Registers a 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
the previous app to shut down.
"""
@spec register(pid(), String.t()) :: :ok
def register(session_pid, slug) do
name = name(slug)
:global.trans({{:app_registration, name}, node()}, fn ->
case :global.whereis_name(name) do
:undefined ->
:ok
pid ->
:global.unregister_name(name)
Session.app_shutdown(pid)
end
:yes = :global.register_name(name, session_pid)
end)
:ok
end
@doc """
Checks if app with the given slug exists.
"""
@spec exists?(String.t()) :: boolean()
def exists?(slug) do
:global.whereis_name(name(slug)) != :undefined
end
@doc """
Looks up app session with the given slug.
"""
@spec fetch_session_by_slug(String.t()) :: {:ok, Session.t()} | :error
def fetch_session_by_slug(slug) do
case :global.whereis_name(name(slug)) do
:undefined ->
:error
pid ->
session = Session.get_by_pid(pid)
{:ok, session}
end
end
defp name(slug), do: {:app, slug}
end

View file

@ -18,14 +18,21 @@ defmodule Livebook.Config do
end
@doc """
Returns the runtime module and `init` args used to start
the default runtime.
Returns the default runtime.
"""
@spec default_runtime() :: Livebook.Runtime.t()
def default_runtime() do
Application.fetch_env!(:livebook, :default_runtime)
end
@doc """
Returns the default runtime for app sessions.
"""
@spec default_app_runtime() :: Livebook.Runtime.t()
def default_app_runtime() do
Application.fetch_env!(:livebook, :default_app_runtime)
end
@doc """
Returns if the runtime module is enabled.
"""
@ -181,11 +188,19 @@ defmodule Livebook.Config do
@doc """
Returns the feature flag list.
"""
@spec feature_flags() :: keyword(boolean()) | []
def feature_flags do
@spec feature_flags() :: keyword(boolean())
def feature_flags() do
@feature_flags
end
@doc """
Returns enabled feature flags.
"""
@spec enabled_feature_flags() :: list()
def enabled_feature_flags() do
for {flag, enabled?} <- feature_flags(), enabled?, do: flag
end
@doc """
Return if the feature flag is enabled.
"""

View file

@ -21,10 +21,11 @@ defmodule Livebook.Notebook do
:leading_comments,
:persist_outputs,
:autosave_interval_s,
:output_counter
:output_counter,
:app_settings
]
alias Livebook.Notebook.{Section, Cell}
alias Livebook.Notebook.{Section, Cell, AppSettings}
alias Livebook.Utils.Graph
import Livebook.Utils, only: [access_by_id: 1]
@ -36,7 +37,8 @@ defmodule Livebook.Notebook do
leading_comments: list(list(line :: String.t())),
persist_outputs: boolean(),
autosave_interval_s: non_neg_integer() | nil,
output_counter: non_neg_integer()
output_counter: non_neg_integer(),
app_settings: AppSettings.t()
}
@version "1.0"
@ -54,7 +56,8 @@ defmodule Livebook.Notebook do
leading_comments: [],
persist_outputs: default_persist_outputs(),
autosave_interval_s: default_autosave_interval_s(),
output_counter: 0
output_counter: 0,
app_settings: AppSettings.new()
}
|> put_setup_cell(Cell.new(:code))
end

View file

@ -0,0 +1,60 @@
defmodule Livebook.Notebook.AppSettings do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset, except: [change: 1]
@type t :: %__MODULE__{
slug: String.t() | nil
}
@primary_key false
embedded_schema do
field :slug, :string
end
@doc """
Returns default app settings.
"""
@spec new() :: t()
def new() do
%__MODULE__{slug: nil}
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking settings changes.
"""
@spec change(t(), map()) :: Ecto.Changeset.t()
def change(%__MODULE__{} = settings, attrs \\ %{}) do
settings
|> changeset(attrs)
|> Map.put(:action, :validate)
end
@doc """
Updates settings with the given changes.
"""
@spec update(t(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def update(%__MODULE__{} = settings, attrs) do
changeset = changeset(settings, attrs)
apply_action(changeset, :update)
end
defp changeset(settings, attrs) do
settings
|> cast(attrs, [:slug])
|> validate_required([:slug])
|> validate_format(:slug, ~r/^[a-zA-Z0-9-]+$/,
message: "slug can only contain alphanumeric characters and dashes"
)
end
@doc """
Checks if the app settings are complete and valid.
"""
@spec valid?(t()) :: boolean()
def valid?(settings) do
change(settings).valid?
end
end

View file

@ -52,6 +52,7 @@ defmodule Livebook.Session do
:origin,
:notebook_name,
:file,
:mode,
:images_dir,
:created_at,
:memory_usage
@ -77,6 +78,7 @@ defmodule Livebook.Session do
origin: Notebook.ContentLoader.location() | nil,
notebook_name: String.t(),
file: FileSystem.File.t() | nil,
mode: Data.session_mode(),
images_dir: FileSystem.File.t(),
created_at: DateTime.t(),
memory_usage: memory_usage()
@ -140,6 +142,9 @@ defmodule Livebook.Session do
deleting a registered file that is no longer in use. Defaults
to `15_000`
* `:mode` - the mode in which the session operates, either `:default`
or `:app`. Defaults to `:default`
"""
@spec start_link(keyword()) :: {:ok, pid} | {:error, any()}
def start_link(opts) do
@ -204,6 +209,21 @@ defmodule Livebook.Session do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
end
@doc """
Subscribes to app session messages.
## Messages
* `{:app_status_changed, session_id, status}`
* `{:app_registration_changed, session_id, registered}`
* `{:app_terminated, session_id}`
"""
@spec app_subscribe(id()) :: :ok | {:error, term()}
def app_subscribe(session_id) do
Phoenix.PubSub.subscribe(Livebook.PubSub, "apps:#{session_id}")
end
@doc """
Computes the file name for download.
@ -623,6 +643,41 @@ defmodule Livebook.Session do
|> Enum.map(&:gen_server.wait_response(&1, :infinity))
end
@doc """
Sends a app settings update request to the server.
"""
@spec set_app_settings(pid(), Notebook.AppSettings.t()) :: :ok
def set_app_settings(pid, app_settings) do
GenServer.cast(pid, {:set_app_settings, self(), app_settings})
end
@doc """
Sends a app deployment request to the server.
"""
@spec deploy_app(pid()) :: :ok
def deploy_app(pid) do
GenServer.cast(pid, {:deploy_app, self()})
end
@doc """
Sends a app build request to the server.
"""
@spec app_build(pid()) :: :ok
def app_build(pid) do
GenServer.cast(pid, {:app_build, self()})
end
@doc """
Sends a app shutdown request to the server.
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()})
end
## Callbacks
@impl true
@ -679,9 +734,9 @@ defmodule Livebook.Session do
notebook = Keyword.get_lazy(opts, :notebook, &default_notebook/0)
file = opts[:file]
origin = opts[:origin]
mode = opts[:mode] || :default
data = Data.new(notebook)
data = %{data | origin: origin}
data = Data.new(notebook: notebook, origin: origin, mode: mode)
if file do
case FileGuard.lock(file, self()) do
@ -785,8 +840,7 @@ defmodule Livebook.Session do
end
def handle_call(:close, _from, state) do
maybe_save_notebook_sync(state)
broadcast_message(state.session_id, :session_closed)
before_close(state)
{:stop, :shutdown, :ok, state}
end
@ -1102,6 +1156,52 @@ defmodule Livebook.Session do
{:noreply, state}
end
def handle_cast({:set_app_settings, client_pid, app_settings}, state) do
client_id = client_id(state, client_pid)
operation = {:set_app_settings, client_id, app_settings}
{:noreply, handle_operation(state, operation)}
end
def handle_cast({:deploy_app, _client_pid}, state) do
# In the initial state app settings are empty, hence not valid,
# so we double-check that we can actually deploy
state =
if Notebook.AppSettings.valid?(state.data.notebook.app_settings) do
opts = [notebook: state.data.notebook, mode: :app]
case Livebook.Sessions.create_session(opts) do
{:ok, session} ->
app_subscribe(session.id)
app_build(session.pid)
operation = {:add_app, @client_id, session.id, session.pid}
handle_operation(state, operation)
{:error, reason} ->
broadcast_error(
state.session_id,
"failed to create app session - #{Exception.format_exit(reason)}"
)
state
end
else
state
end
{:noreply, state}
end
def handle_cast({:app_build, _client_pid}, state) do
cell_ids = Data.cell_ids_for_full_evaluation(state.data, [])
operation = {:queue_cells_evaluation, @client_id, cell_ids}
{:noreply, handle_operation(state, operation)}
end
def handle_cast({:app_shutdown, _client_pid}, state) do
operation = {:app_shutdown, @client_id}
{:noreply, handle_operation(state, operation)}
end
@impl true
def handle_info({:DOWN, ref, :process, _, reason}, %{runtime_monitor_ref: ref} = state) do
broadcast_error(
@ -1320,6 +1420,26 @@ defmodule Livebook.Session do
{:noreply, state}
end
def handle_info({:app_status_changed, session_id, status}, state) do
operation = {:set_app_status, @client_id, session_id, status}
{:noreply, handle_operation(state, operation)}
end
def handle_info({:app_registration_changed, session_id, registered}, state) do
operation = {:set_app_registered, @client_id, session_id, registered}
{:noreply, handle_operation(state, operation)}
end
def handle_info({:app_terminated, session_id}, state) do
operation = {:delete_app, @client_id, session_id}
{:noreply, handle_operation(state, operation)}
end
def handle_info(:close, state) do
before_close(state)
{:stop, :shutdown, state}
end
def handle_info(_message, state), do: {:noreply, state}
@impl true
@ -1341,6 +1461,7 @@ defmodule Livebook.Session do
origin: state.data.origin,
notebook_name: state.data.notebook.name,
file: state.data.file,
mode: state.data.mode,
images_dir: images_dir_from_state(state),
created_at: state.created_at,
memory_usage: state.memory_usage
@ -1634,6 +1755,12 @@ defmodule Livebook.Session do
state
end
defp after_operation(state, _prev_state, {:app_shutdown, _client_id}) do
broadcast_app_message(state.session_id, {:app_registration_changed, state.session_id, false})
state
end
defp after_operation(state, _prev_state, _operation), do: state
defp handle_actions(state, actions) do
@ -1732,6 +1859,40 @@ defmodule Livebook.Session do
state
end
defp handle_action(state, :app_broadcast_status) do
status = state.data.app_data.status
broadcast_app_message(state.session_id, {:app_status_changed, state.session_id, status})
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
end
defp handle_action(state, :app_recover) do
if Runtime.connected?(state.data.runtime) do
{:ok, _} = Runtime.disconnect(state.data.runtime)
end
new_runtime = Livebook.Runtime.duplicate(state.data.runtime)
cell_ids = Data.cell_ids_for_full_evaluation(state.data, [])
state
|> handle_operation({:erase_outputs, @client_id})
|> handle_operation({:set_runtime, @client_id, new_runtime})
|> handle_operation({:queue_cells_evaluation, @client_id, cell_ids})
end
defp handle_action(state, :app_terminate) do
send(self(), :close)
state
end
defp handle_action(state, _action), do: state
defp start_evaluation(state, cell, section) do
@ -1771,6 +1932,10 @@ defmodule Livebook.Session do
Phoenix.PubSub.broadcast(Livebook.PubSub, "sessions:#{session_id}", message)
end
defp broadcast_app_message(session_id, message) do
Phoenix.PubSub.broadcast(Livebook.PubSub, "apps:#{session_id}", message)
end
defp put_memory_usage(state, runtime) do
put_in(state.memory_usage, %{runtime: runtime, system: Livebook.SystemResources.memory()})
end
@ -1802,7 +1967,7 @@ defmodule Livebook.Session do
state
end
defp maybe_save_notebook_async(state) do
defp maybe_save_notebook_async(state) when state.data.mode == :default do
{file, default?} = notebook_autosave_file(state)
if file && should_save_notebook?(state) do
@ -1822,7 +1987,9 @@ defmodule Livebook.Session do
end
end
defp maybe_save_notebook_sync(state) do
defp maybe_save_notebook_async(state), do: state
defp maybe_save_notebook_sync(state) when state.data.mode == :default do
{file, default?} = notebook_autosave_file(state)
if file && should_save_notebook?(state) do
@ -1834,6 +2001,8 @@ defmodule Livebook.Session do
end
end
defp maybe_save_notebook_sync(state), do: state
defp should_save_notebook?(state) do
state.data.dirty and state.save_task_pid == nil
end
@ -1940,6 +2109,15 @@ defmodule Livebook.Session do
%{state | registered_files: Map.new(other_files)}
end
defp before_close(state) do
maybe_save_notebook_sync(state)
broadcast_message(state.session_id, :session_closed)
if state.data.mode == :app do
broadcast_app_message(state.session_id, {:app_terminated, state.session_id})
end
end
@doc """
Subscribes the caller to runtime messages under the given topic.

View file

@ -31,17 +31,20 @@ defmodule Livebook.Session.Data do
:smart_cell_definitions,
:clients_map,
:users_map,
:secrets
:secrets,
:mode,
:apps,
:app_data
]
alias Livebook.{Notebook, Delta, Runtime, JSInterop, FileSystem}
alias Livebook.Users.User
alias Livebook.Notebook.{Cell, Section}
alias Livebook.Notebook.{Cell, Section, AppSettings}
alias Livebook.Utils.Graph
@type t :: %__MODULE__{
notebook: Notebook.t(),
origin: String.t() | nil,
origin: Notebook.ContentLoader.location() | nil,
file: FileSystem.File.t() | nil,
dirty: boolean(),
section_infos: %{Section.id() => section_info()},
@ -52,7 +55,10 @@ defmodule Livebook.Session.Data do
smart_cell_definitions: list(Runtime.smart_cell_definition()),
clients_map: %{client_id() => User.id()},
users_map: %{User.id() => User.t()},
secrets: list(secret())
secrets: %{(name :: String.t()) => value :: String.t()},
mode: session_mode(),
apps: list(app()),
app_data: nil | app_data()
}
@type section_info :: %{
@ -137,6 +143,23 @@ defmodule Livebook.Session.Data do
@type input_reading :: {input_id(), input_value :: term()}
@type session_mode :: :default | :app
@type app :: %{
session_id: Livebook.Session.id(),
session_pid: pid(),
settings: Livebook.Notebook.AppSettings.t(),
status: app_status(),
registered: boolean()
}
@type app_status :: :booting | :running | :error | :shutting_down
@type app_data :: %{
status: app_status(),
registered: boolean()
}
# Note that all operations carry the id of whichever client
# originated the operation. Some operations like :apply_cell_delta
# and :report_cell_revision require the id to be a registered
@ -188,6 +211,12 @@ defmodule Livebook.Session.Data do
| {:mark_as_not_dirty, client_id()}
| {:set_secret, client_id(), secret()}
| {:unset_secret, client_id(), String.t()}
| {:set_app_settings, client_id(), AppSettings.t()}
| {:add_app, client_id(), Livebook.Session.id(), pid()}
| {: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()}
@type action ::
:connect_runtime
@ -199,26 +228,51 @@ defmodule Livebook.Session.Data do
parent :: {Cell.t(), Section.t()} | nil}
| {:broadcast_delta, client_id(), Cell.t(), cell_source_tag(), Delta.t()}
| {:clean_up_input_values, %{input_id() => term()}}
| :app_broadcast_status
| :app_register
| :app_recover
| :app_terminate
@doc """
Returns a fresh notebook session state.
"""
@spec new(Notebook.t()) :: t()
def new(notebook \\ Notebook.new()) do
@spec new(keyword()) :: t()
def new(opts \\ []) do
opts =
opts
|> Keyword.validate!([:notebook, origin: nil, mode: :default])
|> Keyword.put_new_lazy(:notebook, &Notebook.new/0)
notebook = opts[:notebook]
default_runtime =
case opts[:mode] do
:app -> Livebook.Config.default_app_runtime()
_ -> Livebook.Config.default_runtime()
end
app_data =
if opts[:mode] == :app do
%{status: :booting, registered: false}
end
data = %__MODULE__{
notebook: notebook,
origin: nil,
origin: opts[:origin],
file: nil,
dirty: true,
section_infos: initial_section_infos(notebook),
cell_infos: initial_cell_infos(notebook),
input_values: initial_input_values(notebook),
bin_entries: [],
runtime: Livebook.Config.default_runtime(),
runtime: default_runtime,
smart_cell_definitions: [],
clients_map: %{},
users_map: %{},
secrets: %{}
secrets: %{},
mode: opts[:mode],
apps: [],
app_data: app_data
}
data
@ -461,6 +515,7 @@ defmodule Livebook.Session.Data do
end)
|> maybe_connect_runtime(data)
|> update_validity_and_evaluation()
|> app_compute_status()
|> wrap_ok()
else
:error
@ -504,6 +559,7 @@ defmodule Livebook.Session.Data do
|> update_validity_and_evaluation()
|> update_smart_cell_bases(data)
|> mark_dirty_if_persisting_outputs()
|> app_compute_status()
|> wrap_ok()
else
_ -> :error
@ -530,6 +586,7 @@ defmodule Livebook.Session.Data do
|> with_actions()
|> clear_main_evaluation()
|> update_smart_cell_bases(data)
|> app_compute_status()
|> wrap_ok()
end
@ -539,6 +596,7 @@ defmodule Livebook.Session.Data do
|> with_actions()
|> clear_section_evaluation(section)
|> update_smart_cell_bases(data)
|> app_compute_status()
|> wrap_ok()
end
end
@ -550,6 +608,7 @@ defmodule Livebook.Session.Data do
|> with_actions()
|> cancel_cell_evaluation(cell, section)
|> update_smart_cell_bases(data)
|> app_compute_status()
|> wrap_ok()
else
_ -> :error
@ -652,6 +711,7 @@ defmodule Livebook.Session.Data do
data
|> with_actions()
|> client_leave(client_id)
|> app_maybe_terminate()
|> wrap_ok()
else
_ -> :error
@ -734,6 +794,7 @@ defmodule Livebook.Session.Data do
data
|> with_actions()
|> set_runtime(data, runtime)
|> app_compute_status()
|> wrap_ok()
end
@ -773,6 +834,65 @@ defmodule Livebook.Session.Data do
|> wrap_ok()
end
def apply_operation(data, {:set_app_settings, _client_id, settings}) do
data
|> with_actions()
|> set_app_settings(settings)
|> wrap_ok()
end
def apply_operation(data, {:add_app, _client_id, session_id, session_pid}) do
data
|> with_actions()
|> add_app(session_id, session_pid)
|> wrap_ok()
end
def apply_operation(data, {:set_app_status, _client_id, session_id, status}) do
with {:ok, app} <- fetch_app_by_session_id(data, session_id) do
data
|> with_actions()
|> set_app_status(app, status)
|> wrap_ok()
else
_ -> :error
end
end
def apply_operation(data, {:set_app_registered, _client_id, session_id, registered}) do
with {:ok, app} <- fetch_app_by_session_id(data, session_id) do
data
|> with_actions()
|> set_app_registered(app, registered)
|> wrap_ok()
else
_ -> :error
end
end
def apply_operation(data, {:delete_app, _client_id, session_id}) do
with {:ok, app} <- fetch_app_by_session_id(data, session_id) do
data
|> with_actions()
|> delete_app(app)
|> wrap_ok()
else
_ -> :error
end
end
def apply_operation(data, {:app_shutdown, _client_id}) do
with :app <- data.mode do
data
|> with_actions()
|> app_shutdown()
|> app_maybe_terminate()
|> wrap_ok()
else
_ -> :error
end
end
# ===
defp with_actions(data, actions \\ []), do: {data, actions}
@ -1623,6 +1743,57 @@ defmodule Livebook.Session.Data do
set!(data_actions, secrets: secrets)
end
defp set_app_settings({data, _} = data_actions, settings) do
set!(data_actions, notebook: %{data.notebook | app_settings: settings})
end
defp add_app({data, _} = data_actions, session_id, session_pid) do
app = %{
session_id: session_id,
session_pid: session_pid,
settings: data.notebook.app_settings,
status: :booting,
registered: false
}
set!(data_actions, apps: [app | data.apps])
end
defp set_app_status(data_actions, app, status) do
update_app!(data_actions, app.session_id, &%{&1 | status: status})
end
defp set_app_registered(data_actions, app, registered) do
update_app!(data_actions, app.session_id, &%{&1 | registered: registered})
end
defp delete_app({data, _} = data_actions, app) do
apps = Enum.reject(data.apps, &(&1.session_id == app.session_id))
set!(data_actions, apps: apps)
end
defp app_shutdown(data_actions) do
data_actions
|> set_app_data!(status: :shutting_down, registered: false)
|> add_action(:app_broadcast_status)
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)
else
data_actions
end
end
defp fetch_app_by_session_id(data, session_id) do
if app = Enum.find(data.apps, &(&1.session_id == session_id)) do
{:ok, app}
else
:error
end
end
defp set_smart_cell_definitions(data_actions, smart_cell_definitions) do
data_actions
|> set!(smart_cell_definitions: smart_cell_definitions)
@ -1874,6 +2045,25 @@ defmodule Livebook.Session.Data do
Enum.all?(attrs, fn {key, _} -> Map.has_key?(struct, key) end)
end
defp update_app!({data, _} = data_actions, session_id, fun) do
apps =
Enum.map(data.apps, fn
%{session_id: ^session_id} = app -> fun.(app)
app -> app
end)
set!(data_actions, apps: apps)
end
defp set_app_data!({data, _} = data_actions, changes) do
app_data =
Enum.reduce(changes, data.app_data, fn {key, value}, app_data ->
Map.replace!(app_data, key, value)
end)
set!(data_actions, app_data: app_data)
end
@doc """
Builds evaluation parent sequence for every evaluable cell.
@ -2132,6 +2322,54 @@ defmodule Livebook.Session.Data do
end)
end
defp app_compute_status({data, _} = data_actions)
when data.mode != :app,
do: data_actions
defp app_compute_status({data, _} = data_actions)
when data.app_data.status == :shutting_down,
do: data_actions
defp app_compute_status({data, _} = data_actions) do
status =
data.notebook
|> Notebook.evaluable_cells_with_section()
|> Enum.find_value(:running, fn {cell, _section} ->
case data.cell_infos[cell.id].eval do
%{validity: :aborted} -> :error
%{errored: true} -> :error
%{validity: :fresh} -> :booting
%{status: :evaluating} -> :booting
_ -> nil
end
end)
data_actions =
if data.app_data.status == status do
data_actions
else
add_action(data_actions, :app_broadcast_status)
end
data_actions =
if not data.app_data.registered and status == :running do
data_actions
|> set_app_data!(registered: true)
|> add_action(:app_register)
else
data_actions
end
data_actions =
if data.app_data.status == :running and status == :error do
add_action(data_actions, :app_recover)
else
data_actions
end
set_app_data!(data_actions, status: status)
end
@doc """
Checks if the given cell is outdated.

View file

@ -0,0 +1,235 @@
defmodule LivebookWeb.AppLive do
use LivebookWeb, :live_view
alias Livebook.Session
alias Livebook.Notebook
alias Livebook.Notebook.Cell
@impl true
def mount(%{"slug" => slug}, _session, socket) do
case Livebook.Apps.fetch_session_by_slug(slug) do
{:ok, %{pid: session_pid, id: session_id}} ->
{data, client_id} =
if connected?(socket) do
{data, client_id} =
Session.register_client(session_pid, self(), socket.assigns.current_user)
Session.subscribe(session_id)
{data, client_id}
else
data = Session.get_data(session_pid)
{data, nil}
end
session = Session.get_by_pid(session_pid)
{:ok,
socket
|> assign(
slug: slug,
session: session,
page_title: get_page_title(data.notebook.name),
client_id: client_id,
data_view: data_to_view(data)
)
|> assign_private(data: data)}
:error ->
{:ok, redirect(socket, to: Routes.home_path(socket, :page))}
end
end
# Puts the given assigns in `socket.private`,
# to ensure they are not used for rendering.
defp assign_private(socket, assigns) do
Enum.reduce(assigns, socket, fn {key, value}, socket ->
put_in(socket.private[key], value)
end)
end
@impl true
def render(assigns) do
~H"""
<div class="grow overflow-y-auto relative" data-el-notebook>
<div
class="w-full max-w-screen-lg px-4 sm:pl-8 sm:pr-16 md:pl-16 pt-4 sm:py-5 mx-auto"
data-el-notebook-content
>
<div data-el-js-view-iframes phx-update="ignore" id="js-view-iframes"></div>
<div class="flex items-center pb-4 mb-2 space-x-4 border-b border-gray-200">
<h1 class="text-3xl font-semibold text-gray-800">
<%= @data_view.notebook_name %>
</h1>
</div>
<%= if @data_view.app_status == :booting do %>
<div class="flex items-center space-x-2">
<span class="relative flex h-3 w-3">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75">
</span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-blue-500"></span>
</span>
<div class="text-gray-700 font-medium">
Booting
</div>
</div>
<% end %>
<%= if @data_view.app_status == :error do %>
<div class="flex items-center space-x-2">
<span class="relative flex h-3 w-3">
<span class="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
</span>
<div class="text-gray-700 font-medium">
Error
</div>
</div>
<% end %>
<%= if @data_view.app_status in [:running, :shutting_down] do %>
<div class="pt-4 flex flex-col space-y-6" data-el-outputs-container id="outputs">
<%= for output_view <- Enum.reverse(@data_view.output_views) do %>
<div>
<LivebookWeb.Output.outputs
outputs={[output_view.output]}
dom_id_map={%{}}
socket={@socket}
session_id={@session.id}
session_pid={@session.pid}
client_id={@client_id}
input_values={output_view.input_values}
/>
</div>
<% end %>
</div>
<div style="height: 80vh"></div>
<% end %>
</div>
</div>
"""
end
defp get_page_title(notebook_name) do
"Livebook - #{notebook_name}"
end
@impl true
def handle_info({:operation, operation}, socket) do
{:noreply, handle_operation(socket, operation)}
end
def handle_info({:set_input_values, values, _local = true}, socket) do
socket =
Enum.reduce(values, socket, fn {input_id, value}, socket ->
operation = {:set_input_value, socket.assigns.client_id, input_id, value}
handle_operation(socket, operation)
end)
{:noreply, socket}
end
def handle_info(:session_closed, socket) do
{:noreply,
socket
|> put_flash(:info, "Session has been closed")
|> push_redirect(to: Routes.home_path(socket, :page))}
end
def handle_info(_message, socket), do: {:noreply, socket}
defp handle_operation(socket, operation) do
case Session.Data.apply_operation(socket.private.data, operation) do
{:ok, data, _actions} ->
socket
|> assign_private(data: data)
|> assign(
data_view:
update_data_view(socket.assigns.data_view, socket.private.data, data, operation)
)
:error ->
socket
end
end
defp update_data_view(data_view, _prev_data, data, operation) do
case operation do
# See LivebookWeb.SessionLive for more details
{:add_cell_evaluation_output, _client_id, _cell_id,
{:frame, _outputs, %{type: type, ref: ref}}}
when type != :default ->
for {idx, {:frame, frame_outputs, _}} <- Notebook.find_frame_outputs(data.notebook, ref) do
send_update(LivebookWeb.Output.FrameComponent,
id: "output-#{idx}",
outputs: frame_outputs,
update_type: type
)
end
data_view
_ ->
data_to_view(data)
end
end
defp data_to_view(data) do
%{
notebook_name: data.notebook.name,
output_views:
for(
output <- visible_outputs(data.notebook),
do: %{
output: output,
input_values: input_values_for_output(output, data)
}
),
app_status: data.app_data.status
}
end
defp input_values_for_output(output, data) do
input_ids = for attrs <- Cell.find_inputs_in_output(output), do: attrs.id
Map.take(data.input_values, input_ids)
end
defp visible_outputs(notebook) do
for section <- Enum.reverse(notebook.sections),
cell <- Enum.reverse(section.cells),
Cell.evaluable?(cell),
output <- filter_outputs(cell.outputs),
do: output
end
defp filter_outputs(outputs) do
for output <- outputs, output = filter_output(output), do: output
end
defp filter_output({idx, output})
when elem(output, 0) in [:markdown, :image, :js, :control],
do: {idx, output}
defp filter_output({idx, {:tabs, outputs, metadata}}) do
outputs_with_labels =
for {output, label} <- Enum.zip(outputs, metadata.labels),
output = filter_output(output),
do: {output, label}
{outputs, labels} = Enum.unzip(outputs_with_labels)
{idx, {:tabs, outputs, %{metadata | labels: labels}}}
end
defp filter_output({idx, {:grid, outputs, metadata}}) do
outputs = filter_outputs(outputs)
if outputs != [] do
{idx, {:grid, outputs, metadata}}
end
end
defp filter_output({idx, {:frame, outputs, metadata}}) do
outputs = filter_outputs(outputs)
{idx, {:frame, outputs, metadata}}
end
defp filter_output(_output), do: nil
end

View file

@ -15,7 +15,7 @@ defmodule LivebookWeb.HomeLive do
Livebook.SystemResources.subscribe()
end
sessions = Sessions.list_sessions()
sessions = Sessions.list_sessions() |> Enum.filter(&(&1.mode == :default))
notebook_infos = Notebook.Learn.visible_notebook_infos() |> Enum.take(3)
{:ok,

View file

@ -133,6 +133,17 @@ defmodule LivebookWeb.SessionLive do
label="Secrets (se)"
button_attrs={[data_el_secrets_list_toggle: true]}
/>
<div class="relative">
<.button_item
icon="rocket-line"
label="App settings (sa)"
button_attrs={[data_el_app_info_toggle: true]}
/>
<div
data-el-app-indicator
class={"absolute w-[12px] h-[12px] border-gray-900 border-2 rounded-full right-1.5 top-1.5 #{app_status_color(@data_view.apps_status)} pointer-events-none"}
/>
</div>
<.button_item
icon="cpu-line"
label="Runtime settings (sr)"
@ -189,10 +200,19 @@ defmodule LivebookWeb.SessionLive do
socket={@socket}
/>
</div>
<div data-el-runtime-info>
<.runtime_info data_view={@data_view} session={@session} socket={@socket} />
<div data-el-app-info>
<.live_component
module={LivebookWeb.SessionLive.AppInfoComponent}
id="app-info"
session={@session}
settings={@data_view.app_settings}
apps={@data_view.apps}
/>
</div>
</div>
<div data-el-runtime-info>
<.runtime_info data_view={@data_view} session={@session} socket={@socket} />
</div>
<div class="grow overflow-y-auto relative" data-el-notebook>
<div data-el-js-view-iframes phx-update="ignore" id="js-view-iframes"></div>
<LivebookWeb.SessionLive.IndicatorsComponent.render
@ -1984,7 +2004,10 @@ defmodule LivebookWeb.SessionLive do
setup_cell_view: %{cell_to_view(hd(data.notebook.setup_section.cells), data) | type: :setup},
section_views: section_views(data.notebook.sections, data),
bin_entries: data.bin_entries,
secrets: data.secrets
secrets: data.secrets,
apps_status: apps_status(data),
app_settings: data.notebook.app_settings,
apps: data.apps
}
end
@ -2133,6 +2156,9 @@ defmodule LivebookWeb.SessionLive do
Map.take(data.input_values, input_ids)
end
defp apps_status(%{apps: []}), do: nil
defp apps_status(%{apps: [app | _]}), do: app.status
# Updates current data_view in response to an operation.
# In most cases we simply recompute data_view, but for the
# most common ones we only update the relevant parts.
@ -2259,4 +2285,10 @@ defmodule LivebookWeb.SessionLive do
defp fetch_hub!(id, hubs) do
Enum.find(hubs, &(&1.id == id)) || raise "unknown hub id: #{id}"
end
defp app_status_color(nil), do: "bg-gray-400"
defp app_status_color(:booting), do: "bg-blue-500"
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"
end

View file

@ -0,0 +1,247 @@
defmodule LivebookWeb.SessionLive.AppInfoComponent do
use LivebookWeb, :live_component
alias LivebookWeb.Router.Helpers, as: Routes
@impl true
def mount(socket) do
{:ok, assign(socket, deploy_confirmation: false)}
end
@impl true
def update(assigns, socket) do
changeset =
case socket.assigns do
%{changeset: changeset} when changeset.data == assigns.settings -> changeset
_ -> Livebook.Notebook.AppSettings.change(assigns.settings)
end
{:ok,
socket
|> assign(assigns)
|> assign(changeset: changeset)}
end
@impl true
def render(assigns) do
~H"""
<div class="flex flex-col">
<div class="flex items-center justify-between">
<h3 class="uppercase text-sm font-semibold text-gray-500">
App settings
</h3>
<.app_info_icon />
</div>
<div class="flex flex-col mt-2 space-y-4">
<div class="w-full flex flex-col">
<%= if @deploy_confirmation do %>
<div class="mt-5">
<div class="text-gray-700 flex items-center">
<span class="text-sm">
Another app is already running under this slug, do you want to replace it?
</span>
</div>
<div class="mt-5 flex space-x-2">
<button
class="button-base button-red"
phx-click="deploy_confirmation_confirm"
phx-target={@myself}
>
Replace
</button>
<button
class="button-base button-outlined-gray bg-transparent"
phx-click="deploy_confirmation_cancel"
phx-target={@myself}
>
Cancel
</button>
</div>
</div>
<% else %>
<.form
:let={f}
for={@changeset}
phx-submit="deploy"
phx-change="validate"
phx-target={@myself}
autocomplete="off"
>
<.input_wrapper form={f} field={:slug} class="flex flex-col space-y-1">
<div class="input-label">Slug</div>
<%= text_input(f, :slug, class: "input", spellcheck: "false", phx_debounce: "blur") %>
</.input_wrapper>
<div class="mt-5 flex space-x-2">
<button class="button-base button-blue" type="submit" disabled={not @changeset.valid?}>
Deploy
</button>
</div>
</.form>
<% end %>
</div>
</div>
<%= if @apps != [] do %>
<h3 class="mt-16 uppercase text-sm font-semibold text-gray-500">
Deployments
</h3>
<div class="mt-2 flex flex-col space-y-4">
<%= for app <- @apps do %>
<div class="border border-gray-200 pb-0 rounded-lg">
<div class="p-4 flex flex-col space-y-3">
<.labeled_text label="Status">
<.status status={app.status} />
</.labeled_text>
<.labeled_text label="URL" one_line>
<%= if app.registered do %>
<a href={Routes.app_url(@socket, :page, app.settings.slug)} target="_blank">
<%= Routes.app_url(@socket, :page, app.settings.slug) %>
</a>
<% else %>
-
<% end %>
</.labeled_text>
</div>
<div class="border-t border-gray-200 px-3 py-2 flex space-x-2 justify-between">
<span class="tooltip top" data-tooltip="Debug">
<a
class="icon-button"
aria-label="debug app"
href={Routes.session_path(@socket, :page, app.session_id)}
target="_blank"
>
<.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>
</div>
</div>
<% end %>
</div>
<% end %>
</div>
"""
end
defp app_info_icon(assigns) do
~H"""
<span
class="icon-button cursor-pointer tooltip bottom-left"
data-tooltip={
~S'''
App deployment is a way to share your
notebook for people to interact with. Use
inputs and controls to build interactive
UIs, perfect for demos and tasks.
'''
}
>
<.remix_icon icon="question-line" class="text-xl leading-none" />
</span>
"""
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">
<%= if @animated_circle_class do %>
<span class={"#{@animated_circle_class} animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"}>
</span>
<% end %>
<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", %{"app_settings" => params}, socket) do
changeset = Livebook.Notebook.AppSettings.change(socket.assigns.settings, params)
with {:ok, settings} <- Livebook.Notebook.AppSettings.update(socket.assigns.settings, params) do
Livebook.Session.set_app_settings(socket.assigns.session.pid, settings)
end
{:noreply, assign(socket, changeset: changeset)}
end
def handle_event("deploy", %{"app_settings" => params}, socket) do
case Livebook.Notebook.AppSettings.update(socket.assigns.settings, params) do
{:ok, settings} ->
Livebook.Session.set_app_settings(socket.assigns.session.pid, settings)
if slug_taken?(settings.slug, socket.assigns.apps) do
{:noreply, assign(socket, deploy_confirmation: true)}
else
Livebook.Session.deploy_app(socket.assigns.session.pid)
{:noreply, socket}
end
{:error, _changeset} ->
{:noreply, socket}
end
end
def handle_event("deploy_confirmation_confirm", %{}, socket) do
Livebook.Session.deploy_app(socket.assigns.session.pid)
{:noreply, assign(socket, deploy_confirmation: false)}
end
def handle_event("deploy_confirmation_cancel", %{}, socket) do
{:noreply, assign(socket, deploy_confirmation: false)}
end
def handle_event("shutdown_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
defp slug_taken?(slug, apps) do
own? =
Enum.any?(apps, fn app ->
app.registered and app.settings.slug == slug
end)
not own? and Livebook.Apps.exists?(slug)
end
end

View file

@ -86,6 +86,8 @@ defmodule LivebookWeb.Router do
live "/sessions/:id/package-search", SessionLive, :package_search
get "/sessions/:id/images/:image", SessionController, :show_image
live "/sessions/:id/*path_parts", SessionLive, :catch_all
live "/apps/:slug", AppLive, :page
end
# Public authenticated URLs that people may be directed to

View file

@ -20,7 +20,10 @@
>
</script>
</head>
<body class="bg-white">
<body
class="bg-white"
data-feature-flags={Livebook.Config.enabled_feature_flags() |> Enum.join(",")}
>
<%= @inner_content %>
</body>
</html>

View file

@ -5,7 +5,6 @@ defmodule Livebook.Session.DataTest do
alias Livebook.Session.Data
alias Livebook.{Delta, Notebook}
alias Livebook.Notebook.Cell
alias Livebook.Users.User
@eval_resp {:ok, [1, 2, 3]}
@ -46,7 +45,8 @@ defmodule Livebook.Session.DataTest do
]
}
assert %{cell_infos: %{"c1" => %{}}, section_infos: %{"s1" => %{}}} = Data.new(notebook)
assert %{cell_infos: %{"c1" => %{}}, section_infos: %{"s1" => %{}}} =
Data.new(notebook: notebook)
end
test "called with a notebook, computes cell snapshots" do
@ -61,7 +61,9 @@ defmodule Livebook.Session.DataTest do
]
}
assert %{cell_infos: %{"c1" => %{eval: %{snapshot: snapshot}}}} = Data.new(notebook)
assert %{cell_infos: %{"c1" => %{eval: %{snapshot: snapshot}}}} =
Data.new(notebook: notebook)
assert snapshot != nil
end
end
@ -412,7 +414,7 @@ defmodule Livebook.Session.DataTest do
%{
notebook: %{
sections: [
%{cells: [%Cell.Code{id: "c1"}]}
%{cells: [%Notebook.Cell.Code{id: "c1"}]}
]
},
cell_infos: %{"c1" => _}
@ -3706,6 +3708,227 @@ defmodule Livebook.Session.DataTest do
end
end
describe "apply_operation/2 given :set_app_settings" do
test "updates notebook app settings" do
data = Data.new()
settings = %{Notebook.AppSettings.new() | slug: "new-slug"}
operation = {:set_app_settings, @cid, settings}
assert {:ok, %{notebook: %{app_settings: ^settings}}, []} =
Data.apply_operation(data, operation)
end
end
describe "apply_operation/2 given :add_app" do
test "adds app to the app list" do
settings = %{Notebook.AppSettings.new() | slug: "slug"}
data =
data_after_operations!([
{:set_app_settings, @cid, settings}
])
operation = {:add_app, @cid, "as1", self()}
assert {:ok, %{apps: [%{session_id: "as1", settings: ^settings}]}, []} =
Data.apply_operation(data, operation)
end
end
describe "apply_operation/2 given :set_app_status" do
test "returns an error given invalid app session id" do
data = Data.new()
operation = {:set_app_status, @cid, "as1", :running}
assert :error = Data.apply_operation(data, operation)
end
test "updates status of the given app" do
data =
data_after_operations!([
{:add_app, @cid, "as1", self()},
{:add_app, @cid, "as2", self()}
])
operation = {:set_app_status, @cid, "as2", :running}
assert {:ok, %{apps: [%{status: :running}, %{status: :booting}]}, []} =
Data.apply_operation(data, operation)
end
end
describe "apply_operation/2 given :set_app_registered" do
test "returns an error given invalid app session id" do
data = Data.new()
operation = {:set_app_registered, @cid, "as1", true}
assert :error = Data.apply_operation(data, operation)
end
test "updates registration flag of the given app" do
data =
data_after_operations!([
{:add_app, @cid, "as1", self()},
{:add_app, @cid, "as2", self()}
])
operation = {:set_app_registered, @cid, "as2", true}
assert {:ok, %{apps: [%{registered: true}, %{registered: false}]}, []} =
Data.apply_operation(data, operation)
end
end
describe "apply_operation/2 given :delete_app" do
test "returns an error given invalid app session id" do
data = Data.new()
operation = {:delete_app, @cid, "as1"}
assert :error = Data.apply_operation(data, operation)
end
test "deletes app from the app list" do
data =
data_after_operations!([
{:add_app, @cid, "as1", self()},
{:add_app, @cid, "as2", self()}
])
operation = {:delete_app, @cid, "as1"}
assert {:ok, %{apps: [%{session_id: "as2"}]}, []} = Data.apply_operation(data, operation)
end
end
describe "apply_operation/2 given :app_shutdown" do
test "returns an error if not in app mode" do
data = Data.new()
operation = {:app_shutdown, @cid}
assert :error = Data.apply_operation(data, operation)
end
test "updates app status" do
data = Data.new(mode: :app)
operation = {:app_shutdown, @cid}
assert {:ok, %{app_data: %{status: :shutting_down}},
[:app_broadcast_status, :app_terminate]} = Data.apply_operation(data, operation)
end
test "does not return terminate action if there are clients" do
data =
data_after_operations!(Data.new(mode: :app), [
{:client_join, @cid, User.new()}
])
operation = {:app_shutdown, @cid}
assert {:ok, %{app_data: %{status: :shutting_down}}, [:app_broadcast_status]} =
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 =
data_after_operations!(Data.new(mode: :app), [
{:insert_section, @cid, 0, "s1"},
{:insert_cell, @cid, "s1", 0, :code, "c1", %{}},
{:insert_cell, @cid, "s1", 1, :code, "c2", %{}},
{:set_runtime, @cid, connected_noop_runtime()},
evaluate_cells_operations(["setup"]),
{:queue_cells_evaluation, @cid, ["c1", "c2"]}
])
operation = {:add_cell_evaluation_response, @cid, "c1", @eval_resp, eval_meta()}
assert {:ok, %{app_data: %{status: :booting}}, _actions} =
Data.apply_operation(data, operation)
end
test "changes status to :error when an evaluation fails" do
data =
data_after_operations!(Data.new(mode: :app), [
{:insert_section, @cid, 0, "s1"},
{:insert_cell, @cid, "s1", 0, :code, "c1", %{}},
{:insert_cell, @cid, "s1", 1, :code, "c2", %{}},
{:set_runtime, @cid, connected_noop_runtime()},
evaluate_cells_operations(["setup"]),
{:queue_cells_evaluation, @cid, ["c1", "c2"]}
])
operation =
{:add_cell_evaluation_response, @cid, "c1", @eval_resp, eval_meta(errored: true)}
assert {:ok, %{app_data: %{status: :error}}, [:app_broadcast_status]} =
Data.apply_operation(data, operation)
end
test "changes status to :running when all evaluation finishes and returns register action" do
data =
data_after_operations!(Data.new(mode: :app), [
{:insert_section, @cid, 0, "s1"},
{:insert_cell, @cid, "s1", 0, :code, "c1", %{}},
{:insert_cell, @cid, "s1", 1, :code, "c2", %{}},
{:set_runtime, @cid, connected_noop_runtime()},
evaluate_cells_operations(["setup", "c1"]),
{:queue_cells_evaluation, @cid, ["c2"]}
])
operation = {:add_cell_evaluation_response, @cid, "c2", @eval_resp, eval_meta()}
assert {:ok, %{app_data: %{status: :running}}, [:app_broadcast_status, :app_register]} =
Data.apply_operation(data, operation)
end
test "changes status to :error when evaluation is aborted and returns recover action" do
data =
data_after_operations!(Data.new(mode: :app), [
{:insert_section, @cid, 0, "s1"},
{:insert_cell, @cid, "s1", 0, :code, "c1", %{}},
{:insert_cell, @cid, "s1", 1, :code, "c2", %{}},
{:set_runtime, @cid, connected_noop_runtime()},
evaluate_cells_operations(["setup", "c1", "c2"])
])
operation = {:reflect_main_evaluation_failure, @cid}
assert {:ok, %{app_data: %{status: :error}}, [:app_broadcast_status, :app_recover]} =
Data.apply_operation(data, operation)
end
test "changes status back to :running after recovery" do
data =
data_after_operations!(Data.new(mode: :app), [
{:insert_section, @cid, 0, "s1"},
{:insert_cell, @cid, "s1", 0, :code, "c1", %{}},
{:insert_cell, @cid, "s1", 1, :code, "c2", %{}},
{:set_runtime, @cid, connected_noop_runtime()},
evaluate_cells_operations(["setup", "c1", "c2"]),
{:reflect_main_evaluation_failure, @cid},
evaluate_cells_operations(["setup", "c1"]),
{:queue_cells_evaluation, @cid, ["c2"]}
])
operation = {:add_cell_evaluation_response, @cid, "c2", @eval_resp, eval_meta()}
assert {:ok, %{app_data: %{status: :running}}, [:app_broadcast_status]} =
Data.apply_operation(data, operation)
end
test "when the app is shutting down and the last client leaves, returns terminate action" do
data =
data_after_operations!(Data.new(mode: :app), [
{:client_join, @cid, User.new()},
{:app_shutdown, @cid}
])
operation = {:client_leave, @cid}
assert {:ok, %{app_data: %{status: :shutting_down}}, [:app_terminate]} =
Data.apply_operation(data, operation)
end
end
describe "bound_cells_with_section/2" do
test "returns an empty list when an invalid input id is given" do
data = Data.new()

View file

@ -926,7 +926,7 @@ defmodule Livebook.SessionTest do
section2 = %{Section.new() | id: "s2", cells: [cell3]}
notebook = %{Notebook.new() | sections: [section1, section2]}
data = Data.new(notebook)
data = Data.new(notebook: notebook)
data =
data_after_operations!(data, [
@ -955,7 +955,7 @@ defmodule Livebook.SessionTest do
}
notebook = %{Notebook.new() | sections: [section1, section2]}
data = Data.new(notebook)
data = Data.new(notebook: notebook)
data =
data_after_operations!(data, [
@ -970,7 +970,7 @@ defmodule Livebook.SessionTest do
test "given cell in main flow returns an empty list if there is no previous cell" do
%{setup_section: %{cells: [setup_cell]}} = notebook = Notebook.new()
data = Data.new(notebook)
data = Data.new(notebook: notebook)
assert [] = Session.parent_locators_for_cell(data, setup_cell)
end
@ -984,7 +984,7 @@ defmodule Livebook.SessionTest do
section2 = %{Section.new() | id: "s2", cells: [cell3]}
notebook = %{Notebook.new() | sections: [section1, section2]}
data = Data.new(notebook)
data = Data.new(notebook: notebook)
assert [] = Session.parent_locators_for_cell(data, cell3)
@ -1063,6 +1063,69 @@ defmodule Livebook.SessionTest do
assert :ok = Session.fetch_assets(session.pid, hash)
end
describe "apps" do
test "deploying an app under the same slug terminates the old one", %{session: session} do
Session.subscribe(session.id)
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 {:operation, {:add_app, _, app1_session_id, _app1_session_pid}}
assert_receive {:operation, {:set_app_registered, _, ^app1_session_id, true}}
Session.app_subscribe(app1_session_id)
Session.deploy_app(session.pid)
assert_receive {:operation, {:add_app, _, app2_session_id, app2_session_pid}}
assert_receive {:operation, {:set_app_registered, _, ^app1_session_id, false}}
assert_receive {:operation, {:set_app_registered, _, ^app2_session_id, true}}
assert_receive {:app_terminated, ^app1_session_id}
assert {:ok, %{id: ^app2_session_id}} = Livebook.Apps.fetch_session_by_slug(slug)
Session.app_shutdown(app2_session_pid)
end
test "recovers on failure", %{test: test} do
code =
quote do
# This test uses the Embedded runtime, so we can target the
# process by name, this way make the scenario predictable
# and avoid long sleeps
Process.register(self(), unquote(test))
end
|> Macro.to_string()
cell = %{Notebook.Cell.new(:code) | source: code}
section = %{Notebook.Section.new() | cells: [cell]}
notebook = %{Notebook.new() | sections: [section]}
session = start_session(notebook: notebook)
Session.subscribe(session.id)
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 {:operation, {:add_app, _, app_session_id, app_session_pid}}
assert_receive {:operation, {:set_app_status, _, ^app_session_id, :running}}
Process.exit(Process.whereis(test), :shutdown)
assert_receive {:operation, {:set_app_status, _, ^app_session_id, :error}}
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)
end
end
defp start_session(opts \\ []) do
opts = Keyword.merge([id: Utils.random_id()], opts)
pid = start_supervised!({Session, opts}, id: opts[:id])

View file

@ -1386,4 +1386,54 @@ defmodule LivebookWeb.SessionLiveTest do
assert output == "\e[32m\"#{String.replace(initial_os_path, "\\", "\\\\")}\"\e[0m"
end
end
describe "apps" do
test "deploying an app", %{conn: conn, session: session} do
Session.subscribe(session.id)
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
section_id = insert_section(session.pid)
insert_cell_with_output(session.pid, section_id, {:markdown, "Hello from the app!"})
slug = Livebook.Utils.random_short_id()
view
|> element(~s/[data-el-app-info] form/)
|> render_submit(%{"app_settings" => %{"slug" => slug}})
assert_receive {:operation, {:add_app, _, _, app_session_pid}}
assert_receive {:operation, {:set_app_registered, _, _, true}}
assert render(view) =~ "/apps/#{slug}"
{:ok, view, _} = live(conn, "/apps/#{slug}")
assert_push_event(view, "markdown_renderer:" <> _, %{content: "Hello from the app!"})
Session.app_shutdown(app_session_pid)
end
test "terminating an app", %{conn: conn, session: session} do
Session.subscribe(session.id)
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 {:operation, {:add_app, _, _, _}}
assert_receive {:operation, {:set_app_registered, _, _, true}}
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
view
|> element(~s/[data-el-app-info] button[aria-label="shutdown app"]/)
|> render_click()
assert_receive {:operation, {:delete_app, _, _}}
refute render(view) =~ "/apps/#{slug}"
end
end
end

View file

@ -10,6 +10,7 @@ Livebook.Runtime.ErlDist.NodeManager.start(
# cheaper to run. Other runtimes can be tested by starting
# and setting them explicitly
Application.put_env(:livebook, :default_runtime, Livebook.Runtime.Embedded.new())
Application.put_env(:livebook, :default_app_runtime, Livebook.Runtime.Embedded.new())
Application.put_env(:livebook, :runtime_modules, [
Livebook.Runtime.ElixirStandalone,