Add end-to-end app deployment flow with Livebook Teams (#2602)

This commit is contained in:
Jonatan Kłosko 2024-05-14 13:20:14 +02:00 committed by GitHub
parent 4d58f32668
commit 63668c49fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 879 additions and 359 deletions

View file

@ -11,7 +11,7 @@ const deploy = args.includes("--deploy");
const outDir = path.resolve( const outDir = path.resolve(
__dirname, __dirname,
deploy ? "../static/assets" : "../tmp/static_dev/assets" deploy ? "../static/assets" : "../tmp/static_dev/assets",
); );
async function main() { async function main() {

View file

@ -3,8 +3,8 @@
"scripts": { "scripts": {
"deploy": "node build.js --deploy", "deploy": "node build.js --deploy",
"watch": "node build.js --watch", "watch": "node build.js --watch",
"format": "prettier --write '{js,test,css}/**/*.{js,json,css,md}' --no-error-on-unmatched-pattern", "format": "prettier --write '{js,test,css}/**/*.{js,json,css,md}' '*.js' --no-error-on-unmatched-pattern",
"format-check": "prettier --check '{js,test,css}/**/*.{js,json,css,md}' --no-error-on-unmatched-pattern", "format-check": "prettier --check '{js,test,css}/**/*.{js,json,css,md}' '*.js' --no-error-on-unmatched-pattern",
"test": "jest", "test": "jest",
"test:watch": "jest --watch" "test:watch": "jest --watch"
}, },

View file

@ -1,4 +1,4 @@
const plugin = require("tailwindcss/plugin") const plugin = require("tailwindcss/plugin");
module.exports = { module.exports = {
content: [ content: [
@ -102,6 +102,19 @@ module.exports = {
}, },
"brand-pink": "#e44c75", "brand-pink": "#e44c75",
}, },
keyframes: {
shake: {
"0%": { transform: "translateX(0)" },
"20%": { transform: "translateX(-10px)" },
"40%": { transform: "translateX(8px)" },
"60%": { transform: "translateX(-6px)" },
"80%": { transform: "translateX(4px)" },
"100%": { transform: "translateX(0)" },
},
},
animation: {
shake: "shake 0.5s linear 0.2s",
},
}, },
}, },
plugins: [ plugins: [
@ -110,9 +123,18 @@ module.exports = {
addVariant("phx-connected", [".phx-connected&", ".phx-connected &"]); addVariant("phx-connected", [".phx-connected&", ".phx-connected &"]);
addVariant("phx-error", [".phx-error&", ".phx-error &"]); addVariant("phx-error", [".phx-error&", ".phx-error &"]);
addVariant("phx-form-error", [":not(.phx-no-feedback).show-errors &"]); addVariant("phx-form-error", [":not(.phx-no-feedback).show-errors &"]);
addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"]); addVariant("phx-click-loading", [
addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"]); ".phx-click-loading&",
addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"]); ".phx-click-loading &",
}) ]);
addVariant("phx-submit-loading", [
".phx-submit-loading&",
".phx-submit-loading &",
]);
addVariant("phx-change-loading", [
".phx-change-loading&",
".phx-change-loading &",
]);
}),
], ],
}; };

View file

@ -230,7 +230,7 @@ defmodule Livebook.Session.Data do
| {:set_deployed_app_slug, client_id(), String.t()} | {:set_deployed_app_slug, client_id(), String.t()}
| {:app_deactivate, client_id()} | {:app_deactivate, client_id()}
| {:app_shutdown, client_id()} | {:app_shutdown, client_id()}
| {:set_notebook_deployment_group, String.t()} | {:set_notebook_deployment_group, client_id(), String.t()}
@type action :: @type action ::
:connect_runtime :connect_runtime

View file

@ -84,7 +84,7 @@ defmodule LivebookWeb.Confirm do
<.modal id={@id} width={:medium} show={true}> <.modal id={@id} width={:medium} show={true}>
<form <form
id={"#{@id}-confirm-content"} id={"#{@id}-confirm-content"}
class="p-6 flex flex-col" class="flex flex-col"
phx-submit={JS.push("confirm") |> hide_modal(@id)} phx-submit={JS.push("confirm") |> hide_modal(@id)}
data-el-confirm-form data-el-confirm-form
> >

View file

