mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-29 06:46:03 +08:00
Add initial support for apps (#1709)
This commit is contained in:
parent
b14b792452
commit
40c5044a60
23 changed files with 1486 additions and 43 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
57
lib/livebook/apps.ex
Normal 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
|
||||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
60
lib/livebook/notebook/app_settings.ex
Normal file
60
lib/livebook/notebook/app_settings.ex
Normal 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
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
235
lib/livebook_web/live/app_live.ex
Normal file
235
lib/livebook_web/live/app_live.ex
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
247
lib/livebook_web/live/session_live/app_info_component.ex
Normal file
247
lib/livebook_web/live/session_live/app_info_component.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue