mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-06 11:35:54 +08:00
Show notifications for new app version and import warnings (#1955)
This commit is contained in:
parent
cc9731c428
commit
75614e7885
11 changed files with 125 additions and 29 deletions
|
@ -13,12 +13,22 @@ defmodule Livebook.App do
|
||||||
# always taken from the most recently deployed notebook (e.g. access
|
# always taken from the most recently deployed notebook (e.g. access
|
||||||
# type, automatic shutdown, deployment strategy).
|
# type, automatic shutdown, deployment strategy).
|
||||||
|
|
||||||
defstruct [:slug, :pid, :version, :notebook_name, :public?, :multi_session, :sessions]
|
defstruct [
|
||||||
|
:slug,
|
||||||
|
:pid,
|
||||||
|
:version,
|
||||||
|
:warnings,
|
||||||
|
:notebook_name,
|
||||||
|
:public?,
|
||||||
|
:multi_session,
|
||||||
|
:sessions
|
||||||
|
]
|
||||||
|
|
||||||
@type t :: %{
|
@type t :: %{
|
||||||
slug: slug(),
|
slug: slug(),
|
||||||
pid: pid(),
|
pid: pid(),
|
||||||
version: pos_integer(),
|
version: pos_integer(),
|
||||||
|
warnings: list(String.t()),
|
||||||
notebook_name: String.t(),
|
notebook_name: String.t(),
|
||||||
public?: boolean(),
|
public?: boolean(),
|
||||||
multi_session: boolean(),
|
multi_session: boolean(),
|
||||||
|
@ -46,12 +56,15 @@ defmodule Livebook.App do
|
||||||
|
|
||||||
* `:notebook` (required) - the notebook for initial deployment
|
* `:notebook` (required) - the notebook for initial deployment
|
||||||
|
|
||||||
|
* `:warnings` - a list of warnings to show for the initial deployment
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@spec start_link(keyword()) :: {:ok, pid} | {:error, any()}
|
@spec start_link(keyword()) :: {:ok, pid} | {:error, any()}
|
||||||
def start_link(opts) do
|
def start_link(opts) do
|
||||||
notebook = Keyword.fetch!(opts, :notebook)
|
notebook = Keyword.fetch!(opts, :notebook)
|
||||||
|
warnings = Keyword.get(opts, :warnings, [])
|
||||||
|
|
||||||
GenServer.start_link(__MODULE__, {notebook})
|
GenServer.start_link(__MODULE__, {notebook, warnings})
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
@ -100,9 +113,10 @@ defmodule Livebook.App do
|
||||||
@doc """
|
@doc """
|
||||||
Deploys a new notebook into the app.
|
Deploys a new notebook into the app.
|
||||||
"""
|
"""
|
||||||
@spec deploy(pid(), Livebook.Notebook.t()) :: :ok
|
@spec deploy(pid(), Livebook.Notebook.t(), keyword()) :: :ok
|
||||||
def deploy(pid, notebook) do
|
def deploy(pid, notebook, opts \\ []) do
|
||||||
GenServer.cast(pid, {:deploy, notebook})
|
warnings = Keyword.get(opts, :warnings, [])
|
||||||
|
GenServer.cast(pid, {:deploy, notebook, warnings})
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
@ -136,11 +150,12 @@ defmodule Livebook.App do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def init({notebook}) do
|
def init({notebook, warnings}) do
|
||||||
{:ok,
|
{:ok,
|
||||||
%{
|
%{
|
||||||
version: 1,
|
version: 1,
|
||||||
notebook: notebook,
|
notebook: notebook,
|
||||||
|
warnings: warnings,
|
||||||
sessions: [],
|
sessions: [],
|
||||||
users: %{}
|
users: %{}
|
||||||
}
|
}
|
||||||
|
@ -176,11 +191,11 @@ defmodule Livebook.App do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_cast({:deploy, notebook}, state) do
|
def handle_cast({:deploy, notebook, warnings}, state) do
|
||||||
true = notebook.app_settings.slug == state.notebook.app_settings.slug
|
true = notebook.app_settings.slug == state.notebook.app_settings.slug
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
%{state | notebook: notebook, version: state.version + 1}
|
%{state | notebook: notebook, version: state.version + 1, warnings: warnings}
|
||||||
|> start_eagerly()
|
|> start_eagerly()
|
||||||
|> shutdown_old_versions()
|
|> shutdown_old_versions()
|
||||||
|> notify_update()}
|
|> notify_update()}
|
||||||
|
@ -221,6 +236,7 @@ defmodule Livebook.App do
|
||||||
slug: state.notebook.app_settings.slug,
|
slug: state.notebook.app_settings.slug,
|
||||||
pid: self(),
|
pid: self(),
|
||||||
version: state.version,
|
version: state.version,
|
||||||
|
warnings: state.warnings,
|
||||||
notebook_name: state.notebook.name,
|
notebook_name: state.notebook.name,
|
||||||
public?: state.notebook.app_settings.access_type == :public,
|
public?: state.notebook.app_settings.access_type == :public,
|
||||||
multi_session: state.notebook.app_settings.multi_session,
|
multi_session: state.notebook.app_settings.multi_session,
|
||||||
|
|
|
@ -16,9 +16,16 @@ defmodule Livebook.Apps do
|
||||||
If there is no app process under the corresponding slug, it is started.
|
If there is no app process under the corresponding slug, it is started.
|
||||||
Otherwise the notebook is deployed as a new version into the existing
|
Otherwise the notebook is deployed as a new version into the existing
|
||||||
app.
|
app.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
* `:warnings` - a list of warnings to show for the new deployment
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@spec deploy(Livebook.Notebook.t()) :: {:ok, pid()} | {:error, term()}
|
@spec deploy(Livebook.Notebook.t(), keyword()) :: {:ok, pid()} | {:error, term()}
|
||||||
def deploy(notebook) do
|
def deploy(notebook, opts \\ []) do
|
||||||
|
opts = Keyword.validate!(opts, warnings: [])
|
||||||
|
|
||||||
slug = notebook.app_settings.slug
|
slug = notebook.app_settings.slug
|
||||||
name = name(slug)
|
name = name(slug)
|
||||||
|
|
||||||
|
@ -27,25 +34,25 @@ defmodule Livebook.Apps do
|
||||||
:global.trans({{:app_registration, name}, node()}, fn ->
|
:global.trans({{:app_registration, name}, node()}, fn ->
|
||||||
case :global.whereis_name(name) do
|
case :global.whereis_name(name) do
|
||||||
:undefined ->
|
:undefined ->
|
||||||
with {:ok, pid} <- start_app(notebook) do
|
with {:ok, pid} <- start_app(notebook, opts[:warnings]) do
|
||||||
:yes = :global.register_name(name, pid)
|
:yes = :global.register_name(name, pid)
|
||||||
{:ok, pid}
|
{:ok, pid}
|
||||||
end
|
end
|
||||||
|
|
||||||
pid ->
|
pid ->
|
||||||
App.deploy(pid, notebook)
|
App.deploy(pid, notebook, warnings: opts[:warnings])
|
||||||
{:ok, pid}
|
{:ok, pid}
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
pid ->
|
pid ->
|
||||||
App.deploy(pid, notebook)
|
App.deploy(pid, notebook, warnings: opts[:warnings])
|
||||||
{:ok, pid}
|
{:ok, pid}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp start_app(notebook) do
|
defp start_app(notebook, warnings) do
|
||||||
opts = [notebook: notebook]
|
opts = [notebook: notebook, warnings: warnings]
|
||||||
|
|
||||||
case DynamicSupervisor.start_child(Livebook.AppSupervisor, {App, opts}) do
|
case DynamicSupervisor.start_child(Livebook.AppSupervisor, {App, opts}) do
|
||||||
{:ok, pid} ->
|
{:ok, pid} ->
|
||||||
|
@ -178,7 +185,8 @@ defmodule Livebook.Apps do
|
||||||
end
|
end
|
||||||
|
|
||||||
if Livebook.Notebook.AppSettings.valid?(notebook.app_settings) do
|
if Livebook.Notebook.AppSettings.valid?(notebook.app_settings) do
|
||||||
deploy(notebook)
|
warnings = Enum.map(warnings, &("Import: " <> &1))
|
||||||
|
deploy(notebook, warnings: warnings)
|
||||||
else
|
else
|
||||||
Logger.warning("Skipping app deployment at #{path} due to invalid settings")
|
Logger.warning("Skipping app deployment at #{path} due to invalid settings")
|
||||||
end
|
end
|
||||||
|
|
|
@ -21,7 +21,7 @@ defmodule Livebook.LiveMarkdown.Import do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp earmark_message_to_string({_severity, line_number, message}) do
|
defp earmark_message_to_string({_severity, line_number, message}) do
|
||||||
"Line #{line_number}: #{message}"
|
"line #{line_number} - #{Livebook.Utils.downcase_first(message)}"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Does initial pre-processing of the AST, so that it conforms to the expected form.
|
# Does initial pre-processing of the AST, so that it conforms to the expected form.
|
||||||
|
@ -46,7 +46,7 @@ defmodule Livebook.LiveMarkdown.Import do
|
||||||
ast = Enum.map(ast, &downgrade_heading/1)
|
ast = Enum.map(ast, &downgrade_heading/1)
|
||||||
|
|
||||||
message =
|
message =
|
||||||
"Downgrading all headings, because #{primary_headings} instances of heading 1 were found"
|
"downgrading all headings, because #{primary_headings} instances of heading 1 were found"
|
||||||
|
|
||||||
{ast, [message]}
|
{ast, [message]}
|
||||||
else
|
else
|
||||||
|
@ -75,7 +75,7 @@ defmodule Livebook.LiveMarkdown.Import do
|
||||||
{ast, []}
|
{ast, []}
|
||||||
else
|
else
|
||||||
ast = comments ++ [heading] ++ leading ++ rest
|
ast = comments ++ [heading] ++ leading ++ rest
|
||||||
message = "Moving heading 1 to the top of the notebook"
|
message = "moving heading 1 to the top of the notebook"
|
||||||
{ast, [message]}
|
{ast, [message]}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -2040,9 +2040,15 @@ defmodule Livebook.Session do
|
||||||
locator = {container_ref_for_section(section), cell.id}
|
locator = {container_ref_for_section(section), cell.id}
|
||||||
parent_locators = parent_locators_for_cell(state.data, cell)
|
parent_locators = parent_locators_for_cell(state.data, cell)
|
||||||
|
|
||||||
|
language =
|
||||||
|
case cell do
|
||||||
|
%Cell.Code{} -> cell.language
|
||||||
|
_ -> :elixir
|
||||||
|
end
|
||||||
|
|
||||||
Runtime.evaluate_code(
|
Runtime.evaluate_code(
|
||||||
state.data.runtime,
|
state.data.runtime,
|
||||||
cell.language,
|
language,
|
||||||
cell.source,
|
cell.source,
|
||||||
locator,
|
locator,
|
||||||
parent_locators,
|
parent_locators,
|
||||||
|
|
|
@ -85,6 +85,34 @@ defmodule LivebookWeb.CoreComponents do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a message notice.
|
||||||
|
|
||||||
|
Similar to `flash/1`, but for permanent messages on the page.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.message_box kind={:info} message="🦊 in a 📦" />
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
attr :message, :string, required: true
|
||||||
|
attr :kind, :atom, values: [:info, :success, :warning, :error]
|
||||||
|
|
||||||
|
def message_box(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class={[
|
||||||
|
"shadow text-sm flex items-center space-x-3 rounded-lg px-4 py-2 border-l-4 rounded-l-none bg-white text-gray-700",
|
||||||
|
@kind == :info && "border-blue-500",
|
||||||
|
@kind == :success && "border-blue-500",
|
||||||
|
@kind == :warning && "border-yellow-300",
|
||||||
|
@kind == :error && "border-red-500"
|
||||||
|
]}>
|
||||||
|
<div class="whitespace-pre-wrap pr-2 max-h-52 overflow-y-auto tiny-scrollbar" phx-no-format><%= @message %></div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Creates a live region with the given role.
|
Creates a live region with the given role.
|
||||||
|
|
||||||
|
|
|
@ -251,6 +251,14 @@ defmodule LivebookWeb.AppSessionLive do
|
||||||
redirect_on_closed(socket)
|
redirect_on_closed(socket)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp after_operation(socket, _prev_socket, {:app_shutdown, _client_id}) do
|
||||||
|
put_flash(
|
||||||
|
socket,
|
||||||
|
:info,
|
||||||
|
"A new version has been deployed, this session will close once everybody leaves"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
defp after_operation(socket, _prev_socket, _operation), do: socket
|
defp after_operation(socket, _prev_socket, _operation), do: socket
|
||||||
|
|
||||||
defp redirect_on_closed(socket) do
|
defp redirect_on_closed(socket) do
|
||||||
|
|
|
@ -53,6 +53,9 @@ defmodule LivebookWeb.AppsLive do
|
||||||
<div class="mb-2 text-gray-800 font-medium text-xl">
|
<div class="mb-2 text-gray-800 font-medium text-xl">
|
||||||
<%= "/" <> app.slug %>
|
<%= "/" <> app.slug %>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-4 flex flex-col gap-3">
|
||||||
|
<.message_box :for={warning <- app.warnings} kind={:warning} message={warning} />
|
||||||
|
</div>
|
||||||
<div class="mt-4 mb-2 text-gray-600 font-medium text-sm">
|
<div class="mt-4 mb-2 text-gray-600 font-medium text-sm">
|
||||||
App info
|
App info
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -14,11 +14,12 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do
|
||||||
<.app_info_icon />
|
<.app_info_icon />
|
||||||
</div>
|
</div>
|
||||||
<%= if @session.mode == :app do %>
|
<%= if @session.mode == :app do %>
|
||||||
<div class="mt-5 flex flex-col space-y-6">
|
<div class="mt-5 flex flex-col">
|
||||||
<span class="text-gray-700 text-sm">
|
<.message_box
|
||||||
This session is a running app. To deploy a modified version, you can fork it.
|
kind={:info}
|
||||||
</span>
|
message="This session is a running app. To deploy a modified version, you can fork it."
|
||||||
<div>
|
/>
|
||||||
|
<div class="mt-6">
|
||||||
<button class="button-base button-blue" phx-click="fork_session">
|
<button class="button-base button-blue" phx-click="fork_session">
|
||||||
<.remix_icon icon="git-branch-line" />
|
<.remix_icon icon="git-branch-line" />
|
||||||
<span>Fork</span>
|
<span>Fork</span>
|
||||||
|
|
|
@ -72,5 +72,31 @@ defmodule Livebook.AppsTest do
|
||||||
|
|
||||||
Livebook.App.close(app.pid)
|
Livebook.App.close(app.pid)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@tag :capture_log
|
||||||
|
@tag :tmp_dir
|
||||||
|
test "deploys with import warnings", %{tmp_dir: tmp_dir} do
|
||||||
|
app_path = Path.join(tmp_dir, "app.livemd")
|
||||||
|
|
||||||
|
File.write!(app_path, """
|
||||||
|
<!-- livebook:{"app_settings":{"slug":"app"}} -->
|
||||||
|
|
||||||
|
# App
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
""")
|
||||||
|
|
||||||
|
Livebook.Apps.subscribe()
|
||||||
|
|
||||||
|
Livebook.Apps.deploy_apps_in_dir(tmp_dir)
|
||||||
|
|
||||||
|
assert_receive {:app_created, %{slug: "app", warnings: warnings} = app}
|
||||||
|
|
||||||
|
assert warnings == [
|
||||||
|
"Import: line 5 - fenced Code Block opened with ``` not closed at end of input"
|
||||||
|
]
|
||||||
|
|
||||||
|
Livebook.App.close(app.pid)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -241,7 +241,7 @@ defmodule Livebook.LiveMarkdown.ImportTest do
|
||||||
]
|
]
|
||||||
} = notebook
|
} = notebook
|
||||||
|
|
||||||
assert ["Downgrading all headings, because 2 instances of heading 1 were found"] == messages
|
assert ["downgrading all headings, because 2 instances of heading 1 were found"] == messages
|
||||||
end
|
end
|
||||||
|
|
||||||
test "preserves markdown modifiers in notebok/section names" do
|
test "preserves markdown modifiers in notebok/section names" do
|
||||||
|
@ -355,7 +355,7 @@ defmodule Livebook.LiveMarkdown.ImportTest do
|
||||||
]
|
]
|
||||||
} = notebook
|
} = notebook
|
||||||
|
|
||||||
assert ["Moving heading 1 to the top of the notebook"] == messages
|
assert ["moving heading 1 to the top of the notebook"] == messages
|
||||||
end
|
end
|
||||||
|
|
||||||
test "includes parsing warnings in the returned message list" do
|
test "includes parsing warnings in the returned message list" do
|
||||||
|
@ -369,7 +369,7 @@ defmodule Livebook.LiveMarkdown.ImportTest do
|
||||||
|
|
||||||
{_notebook, messages} = Import.notebook_from_livemd(markdown)
|
{_notebook, messages} = Import.notebook_from_livemd(markdown)
|
||||||
|
|
||||||
assert ["Line 3: Closing unclosed backquotes ` at end of input"] == messages
|
assert ["line 3 - closing unclosed backquotes ` at end of input"] == messages
|
||||||
end
|
end
|
||||||
|
|
||||||
test "imports non-elixir code snippets as part of markdown cells" do
|
test "imports non-elixir code snippets as part of markdown cells" do
|
||||||
|
|
|
@ -153,7 +153,7 @@ defmodule LivebookWeb.OpenLiveTest do
|
||||||
{path, flash} = assert_redirect(view, 5000)
|
{path, flash} = assert_redirect(view, 5000)
|
||||||
|
|
||||||
assert flash["warning"] =~
|
assert flash["warning"] =~
|
||||||
"We found problems while importing the file and tried to autofix them:\n- Downgrading all headings, because 3 instances of heading 1 were found"
|
"We found problems while importing the file and tried to autofix them:\n- downgrading all headings, because 3 instances of heading 1 were found"
|
||||||
|
|
||||||
close_session_by_path(path)
|
close_session_by_path(path)
|
||||||
end
|
end
|
||||||
|
|
Loading…
Add table
Reference in a new issue