@ -55,7 +55,11 @@ defmodule LivebookWeb.CoreComponents do
<.remix_icon icon="close-line" /> <.remix_icon icon="close-line" />
</div> </div>
<.remix_icon :if={@kind == :info} icon="information-fill" class="text-xl text-blue-500" /> <.remix_icon :if={@kind == :info} icon="information-fill" class="text-xl text-blue-500" />
<.remix_icon :if={@kind == :success} icon="checkbox-circle-fill" class="text-xl text-blue-500" /> <.remix_icon
:if={@kind == :success}
icon="checkbox-circle-fill"
class="text-xl text-green-bright-400"
/>
<.remix_icon :if={@kind == :warning} icon="alert-fill" class="text-xl text-yellow-500" /> <.remix_icon :if={@kind == :warning} icon="alert-fill" class="text-xl text-yellow-500" />
<.remix_icon :if={@kind == :error} icon="error-warning-fill" class="text-xl text-red-500" /> <.remix_icon :if={@kind == :error} icon="error-warning-fill" class="text-xl text-red-500" />
<span class="whitespace-pre-wrap pr-2 max-h-52 overflow-y-auto tiny-scrollbar" phx-no-format><%= message %></span> <span class="whitespace-pre-wrap pr-2 max-h-52 overflow-y-auto tiny-scrollbar" phx-no-format><%= message %></span>
@ -109,7 +113,7 @@ defmodule LivebookWeb.CoreComponents do
<div class={[ <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", "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 == :info && "border-blue-500",
@kind == :success && "border-blue-500", @kind == :success && "border-green-bright-400",
@kind == :warning && "border-yellow-300", @kind == :warning && "border-yellow-300",
@kind == :error && "border-red-500" @kind == :error && "border-red-500"
]}> ]}>
@ -186,7 +190,7 @@ defmodule LivebookWeb.CoreComponents do
id={"#{@id}-content"} id={"#{@id}-content"}
class={[ class={[
"relative max-h-full overflow-y-auto bg-white rounded-lg shadow-xl", "relative max-h-full overflow-y-auto bg-white rounded-lg shadow-xl",
"w-full", "w-full p-6",
modal_width_class(@width) modal_width_class(@width)
]} ]}
role="dialog" role="dialog"
@ -307,7 +311,7 @@ defmodule LivebookWeb.CoreComponents do
<menu <menu
id={"#{@id}-content"} id={"#{@id}-content"}
class={[ class={[
"absolute z-[100] rounded-lg bg-white flex flex-col py-2 shadow-[0_15px_99px_-0px_rgba(12,24,41,0.15)] hidden", "absolute z-[100] hidden",
menu_position_class(@position), menu_position_class(@position),
@md_position && menu_md_position_class(@md_position), @md_position && menu_md_position_class(@md_position),
@sm_position && menu_sm_position_class(@sm_position), @sm_position && menu_sm_position_class(@sm_position),
@ -316,21 +320,50 @@ defmodule LivebookWeb.CoreComponents do
role="menu" role="menu"
phx-click-away={hide_menu(@id)} phx-click-away={hide_menu(@id)}
> >
<%= render_slot(@inner_block) %> <div
id={"#{@id}-content-inner"}
class="rounded-lg bg-white flex flex-col py-2 shadow-[0_15px_99px_-0px_rgba(12,24,41,0.15)]"
>
<%= render_slot(@inner_block) %>
</div>
</menu> </menu>
</div> </div>
""" """
end end
defp show_menu(id) do @doc """
JS.show(to: "##{id}-overlay") Shows a menu rendered with `menu/1`.
|> JS.show(to: "##{id}-content", display: "flex")
|> JS.dispatch("lb:scroll_into_view", to: "##{id}-content") ## Options
* `:animate` - whether to play an animation when the menu is opened.
Defaults to `false`
"""
def show_menu(js \\ %JS{}, id, opts \\ []) do
opts = Keyword.validate!(opts, animate: false)
js =
js
|> JS.show(to: "##{id}-overlay")
|> JS.show(to: "##{id}-content", display: "flex")
|> JS.dispatch("lb:scroll_into_view", to: "##{id}-content")
if opts[:animate] do
JS.add_class(js, "animate-shake", to: "##{id}-content-inner")
else
js
end
end end
defp hide_menu(id) do @doc """
JS.hide(to: "##{id}-overlay") Hides a menu rendered with `menu/1`.
"""
def hide_menu(js \\ %JS{}, id) do
js
|> JS.hide(to: "##{id}-overlay")
|> JS.hide(to: "##{id}-content") |> JS.hide(to: "##{id}-content")
|> JS.remove_class("animate-shake", to: "##{id}-content-inner")
end end
defp menu_position_class(:top_left), do: "top-0 left-0 transform -translate-y-full -mt-1" defp menu_position_class(:top_left), do: "top-0 left-0 transform -translate-y-full -mt-1"

View file

@ -63,11 +63,15 @@ defmodule LivebookWeb.HTMLHelpers do
@doc """ @doc """
Formats the given UTC datetime relatively to present. Formats the given UTC datetime relatively to present.
""" """
@spec format_datetime_relatively(DateTime.t()) :: String.t() @spec format_datetime_relatively(DateTime.t() | NaiveDateTime.t()) :: String.t()
def format_datetime_relatively(date) do def format_datetime_relatively(%DateTime{} = date) do
date |> DateTime.to_naive() |> Livebook.Utils.Time.time_ago_in_words() date |> DateTime.to_naive() |> Livebook.Utils.Time.time_ago_in_words()
end end
def format_datetime_relatively(%NaiveDateTime{} = date) do
Livebook.Utils.Time.time_ago_in_words(date)
end
@doc """ @doc """
Returns a list of human readable messages for all upload and upload Returns a list of human readable messages for all upload and upload
entry errors. entry errors.

View file

@ -50,7 +50,7 @@ defmodule LivebookWeb.AppLive do
</div> </div>
<.modal id="sessions-modal" show width={:big} patch={~p"/apps"}> <.modal id="sessions-modal" show width={:big} patch={~p"/apps"}>
<div class="p-6 max-w-4xl flex flex-col space-y-3"> <div class="flex flex-col space-y-3">
<h3 class="text-2xl font-semibold text-gray-800"> <h3 class="text-2xl font-semibold text-gray-800">
<%= @app.notebook_name %> <%= @app.notebook_name %>
</h3> </h3>

View file

@ -31,7 +31,7 @@ defmodule LivebookWeb.AppSessionLive.SourceComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="p-6 max-w-4xl flex flex-col space-y-3"> <div class="flex flex-col space-y-3">
<h3 class="text-2xl font-semibold text-gray-800"> <h3 class="text-2xl font-semibold text-gray-800">
App source App source
</h3> </h3>

View file

@ -330,7 +330,7 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent do
defp teams_key_modal(assigns) do defp teams_key_modal(assigns) do
~H""" ~H"""
<div class="p-6 flex flex-col space-y-5"> <div class="flex flex-col space-y-5">
<h3 class="text-2xl font-semibold text-gray-800"> <h3 class="text-2xl font-semibold text-gray-800">
Teams key Teams key
</h3> </h3>

View file

@ -35,7 +35,7 @@ defmodule LivebookWeb.Hub.FileSystemFormComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="p-6 flex flex-col space-y-5"> <div class="flex flex-col space-y-5">
<h3 class="text-2xl font-semibold text-gray-800"> <h3 class="text-2xl font-semibold text-gray-800">
<%= @title %> <%= @title %>
</h3> </h3>

View file

@ -33,7 +33,7 @@ defmodule LivebookWeb.Hub.SecretFormComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="p-6 max-w-4xl flex flex-col space-y-5"> <div class="flex flex-col space-y-5">
<h3 class="text-2xl font-semibold text-gray-800"> <h3 class="text-2xl font-semibold text-gray-800">
<%= @title %> <%= @title %>
</h3> </h3>

View file

@ -1,11 +1,11 @@
defmodule LivebookWeb.Hub.Teams.DeploymentGroupInstanceComponent do defmodule LivebookWeb.Hub.Teams.DeploymentGroupAgentComponent do
use LivebookWeb, :live_component use LivebookWeb, :live_component
alias Livebook.Hubs alias Livebook.Hubs
@impl true @impl true
def mount(socket) do def mount(socket) do
{:ok, assign(socket, messages: [])} {:ok, assign(socket, messages: [], hide_title: false)}
end end
@impl true @impl true
@ -40,8 +40,8 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupInstanceComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="p-6 max-w-4xl flex flex-col gap-3"> <div class="flex flex-col gap-3">
<h3 class="text-2xl font-semibold text-gray-800"> <h3 :if={not @hide_title} class="text-2xl font-semibold text-gray-800">
App server setup App server setup
</h3> </h3>

View file

@ -52,7 +52,7 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupComponent do
</div> </div>
<!-- Overview --> <!-- Overview -->
<div :if={@deployment_group.mode == :online} class="flex flex-col lg:flex-row justify-center"> <div :if={@deployment_group.mode == :online} class="flex flex-col lg:flex-row justify-center">
<.labeled_text class="grow mt-6 lg:border-l lg:pl-4" label="App servers"> <.labeled_text class="grow mt-6 lg:border-l border-gray-200 lg:pl-4" label="App servers">
<span class="text-lg font-normal" aria-label="app servers"> <span class="text-lg font-normal" aria-label="app servers">
<%= @agents_count %> <%= @agents_count %>
</span> </span>
@ -63,7 +63,7 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupComponent do
+ Deploy + Deploy
</.link> </.link>
</.labeled_text> </.labeled_text>
<.labeled_text class="grow mt-6 lg:border-l lg:pl-4" label="Apps deployed"> <.labeled_text class="grow mt-6 lg:border-l border-gray-200 lg:pl-4" label="Apps deployed">
<span class="text-lg font-normal" aria-label="apps deployed"> <span class="text-lg font-normal" aria-label="apps deployed">
<%= @app_deployments_count %> <%= @app_deployments_count %>
</span> </span>
@ -74,7 +74,7 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupComponent do
+ Add new + Add new
</.link> </.link>
</.labeled_text> </.labeled_text>
<.labeled_text class="grow mt-6 lg:border-l lg:pl-4" label="Authentication"> <.labeled_text class="grow mt-6 lg:border-l border-gray-200 lg:pl-4" label="Authentication">
<span class="text-lg font-normal"> <span class="text-lg font-normal">
<%= Livebook.ZTA.provider_name(@deployment_group.zta_provider) %> <%= Livebook.ZTA.provider_name(@deployment_group.zta_provider) %>
</span> </span>
@ -148,10 +148,11 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupComponent do
patch={~p"/hub/#{@hub.id}"} patch={~p"/hub/#{@hub.id}"}
> >
<.live_component <.live_component
module={LivebookWeb.Hub.Teams.DeploymentGroupInstanceComponent} module={LivebookWeb.Hub.Teams.DeploymentGroupAgentComponent}
id="deployment-group-agent-instance" id="deployment-group-agent-instance"
hub={@hub} hub={@hub}
deployment_group={@deployment_group} deployment_group={@deployment_group}
return_to={nil}
/> />
</.modal> </.modal>
@ -162,7 +163,7 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupComponent do
width={:medium} width={:medium}
patch={~p"/hub/#{@hub.id}"} patch={~p"/hub/#{@hub.id}"}
> >
<div class="p-6 max-w-4xl flex flex-col space-y-3"> <div class="flex flex-col space-y-3">
<h3 class="text-2xl font-semibold text-gray-800"> <h3 class="text-2xl font-semibold text-gray-800">
New app deployment New app deployment
</h3> </h3>

View file

@ -6,7 +6,7 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupFormComponent do
@impl true @impl true
def mount(socket) do def mount(socket) do
{:ok, assign(socket, form: nil, error_message: nil)} {:ok, assign(socket, form: nil, error_message: nil, hide_title: false, force_mode: nil)}
end end
@impl true @impl true
@ -16,7 +16,14 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupFormComponent do
if socket.assigns.form do if socket.assigns.form do
{:ok, socket} {:ok, socket}
else else
{:ok, assign_form(socket, change_deployment_group(socket, %{}))} attrs =
if mode = socket.assigns.force_mode do
%{mode: mode}
else
%{}
end
{:ok, assign_form(socket, change_deployment_group(socket, attrs))}
end end
end end
@ -27,8 +34,8 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupFormComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="p-6 max-w-4xl flex flex-col space-y-5"> <div class="flex flex-col space-y-5">
<h3 class="text-2xl font-semibold text-gray-800"> <h3 :if={not @hide_title} class="text-2xl font-semibold text-gray-800">
Add deployment group Add deployment group
</h3> </h3>
<div :if={@error_message} class="error-box"> <div :if={@error_message} class="error-box">
@ -50,12 +57,22 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupFormComponent do
Type Type
</label> </label>
<div class="flex gap-y-6 sm:gap-x-4"> <div class="flex gap-y-6 sm:gap-x-4">
<.radio_card field={@form[:mode]} title="Online" value={:online}> <.radio_card
field={@form[:mode]}
title="Online"
value={:online}
disabled={@force_mode != nil}
>
Deploy notebooks to your infrastructure with the click of a button. Deploy notebooks to your infrastructure with the click of a button.
This mode requires running app servers connected to Livebook Teams. This mode requires running app servers connected to Livebook Teams.
</.radio_card> </.radio_card>
<.radio_card field={@form[:mode]} title="Airgapped" value={:offline}> <.radio_card
field={@form[:mode]}
title="Airgapped"
value={:offline}
disabled={@force_mode}
>
Manually deploy notebooks to your infrastructure via Dockerfiles. Manually deploy notebooks to your infrastructure via Dockerfiles.
Connection to Livebook Teams is not required. Connection to Livebook Teams is not required.
</.radio_card> </.radio_card>
@ -79,7 +96,7 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupFormComponent do
<span class="font-normal">Add</span> <span class="font-normal">Add</span>
</.button> </.button>
<.button color="gray" outlined patch={@return_to}> <.button :if={@return_to} color="gray" outlined patch={@return_to}>
Cancel Cancel
</.button> </.button>
</div> </div>
@ -93,9 +110,9 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupFormComponent do
defp radio_card(assigns) do defp radio_card(assigns) do
~H""" ~H"""
<label class={[ <label class={[
"relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none w-1/2", "relative flex rounded-lg border p-4 w-1/2",
to_string(@field.value) == to_string(@value) && if(to_string(@field.value) == to_string(@value), do: "border-blue-500", else: "border-gray-200"),
"border-blue-500 ring-1 ring-blue-500" if(@disabled, do: "opacity-70", else: "cursor-pointer")
]}> ]}>
<input <input
type="radio" type="radio"
@ -103,6 +120,7 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupFormComponent do
value={@value} value={@value}
checked={to_string(@field.value) == to_string(@value)} checked={to_string(@field.value) == to_string(@value)}
class="sr-only" class="sr-only"
disabled={@disabled}
/> />
<span class="flex flex-1"> <span class="flex flex-1">
<span class="flex flex-col"> <span class="flex flex-col">
@ -121,8 +139,6 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupFormComponent do
if(to_string(@field.value) == to_string(@value), do: "visible", else: "invisible") if(to_string(@field.value) == to_string(@value), do: "visible", else: "invisible")
]} ]}
/> />
<span class="pointer-events-none absolute -inset-px rounded-lg border-2" aria-hidden="true">
</span>
</label> </label>
""" """
end end
@ -133,10 +149,14 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupFormComponent do
with {:ok, deployment_group} <- Ecto.Changeset.apply_action(changeset, :update), with {:ok, deployment_group} <- Ecto.Changeset.apply_action(changeset, :update),
{:ok, _id} <- Teams.create_deployment_group(socket.assigns.hub, deployment_group) do {:ok, _id} <- Teams.create_deployment_group(socket.assigns.hub, deployment_group) do
{:noreply, if return_to = socket.assigns.return_to do
socket {:noreply,
|> put_flash(:success, "Deployment group added successfully") socket
|> push_patch(to: ~p"/hub/#{socket.assigns.hub.id}")} |> put_flash(:success, "Deployment group added successfully")
|> push_patch(to: return_to)}
else
{:noreply, socket}
end
else else
{:error, %Ecto.Changeset{} = changeset} -> {:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, Map.replace!(changeset, :action, :validate))} {:noreply, assign_form(socket, Map.replace!(changeset, :action, :validate))}

