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"""
App deployment with Livebook Teams
<.remix_icon icon="corner-down-right-line" /> <%= subtitle %>
<.message_box :for={{kind, message} <- @messages} kind={kind} message={message} />
<.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?}
/>
"""
end
defp subtitle(:add_deployment_group), do: "Step: add deployment group"
defp subtitle(:add_agent), do: "Step: add app server"
defp subtitle(:success), do: "Step: summary"
defp subtitle(_), do: nil
defp content(%{settings_valid?: false} = assigns) do
~H"""
To deploy this app, make sure to specify valid settings.
<.link
class="text-blue-600 font-medium"
patch={~p"/sessions/#{@session.id}/settings/app?context=app-teams"}
>
Configure
<.remix_icon icon="arrow-right-line" />
"""
end
defp content(%{action: :add_deployment_group} = assigns) do
~H"""
<.message_box kind="info">
You must create a deployment group before deploying the app.
<.live_component
module={LivebookWeb.Hub.Teams.DeploymentGroupFormComponent}
id="add-deployment-group"
session={@session}
hub={@hub}
return_to={nil}
force_mode={:online}
hide_title
/>
"""
end
defp content(%{action: :add_agent} = assigns) do
~H"""
<.message_box :if={@initial?} kind="info">
You must set up an app server for the app to run on.
<.live_component
module={LivebookWeb.Hub.Teams.DeploymentGroupAgentComponent}
id="add-agent"
session={@session}
hub={@hub}
deployment_group={@deployment_group}
hide_title
/>
Status
<%= if @num_agents[@deployment_group.id] do %>
<.message_box kind="success">
An app server is running, click "Deploy" to ship the app!
<% else %>
<.message_box kind="info">
Awaiting an app server to be set up. If you click "Deploy anyway",
the app will only start once there is an app server.
<% end %>
<%= if @num_agents[@deployment_group.id] do %>
<.button color="blue" phx-click="deploy_app">
<.remix_icon icon="rocket-line" /> Deploy
<% else %>
<.button color="blue" outlined phx-click="deploy_app">
<.remix_icon icon="rocket-line" /> Deploy anyway
<% end %>
<.button color="gray" outlined phx-click="go_deployment_groups">
See deployment groups
"""
end
defp content(%{action: :deployment_groups} = assigns) do
~H"""
Deploy this app to your cloud infrastructure using the <.workspace hub={@hub} /> workspace.
<%= if @deployment_group do %>
<.deployment_group_entry
deployment_group={@deployment_group}
num_agents={@num_agents}
num_app_deployments={@num_app_deployments}
active
/>
Current version:
<.app_deployment_card app_deployment={@app_deployment} deployment_group={@deployment_group} />
<.message_box :if={@num_agents[@deployment_group.id] == nil} kind="warning">
The selected deployment group has no app servers. If you click "Deploy anyway",
the app will only start once there is an app server.
<%= if @num_agents[@deployment_group.id] do %>
<.button color="blue" phx-click="deploy_app">
<.remix_icon icon="rocket-line" /> Deploy
<% else %>
<.button color="blue" phx-click="go_add_agent">
<.remix_icon icon="add-line" /> Add app server
<.button color="blue" outlined phx-click="deploy_app">
<.remix_icon icon="rocket-line" /> Deploy anyway
<% 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" />
Choose a deployment group:
<.deployment_group_entry
:for={deployment_group <- @deployment_groups}
deployment_group={deployment_group}
num_agents={@num_agents}
num_app_deployments={@num_app_deployments}
selectable
/>
<% end %>
"""
end
defp content(%{action: :success} = assigns) do
~H"""
<.message_box kind="success">
App deployment created successfully.
<.link
href={"#{Livebook.Config.teams_url()}/orgs/#{@hub.org_id}"}
target="_blank"
class="font-medium text-blue-600"
>
See all deployed apps
<.remix_icon icon="external-link-line" />
<.app_deployment_card
:if={@app_deployment}
app_deployment={@app_deployment}
deployment_group={@deployment_group}
/>
<.button color="gray" outlined phx-click="go_deployment_groups">
See deployment groups
"""
end
defp workspace(assigns) do
~H"""
<%= @hub.hub_emoji %>
<%= @hub.hub_name %>
"""
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"""
<%= @deployment_group.name %>
(<%= url %>)
App servers: <%= @num_agents[@deployment_group.id] || 0 %>
Apps deployed: <%= @num_app_deployments[@deployment_group.id] || 0 %>
"""
end
defp app_deployment_card(assigns) do
~H"""
<.labeled_text label="Slug">
<%= if @deployment_group.url do %>
<.link
href={@deployment_group.url <> ~p"/apps/#{@app_deployment.slug}"}
target="_blank"
class="text-blue-600 font-medium"
>
/<%= @app_deployment.slug %>
<% else %>
/<%= @app_deployment.slug %>
<% end %>
<.labeled_text label="Title">
<%= @app_deployment.title %>
<.labeled_text label="Deployed by">
<%= @app_deployment.deployed_by %>
<.labeled_text label="Deployed">
<%= LivebookWeb.HTMLHelpers.format_datetime_relatively(@app_deployment.deployed_at) %> ago
"""
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
{:noreply, navigate(socket, :success)}
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, :success] 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} -> {"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