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(
__dirname,
deploy ? "../static/assets" : "../tmp/static_dev/assets"
deploy ? "../static/assets" : "../tmp/static_dev/assets",
);
async function main() {

View file

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

View file

@ -1,4 +1,4 @@
const plugin = require("tailwindcss/plugin")
const plugin = require("tailwindcss/plugin");
module.exports = {
content: [
@ -102,6 +102,19 @@ module.exports = {
},
"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: [
@ -110,9 +123,18 @@ module.exports = {
addVariant("phx-connected", [".phx-connected&", ".phx-connected &"]);
addVariant("phx-error", [".phx-error&", ".phx-error &"]);
addVariant("phx-form-error", [":not(.phx-no-feedback).show-errors &"]);
addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"]);
addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"]);
addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"]);
})
addVariant("phx-click-loading", [
".phx-click-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()}
| {:app_deactivate, client_id()}
| {:app_shutdown, client_id()}
| {:set_notebook_deployment_group, String.t()}
| {:set_notebook_deployment_group, client_id(), String.t()}
@type action ::
:connect_runtime

View file

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

View file

@ -55,7 +55,11 @@ defmodule LivebookWeb.CoreComponents do
<.remix_icon icon="close-line" />
</div>
<.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 == :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>
@ -109,7 +113,7 @@ defmodule LivebookWeb.CoreComponents do
<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 == :success && "border-green-bright-400",
@kind == :warning && "border-yellow-300",
@kind == :error && "border-red-500"
]}>
@ -186,7 +190,7 @@ defmodule LivebookWeb.CoreComponents do
id={"#{@id}-content"}
class={[
"relative max-h-full overflow-y-auto bg-white rounded-lg shadow-xl",
"w-full",
"w-full p-6",
modal_width_class(@width)
]}
role="dialog"
@ -307,7 +311,7 @@ defmodule LivebookWeb.CoreComponents do
<menu
id={"#{@id}-content"}
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),
@md_position && menu_md_position_class(@md_position),
@sm_position && menu_sm_position_class(@sm_position),
@ -316,21 +320,50 @@ defmodule LivebookWeb.CoreComponents do
role="menu"
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>
</div>
"""
end
defp show_menu(id) do
JS.show(to: "##{id}-overlay")
|> JS.show(to: "##{id}-content", display: "flex")
|> JS.dispatch("lb:scroll_into_view", to: "##{id}-content")
@doc """
Shows a menu rendered with `menu/1`.
## 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
defp hide_menu(id) do
JS.hide(to: "##{id}-overlay")
@doc """
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.remove_class("animate-shake", to: "##{id}-content-inner")
end
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 """
Formats the given UTC datetime relatively to present.
"""
@spec format_datetime_relatively(DateTime.t()) :: String.t()
def format_datetime_relatively(date) do
@spec format_datetime_relatively(DateTime.t() | NaiveDateTime.t()) :: String.t()
def format_datetime_relatively(%DateTime{} = date) do
date |> DateTime.to_naive() |> Livebook.Utils.Time.time_ago_in_words()
end
def format_datetime_relatively(%NaiveDateTime{} = date) do
Livebook.Utils.Time.time_ago_in_words(date)
end
@doc """
Returns a list of human readable messages for all upload and upload
entry errors.

View file

@ -50,7 +50,7 @@ defmodule LivebookWeb.AppLive do
</div>
<.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">
<%= @app.notebook_name %>
</h3>

View file

@ -31,7 +31,7 @@ defmodule LivebookWeb.AppSessionLive.SourceComponent do
@impl true
def render(assigns) do
~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">
App source
</h3>

View file

@ -330,7 +330,7 @@ defmodule LivebookWeb.Hub.Edit.TeamComponent do
defp teams_key_modal(assigns) do
~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">
Teams key
</h3>

View file

@ -35,7 +35,7 @@ defmodule LivebookWeb.Hub.FileSystemFormComponent do
@impl true
def render(assigns) do
~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">
<%= @title %>
</h3>

View file

@ -33,7 +33,7 @@ defmodule LivebookWeb.Hub.SecretFormComponent do
@impl true
def render(assigns) do
~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">
<%= @title %>
</h3>

View file

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

View file

@ -52,7 +52,7 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupComponent do
</div>
<!-- Overview -->
<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">
<%= @agents_count %>
</span>
@ -63,7 +63,7 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupComponent do
+ Deploy
</.link>
</.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">
<%= @app_deployments_count %>
</span>
@ -74,7 +74,7 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupComponent do
+ Add new
</.link>
</.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">
<%= Livebook.ZTA.provider_name(@deployment_group.zta_provider) %>
</span>
@ -148,10 +148,11 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupComponent do
patch={~p"/hub/#{@hub.id}"}
>
<.live_component
module={LivebookWeb.Hub.Teams.DeploymentGroupInstanceComponent}
module={LivebookWeb.Hub.Teams.DeploymentGroupAgentComponent}
id="deployment-group-agent-instance"
hub={@hub}
deployment_group={@deployment_group}
return_to={nil}
/>
</.modal>
@ -162,7 +163,7 @@ defmodule LivebookWeb.Hub.Teams.DeploymentGroupComponent do
width={:medium}
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">
New app deployment
</h3>

View file

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

View file

@ -181,6 +181,14 @@ defmodule LivebookWeb.SessionLive do
{socket, %{context: params["context"]}}
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
path_parts =
Enum.map(path_parts, fn

View file

@ -58,7 +58,7 @@ defmodule LivebookWeb.SessionLive.AppDockerComponent do
@impl true
def render(assigns) do
~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">
App deployment with Docker
</h3>

View file

@ -65,9 +65,16 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do
<.button
color="blue"
patch={
if Livebook.Notebook.AppSettings.valid?(@settings),
do: ~p"/sessions/#{@session.id}/app-teams",
else: ~p"/sessions/#{@session.id}/settings/app?context=app-teams"
cond do
Livebook.Hubs.Provider.type(@hub) != "team" ->
~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

View file

@ -21,7 +21,7 @@ defmodule LivebookWeb.SessionLive.AppSettingsComponent do
@impl true
def render(assigns) do
~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">
App settings
</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
def render(assigns) do
~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">
Bin
</h3>

View file

@ -20,7 +20,7 @@ defmodule LivebookWeb.SessionLive.CodeCellSettingsComponent do
@impl true
def render(assigns) do
~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">
Cell settings
</h3>

View file

@ -19,7 +19,7 @@ defmodule LivebookWeb.SessionLive.ExportComponent do
@impl true
def render(assigns) do
~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">
Export
</h3>

View file

@ -34,7 +34,7 @@ defmodule LivebookWeb.SessionLive.InsertImageComponent do
@impl true
def render(assigns) do
~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">
Insert image
</h3>

View file

@ -21,7 +21,7 @@ defmodule LivebookWeb.SessionLive.PackageSearchLive do
@impl true
def render(assigns) do
~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">
Search packages
</h3>

View file

@ -63,7 +63,7 @@ defmodule LivebookWeb.SessionLive.PersistenceComponent do
@impl true
def render(assigns) do
~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">
Save to file
</h3>

View file

@ -25,7 +25,7 @@ defmodule LivebookWeb.SessionLive.RenameFileEntryComponent do
@impl true
def render(assigns) do
~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">
Rename file
</h3>

View file

@ -116,21 +116,25 @@ defmodule LivebookWeb.SessionLive.Render do
/>
</.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
:if={@live_action == :app_teams}
id="app-teams-modal"
:if={@live_action == :app_teams_hub_info}
id="app-teams-hub-info-modal"
show
width={:medium}
width={:big}
patch={@self_path}
>
<.live_component
module={LivebookWeb.SessionLive.AppTeamsComponent}
id="app-teams"
<.app_teams_hub_info_content
any_team_hub?={Enum.any?(@saved_hubs, &(Livebook.Hubs.Provider.type(&1.provider) == "team"))}
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>
@ -430,6 +434,7 @@ defmodule LivebookWeb.SessionLive.Render do
app={@app}
deployed_app_slug={@data_view.deployed_app_slug}
any_session_secrets?={@data_view.any_session_secrets?}
hub={@data_view.hub}
/>
</div>
<div data-el-runtime-info>
@ -794,9 +799,42 @@ defmodule LivebookWeb.SessionLive.Render do
"""
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
~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">
Add file
</h3>

View file

@ -24,7 +24,7 @@ defmodule LivebookWeb.SessionLive.RuntimeComponent do
@impl true
def render(assigns) do
~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">
Runtime settings
</h3>

View file

@ -37,7 +37,7 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do
@impl true
def render(assigns) do
~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">
<%= @title %>
</h3>

View file

@ -155,7 +155,7 @@ defmodule LivebookWeb.SessionLive.ShortcutsComponent do
@impl true
def render(assigns) do
~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">
Keyboard shortcuts
</h3>

View file

@ -24,7 +24,7 @@ defmodule LivebookWeb.SettingsLive.EnvVarComponent do
assigns = assign_new(assigns, :on_save, fn -> "save" end)
~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">
<%= if @operation == :new, do: "Add environment variable", else: "Edit environment variable" %>
</h3>

View file

@ -18,7 +18,7 @@ defmodule LivebookWeb.UserComponent do
@impl true
def render(assigns) do
~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">
User profile
</h3>

View file

@ -109,6 +109,7 @@ defmodule LivebookWeb.Router do
live "/sessions/:id/settings/app", SessionLive, :app_settings
live "/sessions/:id/app-docker", SessionLive, :app_docker
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/rename-file/:name", SessionLive, :rename_file_entry
live "/sessions/:id/bin", SessionLive, :bin

View file

@ -189,7 +189,7 @@ defmodule LivebookWeb.Integration.Hub.DeploymentGroupTest do
end
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}")
@ -200,23 +200,7 @@ defmodule LivebookWeb.Integration.Hub.DeploymentGroupTest do
|> Floki.text()
|> String.trim() == "0"
org_id = to_string(hub.org_id)
# 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}
simulate_agent_join(hub, deployment_group)
assert view
|> element("#hub-deployment-group-#{id} [aria-label=\"app servers\"]")

View file

@ -378,7 +378,7 @@ defmodule LivebookWeb.Integration.SessionLiveTest do
end
end
describe "deployment group for app deployment" do
describe "offline deployment with docker" do
@tag :tmp_dir
test "show deployment group on app deployment",
%{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"
refute has_element?(view, "#select_deployment_group_form")
end
end
@tag :tmp_dir
test "deploys the app to livebook teams api",
%{conn: conn, user: user, node: node, session: session, tmp_dir: tmp_dir} do
team = create_team_hub(user, node)
Session.set_notebook_hub(session.pid, team.id)
describe "online deployment" do
test "shows a message when non-teams hub is selected",
%{conn: conn, user: user, node: node, session: session} do
create_team_hub(user, node)
notebook_path = Path.join(tmp_dir, "notebook.livemd")
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"
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
view
|> element("#deploy-livebook-agent-button")
|> element("a", "Deploy with Livebook Teams")
|> render_click()
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
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
team = create_team_hub(user, node)
Session.set_notebook_hub(session.pid, team.id)
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"
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
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()
assert render(view) =~
"App deployment for #{slug} with title Untitled notebook created successfully"
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
team = create_team_hub(user, node)
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")
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
|> element("#deploy-livebook-agent-button")
|> element("button", "Deploy")
|> render_click()
assert render(view) =~

View file

@ -254,6 +254,33 @@ defmodule Livebook.HubHelpers do
:erpc.call(node, TeamsRPC, fun, args)
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
if pid = GenServer.whereis({:via, Registry, {Livebook.HubsRegistry, hub.id}}) do
{:ok, pid}