View file

@ -181,6 +181,14 @@ defmodule LivebookWeb.SessionLive do
{socket, %{context: params["context"]}} {socket, %{context: params["context"]}}
end end
defp handle_params(:add_agent, params, _url, socket) do
hub = Livebook.Hubs.fetch_hub!(socket.private.data.notebook.hub_id)
deployment_groups = Livebook.Hubs.Provider.deployment_groups(hub)
deployment_group_id = params["deployment_group_id"]
deployment_group = Enum.find(deployment_groups, &(&1.id == deployment_group_id))
{socket, %{deployment_group: deployment_group}}
end
defp handle_params(:catch_all, %{"path_parts" => path_parts}, requested_url, socket) do defp handle_params(:catch_all, %{"path_parts" => path_parts}, requested_url, socket) do
path_parts = path_parts =
Enum.map(path_parts, fn Enum.map(path_parts, fn

View file

@ -58,7 +58,7 @@ defmodule LivebookWeb.SessionLive.AppDockerComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="p-6 flex flex-col space-y-8"> <div class="flex flex-col space-y-8">
<h3 class="text-2xl font-semibold text-gray-800"> <h3 class="text-2xl font-semibold text-gray-800">
App deployment with Docker App deployment with Docker
</h3> </h3>

View file

@ -65,9 +65,16 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do
<.button <.button
color="blue" color="blue"
patch={ patch={
if Livebook.Notebook.AppSettings.valid?(@settings), cond do
do: ~p"/sessions/#{@session.id}/app-teams", Livebook.Hubs.Provider.type(@hub) != "team" ->
else: ~p"/sessions/#{@session.id}/settings/app?context=app-teams" ~p"/sessions/#{@session.id}/app-teams-hub-info"
not Livebook.Notebook.AppSettings.valid?(@settings) ->
~p"/sessions/#{@session.id}/settings/app?context=app-teams"
true ->
~p"/sessions/#{@session.id}/app-teams"
end
} }
> >
<.remix_icon icon="rocket-line" /> Deploy with Livebook Teams <.remix_icon icon="rocket-line" /> Deploy with Livebook Teams

View file

@ -21,7 +21,7 @@ defmodule LivebookWeb.SessionLive.AppSettingsComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="p-6 max-w-4xl flex flex-col space-y-8"> <div class="flex flex-col space-y-8">
<h3 class="text-2xl font-semibold text-gray-800"> <h3 class="text-2xl font-semibold text-gray-800">
App settings App settings
</h3> </h3>

View file

@ -1,223 +0,0 @@
defmodule LivebookWeb.SessionLive.AppTeamsComponent do
use LivebookWeb, :live_component
@impl true
def update(assigns, socket) do
socket = assign(socket, assigns)
deployment_groups = Livebook.Teams.get_deployment_groups(assigns.hub)
app_deployments = Livebook.Teams.get_app_deployments(assigns.hub)
deployment_group =
if assigns.deployment_group_id do
Enum.find(deployment_groups, &(&1.id == assigns.deployment_group_id))
end
app_deployment =
if deployment_group do
Enum.find(
app_deployments,
&(&1.slug == assigns.app_settings.slug and &1.deployment_group_id == deployment_group.id)
)
end
socket =
socket
|> assign(
settings_valid?: Livebook.Notebook.AppSettings.valid?(socket.assigns.app_settings),
app_deployment: app_deployment,
deployment_groups: deployment_groups,
deployment_group: deployment_group,
deployment_group_form: %{"deployment_group_id" => assigns.deployment_group_id},
deployment_group_id: assigns.deployment_group_id
)
|> assign_new(:messages, fn -> [] end)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div class="p-6 flex flex-col space-y-8">
<h3 class="text-2xl font-semibold text-gray-800">
App deployment with Livebook Teams
</h3>
<.content
file={@file}
hub={@hub}
app_deployment={@app_deployment}
deployment_group={@deployment_group}
deployment_groups={@deployment_groups}
deployment_group_form={@deployment_group_form}
deployment_group_id={@deployment_group_id}
session={@session}
messages={@messages}
myself={@myself}
/>
</div>
"""
end
defp content(%{settings_valid?: false} = assigns) do
~H"""
<div class="flex justify-between">
<p class="text-gray-700">
To deploy this app, make sure to specify valid settings.
</p>
<.link
class="text-blue-600 font-medium"
patch={~p"/sessions/#{@session.id}/settings/app?context=app-teams"}
>
<span>Configure</span>
<.remix_icon icon="arrow-right-line" />
</.link>
</div>
"""
end
defp content(assigns) do
~H"""
<div class="flex flex-col gap-4">
<p class="text-gray-700">
You can deploy this app in the to your own cloud using Livebook Teams. To do that, select
which deployment group you want to send this app and then click the button to Deploy.
</p>
<div class="flex gap-12">
<p class="text-gray-700">
<.label>Workspace</.label>
<span>
<span class="text-lg"><%= @hub.hub_emoji %></span>
<span><%= @hub.hub_name %></span>
</span>
</p>
<%= if @deployment_groups do %>
<%= if @deployment_groups != [] do %>
<.form
for={@deployment_group_form}
phx-change="select_deployment_group"
phx-target={@myself}
id="select_deployment_group_form"
>
<.select_field
help={deployment_group_help()}
field={@deployment_group_form[:deployment_group_id]}
options={deployment_group_options(@deployment_groups)}
label="Deployment Group"
name="deployment_group_id"
value={@deployment_group_id}
/>
</.form>
<% else %>
<p class="text-gray-700">
<.label help={deployment_group_help()}>
Deployment Group
</.label>
<span>None configured</span>
</p>
<% end %>
<% end %>
</div>
<div :if={@messages != []} class="flex flex-col gap-2">
<.message_box :for={{kind, message} <- @messages} kind={kind}>
<%= raw(message) %>
</.message_box>
</div>
<div :if={@app_deployment} class="space-y-3 pb-4">
<p class="text-gray-700">Current deployed version:</p>
<ul class="text-gray-700 space-y-3">
<li class="flex gap-2">
<div class="font-bold">Title:</div>
<span><%= @app_deployment.title %></span>
</li>
<li class="flex gap-2">
<div class="font-bold">Deployed by:</div>
<span><%= @app_deployment.deployed_by %></span>
</li>
<li class="flex gap-2">
<div class="font-bold">Deployed at:</div>
<span><%= @app_deployment.deployed_at %></span>
</li>
</ul>
</div>
<.button
id="deploy-livebook-agent-button"
color="blue"
phx-click="deploy_app"
phx-target={@myself}
>
<.remix_icon icon="rocket-line" /> Deploy to Livebook Agent
</.button>
</div>
"""
end
@impl true
def handle_event("select_deployment_group", %{"deployment_group_id" => id}, socket) do
id = if(id != "", do: id)
Livebook.Session.set_notebook_deployment_group(socket.assigns.session.pid, id)
{:noreply, socket}
end
def handle_event("deploy_app", _, socket) do
with {:ok, app_deployment} <- pack_app(socket),
:ok <- deploy_app(socket, app_deployment) do
message =
"App deployment for #{app_deployment.slug} with title #{app_deployment.title} created successfully"
{:noreply, assign(socket, messages: [{:info, message}])}
end
end
defp pack_app(socket) do
notebook = Livebook.Session.get_notebook(socket.assigns.session.pid)
files_dir = socket.assigns.session.files_dir
case Livebook.Teams.AppDeployment.new(notebook, files_dir) do
{:ok, app_deployment} ->
{:ok, app_deployment}
{:warning, warnings} ->
messages = Enum.map(warnings, &{:error, &1})
{:noreply, assign(socket, messages: messages)}
{:error, error} ->
error = "Failed to pack files: #{error}"
{:noreply, assign(socket, messages: [{:error, error}])}
end
end
defp deploy_app(socket, app_deployment) do
case Livebook.Teams.deploy_app(socket.assigns.hub, app_deployment) do
:ok ->
:ok
{:error, %{errors: errors}} ->
errors = Enum.map(errors, fn {key, error} -> "#{key}: #{normalize_error(error)}" end)
{:noreply, assign(socket, messages: errors)}
{:transport_error, error} ->
{:noreply, assign(socket, messages: [{:error, error}])}
end
end
defp normalize_error({msg, opts}) do
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end
defp deployment_group_options(deployment_groups) do
for deployment_group <- [%{name: "none", id: nil}] ++ deployment_groups,
do: {deployment_group.name, deployment_group.id}
end
defp deployment_group_help() do
"Share deployment credentials, secrets, and configuration with deployment groups."
end
end

View file

@ -0,0 +1,514 @@
defmodule LivebookWeb.SessionLive.AppTeamsLive do
use LivebookWeb, :live_view
# We use a child LV, because we want to subscribe and react to many
# hub-specific events, but they are only relevant while this view
# is open.
#
# We also use the :action assign (similar to :live_action), instead
# of relaying on URL changes. The main reason for this being, that
# the initial action is conditional and we couldn't patch the URL
# on mount.
alias Livebook.Session
alias Livebook.Teams
@impl true
def mount(_params, %{"session_pid" => session_pid}, socket) do
session = Session.get_by_pid(session_pid)
%{
hub_id: hub_id,
app_settings: app_settings,
deployment_group_id: deployment_group_id
} = Session.get_notebook(session_pid)
hub = Livebook.Hubs.fetch_hub!(hub_id)
if connected?(socket) do
Session.subscribe(session.id)
Teams.Broadcasts.subscribe([:deployment_groups, :app_deployments, :agents])
end
socket =
socket
|> assign(
session: session,
hub: hub,
slug: app_settings.slug,
settings_valid?: Livebook.Notebook.AppSettings.valid?(app_settings),
messages: [],
action: :deployment_groups
)
|> assign_deployment_groups()
|> assign_app_deployments()
|> assign_agents()
|> assign_deployment_group(deployment_group_id)
|> assign_app_deployment()
|> assign_initial()
|> navigate_if_no_deployment_groups()
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div class="flex flex-col space-y-8">
<div class="flex flex-col gap-1">
<h3 class="text-2xl font-semibold text-gray-800">
App deployment with Livebook Teams
</h3>
<h4 :if={subtitle = subtitle(@action)} class="text-gray-600">
<.remix_icon icon="corner-down-right-line" /> <%= subtitle %>
</h4>
</div>
<div :if={@messages != []} class="flex flex-col gap-2">
<.message_box :for={{kind, message} <- @messages} kind={kind}>
<%= raw(message) %>
</.message_box>
</div>
<.content
hub={@hub}
settings_valid?={@settings_valid?}
app_deployment={@app_deployment}
deployment_groups={@deployment_groups}
num_agents={@num_agents}
num_app_deployments={@num_app_deployments}
deployment_group={@deployment_group}
session={@session}
messages={@messages}
action={@action}
initial?={@initial?}
/>
</div>
"""
end
defp subtitle(:add_deployment_group), do: "Step: add deployment group"
defp subtitle(:add_agent), do: "Step: add app server"
defp subtitle(_), do: nil
defp content(%{settings_valid?: false} = assigns) do
~H"""
<div class="flex justify-between">
<p class="text-gray-700">
To deploy this app, make sure to specify valid settings.
</p>
<.link
class="text-blue-600 font-medium"
patch={~p"/sessions/#{@session.id}/settings/app?context=app-teams"}
>
<span>Configure</span>
<.remix_icon icon="arrow-right-line" />
</.link>
</div>
"""
end
defp content(%{action: :add_deployment_group} = assigns) do
~H"""
<div class="flex flex-col gap-8">
<.message_box kind={:info}>
You must create a deployment group before deploying the app.
</.message_box>
<.live_component
module={LivebookWeb.Hub.Teams.DeploymentGroupFormComponent}
id="add-deployment-group"
session={@session}
hub={@hub}
return_to={nil}
force_mode={:online}
hide_title
/>
</div>
"""
end
defp content(%{action: :add_agent} = assigns) do
~H"""
<div class="flex flex-col gap-8">
<.message_box :if={@initial?} kind={:info}>
You must set up an app server for the app to run on.
</.message_box>
<div>
<.live_component
module={LivebookWeb.Hub.Teams.DeploymentGroupAgentComponent}
id="add-agent"
session={@session}
hub={@hub}
deployment_group={@deployment_group}
hide_title
/>
<div class="mt-6 pt-6 border-t border-gray-200 flex flex-col gap-4">
<h4 class="text-lg font-semibold text-gray-800">
Status
</h4>
<%= if @num_agents[@deployment_group.id] do %>
<.message_box kind={:success}>
An app server is running, click "Deploy" to ship the app!
</.message_box>
<% else %>
<.message_box kind={:info}>
Awaiting an app server to be set up. If you deploy the app,
it will only start once there is an app server to run it.
</.message_box>
<% end %>
<div class="flex gap-2">
<%= if @num_agents[@deployment_group.id] do %>
<.button color="blue" phx-click="deploy_app">
<.remix_icon icon="rocket-line" /> Deploy
</.button>
<% else %>
<.button color="blue" outlined phx-click="deploy_app">
<.remix_icon icon="rocket-line" /> Deploy anyway
</.button>
<% end %>
<.button color="gray" outlined phx-click="go_deployment_groups">
See deployment groups
</.button>
</div>
</div>
</div>
</div>
"""
end
defp content(%{action: :deployment_groups} = assigns) do
~H"""
<div class="flex flex-col gap-6">
<p class="text-gray-700">
Deploy this app to your cloud infrastructure. The app will be pushed to the Livebook
Teams app servers associated with the deployment group that you choose.
</p>
<%= if @deployment_group do %>
<div class="flex flex-col gap-2">
<div class="flex justify-between items-center">
<p class="text-gray-700">
Using deployment group in <.workspace hub={@hub} /> workspace:
</p>
<button class="font-medium text-blue-600" phx-click="unselect_deployment_group">
Change
</button>
</div>
<div class="flex flex-col gap-3">
<.deployment_group_entry
deployment_group={@deployment_group}
num_agents={@num_agents}
num_app_deployments={@num_app_deployments}
active
/>
</div>
</div>
<div :if={@app_deployment} class="space-y-3">
<p class="text-gray-700">Currently deployed version:</p>
<.app_deployment_card app_deployment={@app_deployment} />
</div>
<.message_box :if={@num_agents[@deployment_group.id] == nil} kind={:warning}>
The selected deployment group has no app servers. If you deploy the app,
it will only start once there is an app server to run it.
</.message_box>
<%= if @num_agents[@deployment_group.id] do %>
<div>
<.button color="blue" phx-click="deploy_app">
<.remix_icon icon="rocket-line" /> Deploy
</.button>
</div>
<% else %>
<div>
<.button color="blue" phx-click="go_add_agent">
<.remix_icon icon="add-line" /> Add app server
</.button>
<.button color="blue" outlined phx-click="deploy_app">
<.remix_icon icon="rocket-line" /> Deploy anyway
</.button>
</div>
<% end %>
<% else %>
<.no_entries :if={@deployment_groups == []}>
No online deployment groups yet.
<.link
class="font-medium text-blue-600"
patch={~p"/sessions/#{@session.id}/add-deployment-group"}
>
Add deployment group <.remix_icon icon="arrow-right-line" class="align-middle" />
</.link>
</.no_entries>
<div :if={@deployment_groups != []} class="flex flex-col gap-2">
<p class="text-gray-700">
Online deployment groups in <.workspace hub={@hub} /> workspace:
</p>
<div class="flex flex-col gap-3">
<.deployment_group_entry
:for={deployment_group <- @deployment_groups}
deployment_group={deployment_group}
num_agents={@num_agents}
num_app_deployments={@num_app_deployments}
selectable
/>
</div>
</div>
<% end %>
</div>
"""
end
defp workspace(assigns) do
~H"""
<span class="font-medium">
<span class="text-lg"><%= @hub.hub_emoji %></span>
<span><%= @hub.hub_name %></span>
</span>
"""
end
attr :active, :boolean, default: false
attr :selectable, :boolean, default: false
attr :deployment_group, :map, required: true
attr :num_agents, :map, required: true
attr :num_app_deployments, :map, required: true
attr :rest, :global
defp deployment_group_entry(assigns) do
~H"""
<div
class={[
"border p-3 rounded-lg",
@selectable && "cursor-pointer",
if(@active,
do: "border-blue-600 bg-blue-50",
else: "border-gray-200"
)
]}
phx-click={@selectable && "select_deployment_group"}
phx-value-id={@deployment_group.id}
{@rest}
>
<div class="flex justify-between items-center">
<div class="flex gap-2 items-center text-gray-700">
<h3 class="text-sm">
<span class="font-semibold"><%= @deployment_group.name %></span>
(internal-domain.example.com)
</h3>
</div>
<div class="flex gap-2">
<div class="text-sm text-gray-700 border-l border-gray-300 pl-2">
App servers: <%= @num_agents[@deployment_group.id] || 0 %>
</div>
<div class="text-sm text-gray-700 border-l border-gray-300 pl-2">
Apps deployed: <%= @num_app_deployments[@deployment_group.id] || 0 %>
</div>
</div>
</div>
</div>
"""
end
defp app_deployment_card(assigns) do
~H"""
<div class="flex gap-4 sm:gap-12 border border-gray-200 rounded-lg p-4">
<.labeled_text label="Title">
<%= @app_deployment.title %>
</.labeled_text>
<.labeled_text label="Deployed by">
<%= @app_deployment.deployed_by %>
</.labeled_text>
<.labeled_text label="Deployed">
<%= LivebookWeb.HTMLHelpers.format_datetime_relatively(@app_deployment.deployed_at) %> ago
</.labeled_text>
</div>
"""
end
@impl true
def handle_event("unselect_deployment_group", %{}, socket) do
Livebook.Session.set_notebook_deployment_group(socket.assigns.session.pid, nil)
{:noreply, socket}
end
def handle_event("select_deployment_group", %{"id" => id}, socket) do
Livebook.Session.set_notebook_deployment_group(socket.assigns.session.pid, id)
{:noreply, socket}
end
def handle_event("deploy_app", _, socket) do
with {:ok, app_deployment} <- pack_app(socket),
:ok <- deploy_app(socket, app_deployment) do
message =
"App deployment for #{app_deployment.slug} with title #{app_deployment.title} created successfully."
{:noreply,
socket
|> navigate(:deployment_groups)
|> assign(messages: [{:success, message}])}
end
end
def handle_event("go_add_agent", %{}, socket) do
{:noreply, navigate(socket, :add_agent)}
end
def handle_event("go_deployment_groups", %{}, socket) do
{:noreply, navigate(socket, :deployment_groups)}
end
@impl true
def handle_info({event, deployment_group}, socket)
when event in [
:deployment_group_created,
:deployment_group_update,
:deployment_group_deleted
] and deployment_group.hub_id == socket.assigns.hub.id do
current_deployment_group_id =
if current_deployment_group = socket.assigns.deployment_group do
current_deployment_group.id
end
{socket, deployment_group_id} =
if socket.assigns.initial? and event == :deployment_group_created do
Livebook.Session.set_notebook_deployment_group(
socket.assigns.session.pid,
deployment_group.id
)
{navigate(socket, :add_agent), deployment_group.id}
else
{socket, current_deployment_group_id}
end
{:noreply,
socket
|> assign_deployment_groups()
|> assign_deployment_group(deployment_group_id)
|> navigate_if_no_deployment_groups()}
end
def handle_info({event, agent}, socket)
when event in [:agent_joined, :agent_left] and agent.hub_id == socket.assigns.hub.id do
{:noreply, assign_agents(socket)}
end
def handle_info({event, app_deployment}, socket)
when event in [:app_deployment_started, :app_deployment_stopped] and
app_deployment.hub_id == socket.assigns.hub.id do
{:noreply, socket |> assign_app_deployments() |> assign_app_deployment()}
end
def handle_info(
{:operation, {:set_notebook_deployment_group, _client_id, deployment_group_id}},
socket
) do
{:noreply,
socket
|> assign_deployment_group(deployment_group_id)
|> assign_app_deployment()}
end
def handle_info(_message, socket), do: {:noreply, socket}
defp assign_deployment_groups(socket) do
deployment_groups =
socket.assigns.hub
|> Teams.get_deployment_groups()
|> Enum.filter(&(&1.mode == :online))
|> Enum.sort_by(& &1.name)
assign(socket, deployment_groups: deployment_groups)
end
defp assign_app_deployments(socket) do
app_deployments = Teams.get_app_deployments(socket.assigns.hub)
num_app_deployments = Enum.frequencies_by(app_deployments, & &1.deployment_group_id)
assign(socket, app_deployments: app_deployments, num_app_deployments: num_app_deployments)
end
defp assign_agents(socket) do
agents = Teams.get_agents(socket.assigns.hub)
num_agents = Enum.frequencies_by(agents, & &1.deployment_group_id)
assign(socket, num_agents: num_agents)
end
defp assign_deployment_group(socket, deployment_group_id) do
deployment_group =
if deployment_group_id do
Enum.find(socket.assigns.deployment_groups, &(&1.id == deployment_group_id))
end
assign(socket, deployment_group: deployment_group)
end
defp assign_app_deployment(socket) do
app_deployment =
if deployment_group = socket.assigns.deployment_group do
Enum.find(
socket.assigns.app_deployments,
&(&1.slug == socket.assigns.slug and &1.deployment_group_id == deployment_group.id)
)
end
assign(socket, app_deployment: app_deployment)
end
defp assign_initial(socket) do
assign(socket, initial?: socket.assigns.deployment_groups == [])
end
defp navigate(socket, action)
when action in [:deployment_groups, :add_deployment_group, :add_agent] do
assign(socket, action: action, messages: [])
end
defp navigate_if_no_deployment_groups(socket) do
if socket.assigns.deployment_groups == [] do
navigate(socket, :add_deployment_group)
else
socket
end
end
defp pack_app(socket) do
notebook = Livebook.Session.get_notebook(socket.assigns.session.pid)
files_dir = socket.assigns.session.files_dir
case Teams.AppDeployment.new(notebook, files_dir) do
{:ok, app_deployment} ->
{:ok, app_deployment}
{:warning, warnings} ->
messages = Enum.map(warnings, &{:error, &1})
{:noreply, assign(socket, messages: messages)}
{:error, error} ->
error = "Failed to pack files: #{error}"
{:noreply, assign(socket, messages: [{:error, error}])}
end
end
defp deploy_app(socket, app_deployment) do
case Teams.deploy_app(socket.assigns.hub, app_deployment) do
:ok ->
:ok
{:error, %{errors: errors}} ->
errors = Enum.map(errors, fn {key, error} -> "#{key}: #{normalize_error(error)}" end)
{:noreply, assign(socket, messages: errors)}
{:transport_error, error} ->
{:noreply, assign(socket, messages: [{:error, error}])}
end
end
defp normalize_error({msg, opts}) do
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end
end

View file

@ -27,7 +27,7 @@ defmodule LivebookWeb.SessionLive.BinComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="p-6 max-w-4xl flex flex-col space-y-3"> <div class="flex flex-col space-y-3">
<h3 class="text-2xl font-semibold text-gray-800"> <h3 class="text-2xl font-semibold text-gray-800">
Bin Bin
</h3> </h3>

View file

@ -20,7 +20,7 @@ defmodule LivebookWeb.SessionLive.CodeCellSettingsComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="p-6 flex flex-col space-y-8"> <div class="flex flex-col space-y-8">
<h3 class="text-2xl font-semibold text-gray-800"> <h3 class="text-2xl font-semibold text-gray-800">
Cell settings Cell settings
</h3> </h3>

View file

@ -19,7 +19,7 @@ defmodule LivebookWeb.SessionLive.ExportComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="p-6 max-w-4xl flex flex-col space-y-3"> <div class="flex flex-col space-y-3">
<h3 class="text-2xl font-semibold text-gray-800"> <h3 class="text-2xl font-semibold text-gray-800">
Export Export
</h3> </h3>

View file

@ -34,7 +34,7 @@ defmodule LivebookWeb.SessionLive.InsertImageComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="p-6 flex flex-col space-y-8"> <div class="flex flex-col space-y-8">
<h3 class="text-2xl font-semibold text-gray-800"> <h3 class="text-2xl font-semibold text-gray-800">
Insert image Insert image
</h3> </h3>

View file

@ -21,7 +21,7 @@ defmodule LivebookWeb.SessionLive.PackageSearchLive do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="p-6 flex flex-col space-y-5"> <div class="flex flex-col space-y-5">
<h3 class="text-2xl font-semibold text-gray-800"> <h3 class="text-2xl font-semibold text-gray-800">
Search packages Search packages
</h3> </h3>

View file

@ -63,7 +63,7 @@ defmodule LivebookWeb.SessionLive.PersistenceComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="p-6 flex flex-col space-y-8"> <div class="flex flex-col space-y-8">
<h3 class="text-2xl font-semibold text-gray-800"> <h3 class="text-2xl font-semibold text-gray-800">
Save to file Save to file
</h3> </h3>

View file

@ -25,7 +25,7 @@ defmodule LivebookWeb.SessionLive.RenameFileEntryComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="p-6 max-w-4xl flex flex-col space-y-4"> <div class="flex flex-col space-y-4">
<h3 class="text-2xl font-semibold text-gray-800"> <h3 class="text-2xl font-semibold text-gray-800">
Rename file Rename file
</h3> </h3>

View file

@ -116,21 +116,25 @@ defmodule LivebookWeb.SessionLive.Render do
/> />
</.modal> </.modal>
<.modal :if={@live_action == :app_teams} id="app-teams-modal" show width={:big} patch={@self_path}>
<%= live_render(@socket, LivebookWeb.SessionLive.AppTeamsLive,
id: "app-teams",
session: %{
"session_pid" => @session.pid
}
) %>
</.modal>
<.modal <.modal
:if={@live_action == :app_teams} :if={@live_action == :app_teams_hub_info}
id="app-teams-modal" id="app-teams-hub-info-modal"
show show
width={:medium} width={:big}
patch={@self_path} patch={@self_path}
> >
<.live_component <.app_teams_hub_info_content
module={LivebookWeb.SessionLive.AppTeamsComponent} any_team_hub?={Enum.any?(@saved_hubs, &(Livebook.Hubs.Provider.type(&1.provider) == "team"))}
id="app-teams"
session={@session} session={@session}
hub={@data_view.hub}
file={@data_view.file}
app_settings={@data_view.app_settings}
deployment_group_id={@data_view.deployment_group_id}
/> />
</.modal> </.modal>
@ -430,6 +434,7 @@ defmodule LivebookWeb.SessionLive.Render do
app={@app} app={@app}
deployed_app_slug={@data_view.deployed_app_slug} deployed_app_slug={@data_view.deployed_app_slug}
any_session_secrets?={@data_view.any_session_secrets?} any_session_secrets?={@data_view.any_session_secrets?}
hub={@data_view.hub}
/> />
</div> </div>
<div data-el-runtime-info> <div data-el-runtime-info>
@ -794,9 +799,42 @@ defmodule LivebookWeb.SessionLive.Render do
""" """
end end
defp app_teams_hub_info_content(assigns) do
~H"""
<div class="flex flex-col space-y-4">
<h3 class="text-2xl font-semibold text-gray-800">
App deployment with Livebook Teams
</h3>
<%= if @any_team_hub? do %>
<.message_box kind={:info}>
In order to deploy your app using Livebook Teams, you need to select a Livebook Teams
workspace. To change the workspace, use the dropdown right below the notebook title.
<.link
class="text-blue-600 font-medium"
patch={~p"/sessions/#{@session.id}"}
phx-click={show_menu(%JS{}, "notebook-hub-menu", animate: true)}
>
<span>Change workspace</span>
<.remix_icon icon="arrow-right-line" />
</.link>
</.message_box>
<% else %>
<.message_box kind={:info}>
In order to deploy your app using Livebook Teams, you need to create an organization.
<.link class="text-blue-600 font-medium" patch={~p"/hub"}>
<span>Add organization</span>
<.remix_icon icon="arrow-right-line" />
</.link>
</.message_box>
<% end %>
</div>
"""
end
def add_file_entry_content(assigns) do def add_file_entry_content(assigns) do
~H""" ~H"""
<div class="p-6 max-w-4xl flex flex-col space-y-4"> <div class="flex flex-col space-y-4">
<h3 class="text-2xl font-semibold text-gray-800"> <h3 class="text-2xl font-semibold text-gray-800">
Add file Add file
</h3> </h3>

View file

@ -24,7 +24,7 @@ defmodule LivebookWeb.SessionLive.RuntimeComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="p-6 max-w-4xl flex flex-col space-y-5"> <div class="flex flex-col space-y-5">
<h3 class="text-2xl font-semibold text-gray-800"> <h3 class="text-2xl font-semibold text-gray-800">
Runtime settings Runtime settings
</h3> </h3>

View file

@ -37,7 +37,7 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="p-6 w-full flex flex-col space-y-5"> <div class="flex flex-col space-y-5">
<h3 class="text-2xl font-semibold text-gray-800"> <h3 class="text-2xl font-semibold text-gray-800">
<%= @title %> <%= @title %>
</h3> </h3>

View file

@ -155,7 +155,7 @@ defmodule LivebookWeb.SessionLive.ShortcutsComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="p-6 flex flex-col space-y-5"> <div class="flex flex-col space-y-5">
<h3 class="text-2xl font-semibold text-gray-800"> <h3 class="text-2xl font-semibold text-gray-800">
Keyboard shortcuts Keyboard shortcuts
</h3> </h3>

View file

@ -24,7 +24,7 @@ defmodule LivebookWeb.SettingsLive.EnvVarComponent do
assigns = assign_new(assigns, :on_save, fn -> "save" end) assigns = assign_new(assigns, :on_save, fn -> "save" end)
~H""" ~H"""
<div class="p-6 flex flex-col space-y-5"> <div class="flex flex-col space-y-5">
<h3 class="text-2xl font-semibold text-gray-800"> <h3 class="text-2xl font-semibold text-gray-800">
<%= if @operation == :new, do: "Add environment variable", else: "Edit environment variable" %> <%= if @operation == :new, do: "Add environment variable", else: "Edit environment variable" %>
</h3> </h3>

View file

@ -18,7 +18,7 @@ defmodule LivebookWeb.UserComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="p-6 flex flex-col space-y-5"> <div class="flex flex-col space-y-5">
<h3 class="text-2xl font-semibold text-gray-800"> <h3 class="text-2xl font-semibold text-gray-800">
User profile User profile
</h3> </h3>

View file

@ -109,6 +109,7 @@ defmodule LivebookWeb.Router do
live "/sessions/:id/settings/app", SessionLive, :app_settings live "/sessions/:id/settings/app", SessionLive, :app_settings
live "/sessions/:id/app-docker", SessionLive, :app_docker live "/sessions/:id/app-docker", SessionLive, :app_docker
live "/sessions/:id/app-teams", SessionLive, :app_teams live "/sessions/:id/app-teams", SessionLive, :app_teams
live "/sessions/:id/app-teams-hub-info", SessionLive, :app_teams_hub_info
live "/sessions/:id/add-file/:tab", SessionLive, :add_file_entry live "/sessions/:id/add-file/:tab", SessionLive, :add_file_entry
live "/sessions/:id/rename-file/:name", SessionLive, :rename_file_entry live "/sessions/:id/rename-file/:name", SessionLive, :rename_file_entry
live "/sessions/:id/bin", SessionLive, :bin live "/sessions/:id/bin", SessionLive, :bin

View file

@ -189,7 +189,7 @@ defmodule LivebookWeb.Integration.Hub.DeploymentGroupTest do
end end
test "shows the agent count", %{conn: conn, hub: hub} do test "shows the agent count", %{conn: conn, hub: hub} do
%{id: id} = insert_deployment_group(mode: :online, hub_id: hub.id) %{id: id} = deployment_group = insert_deployment_group(mode: :online, hub_id: hub.id)
{:ok, view, _html} = live(conn, ~p"/hub/#{hub.id}") {:ok, view, _html} = live(conn, ~p"/hub/#{hub.id}")
@ -200,23 +200,7 @@ defmodule LivebookWeb.Integration.Hub.DeploymentGroupTest do
|> Floki.text() |> Floki.text()
|> String.trim() == "0" |> String.trim() == "0"
org_id = to_string(hub.org_id) simulate_agent_join(hub, deployment_group)
# Simulates the agent join event
pid = Livebook.Hubs.TeamClient.get_pid(hub.id)
agent = build(:agent, hub_id: hub.id, org_id: org_id, deployment_group_id: to_string(id))
livebook_proto_agent =
%LivebookProto.Agent{
id: agent.id,
name: agent.name,
org_id: agent.org_id,
deployment_group_id: agent.deployment_group_id
}
livebook_proto_agent_joined = %LivebookProto.AgentJoined{agent: livebook_proto_agent}
send(pid, {:event, :agent_joined, livebook_proto_agent_joined})
assert_receive {:agent_joined, ^agent}
assert view assert view
|> element("#hub-deployment-group-#{id} [aria-label=\"app servers\"]") |> element("#hub-deployment-group-#{id} [aria-label=\"app servers\"]")

View file

@ -378,7 +378,7 @@ defmodule LivebookWeb.Integration.SessionLiveTest do
end end
end end
describe "deployment group for app deployment" do describe "offline deployment with docker" do
@tag :tmp_dir @tag :tmp_dir
test "show deployment group on app deployment", test "show deployment group on app deployment",
%{conn: conn, user: user, node: node, session: session, tmp_dir: tmp_dir} do %{conn: conn, user: user, node: node, session: session, tmp_dir: tmp_dir} do
@ -475,61 +475,143 @@ defmodule LivebookWeb.Integration.SessionLiveTest do
assert render(view) =~ "None configured" assert render(view) =~ "None configured"
refute has_element?(view, "#select_deployment_group_form") refute has_element?(view, "#select_deployment_group_form")
end end
end
@tag :tmp_dir describe "online deployment" do
test "deploys the app to livebook teams api", test "shows a message when non-teams hub is selected",
%{conn: conn, user: user, node: node, session: session, tmp_dir: tmp_dir} do %{conn: conn, user: user, node: node, session: session} do
team = create_team_hub(user, node) create_team_hub(user, node)
Session.set_notebook_hub(session.pid, team.id)
notebook_path = Path.join(tmp_dir, "notebook.livemd") {:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
file = FileSystem.File.local(notebook_path)
Session.set_file(session.pid, file)
slug = Livebook.Utils.random_short_id()
app_settings = %{Livebook.Notebook.AppSettings.new() | slug: slug}
Session.set_app_settings(session.pid, app_settings)
deployment_group = insert_deployment_group(mode: :online, hub_id: team.id)
Session.set_notebook_deployment_group(session.pid, deployment_group.id)
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}/app-teams")
assert render(view) =~ "Deploy to Livebook Agent"
view view
|> element("#deploy-livebook-agent-button") |> element("a", "Deploy with Livebook Teams")
|> render_click() |> render_click()
assert render(view) =~ assert render(view) =~
"App deployment for #{slug} with title Untitled notebook created successfully" "In order to deploy your app using Livebook Teams, you need to select"
end end
test "deploys the app to livebook teams api without saving the file", test "deployment flow with no deployment groups in the hub",
%{conn: conn, user: user, node: node, session: session} do %{conn: conn, user: user, node: node, session: session} do
team = create_team_hub(user, node) team = create_team_hub(user, node)
Session.set_notebook_hub(session.pid, team.id) Session.set_notebook_hub(session.pid, team.id)
slug = Livebook.Utils.random_short_id() {:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
app_settings = %{Livebook.Notebook.AppSettings.new() | slug: slug}
Session.set_app_settings(session.pid, app_settings)
deployment_group = insert_deployment_group(mode: :online, hub_id: team.id)
Session.set_notebook_deployment_group(session.pid, deployment_group.id)
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}/app-teams")
assert render(view) =~ "Deploy to Livebook Agent"
view view
|> element("#deploy-livebook-agent-button") |> element("a", "Deploy with Livebook Teams")
|> render_click()
# Step: configuring valid app settings
assert render(view) =~ "You must configure your app before deploying it."
slug = Livebook.Utils.random_short_id()
view
|> element(~s/#app-settings-modal form/)
|> render_submit(%{"app_settings" => %{"slug" => slug}})
# From this point forward we are in a child LV
view = find_live_child(view, "app-teams")
assert render(view) =~ "App deployment with Livebook Teams"
# Step: deployment group creation
assert render(view) =~ "Step: add deployment group"
assert render(view) =~ "You must create a deployment group before deploying the app."
view
|> element(~s/#add-deployment-group-form/)
|> render_submit(%{"deployment_group" => %{"name" => "test"}})
# Step: agent instance setup
assert render(view) =~ "Step: add app server"
assert render(view) =~ "You must set up an app server for the app to run on."
assert render(view) =~ "Awaiting an app server to be set up."
[deployment_group] = Livebook.Hubs.TeamClient.get_deployment_groups(team.id)
simulate_agent_join(team, deployment_group)
assert render(view) =~ "An app server is running"
# Step: deploy
view
|> element("button", "Deploy")
|> render_click() |> render_click()
assert render(view) =~ assert render(view) =~
"App deployment for #{slug} with title Untitled notebook created successfully" "App deployment for #{slug} with title Untitled notebook created successfully"
end end
test "returns error when the deployment size is higher than the maximum size of 20MB", test "deployment flow with existing deployment groups in the hub",
%{conn: conn, user: user, node: node, session: session} do
team = create_team_hub(user, node)
Session.set_notebook_hub(session.pid, team.id)
deployment_group = insert_deployment_group(mode: :online, hub_id: team.id)
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
view
|> element("a", "Deploy with Livebook Teams")
|> render_click()
# Step: configuring valid app settings
assert render(view) =~ "You must configure your app before deploying it."
slug = Livebook.Utils.random_short_id()
view
|> element(~s/#app-settings-modal form/)
|> render_submit(%{"app_settings" => %{"slug" => slug}})
# From this point forward we are in a child LV
view = find_live_child(view, "app-teams")
assert render(view) =~ "App deployment with Livebook Teams"
# Step: selecting deployment group
view
|> element(~s/[phx-click="select_deployment_group"][phx-value-id="#{deployment_group.id}"]/)
|> render_click()
%{id: id} = deployment_group
assert_receive {:operation, {:set_notebook_deployment_group, _, ^id}}
assert render(view) =~ "The selected deployment group has no app servers."
view
|> element(~s/button/, "Add app server")
|> render_click()
# Step: agent instance setup
assert render(view) =~ "Step: add app server"
assert render(view) =~ "Awaiting an app server to be set up."
[deployment_group] = Livebook.Hubs.TeamClient.get_deployment_groups(team.id)
simulate_agent_join(team, deployment_group)
assert render(view) =~ "An app server is running"
# Step: deploy
view
|> element("button", "Deploy")
|> render_click()
assert render(view) =~
"App deployment for #{slug} with title Untitled notebook created successfully"
end
test "shows an error when the deployment size is higher than the maximum size of 20MB",
%{conn: conn, user: user, node: node, session: session} do %{conn: conn, user: user, node: node, session: session} do
team = create_team_hub(user, node) team = create_team_hub(user, node)
Session.set_notebook_hub(session.pid, team.id) Session.set_notebook_hub(session.pid, team.id)
@ -548,10 +630,12 @@ defmodule LivebookWeb.Integration.SessionLiveTest do
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}/app-teams") {:ok, view, _} = live(conn, ~p"/sessions/#{session.id}/app-teams")
assert render(view) =~ "Deploy to Livebook Agent" # From this point forward we are in a child LV
view = find_live_child(view, "app-teams")
assert render(view) =~ "App deployment with Livebook Teams"
view view
|> element("#deploy-livebook-agent-button") |> element("button", "Deploy")
|> render_click() |> render_click()
assert render(view) =~ assert render(view) =~

View file

@ -254,6 +254,33 @@ defmodule Livebook.HubHelpers do
:erpc.call(node, TeamsRPC, fun, args) :erpc.call(node, TeamsRPC, fun, args)
end end
def simulate_agent_join(hub, deployment_group) do
Livebook.Teams.Broadcasts.subscribe([:agents])
# Simulates the agent join event
pid = Livebook.Hubs.TeamClient.get_pid(hub.id)
agent =
build(:agent,
hub_id: hub.id,
org_id: to_string(hub.org_id),
deployment_group_id: to_string(deployment_group.id)
)
livebook_proto_agent =
%LivebookProto.Agent{
id: agent.id,
name: agent.name,
org_id: agent.org_id,
deployment_group_id: agent.deployment_group_id
}
livebook_proto_agent_joined = %LivebookProto.AgentJoined{agent: livebook_proto_agent}
send(pid, {:event, :agent_joined, livebook_proto_agent_joined})
assert_receive {:agent_joined, ^agent}
end
defp hub_pid(hub) do defp hub_pid(hub) do
if pid = GenServer.whereis({:via, Registry, {Livebook.HubsRegistry, hub.id}}) do if pid = GenServer.whereis({:via, Registry, {Livebook.HubsRegistry, hub.id}}) do
{:ok, pid} {:ok, pid}