Allow users to assign folders to their apps (#3088)

This commit is contained in:
Alexandre de Souza 2025-11-04 10:24:45 -03:00 committed by GitHub
parent 6285a5f395
commit 82b2b285d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 533 additions and 26 deletions

View file

@ -281,4 +281,6 @@ defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Personal do
def deployment_groups(_personal), do: nil
def get_app_specs(_personal), do: []
def get_app_folders(_personal), do: []
end

View file

@ -155,4 +155,10 @@ defprotocol Livebook.Hubs.Provider do
"""
@spec get_app_specs(t()) :: list(Livebook.Apps.AppSpec.t())
def get_app_specs(hub)
@doc """
Gets the app folders from the given hub.
"""
@spec get_app_folders(t()) :: list(%{id: String.t(), name: String.t()})
def get_app_folders(hub)
end

View file

@ -259,6 +259,12 @@ defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Team do
end
end
def get_app_folders(team) do
team.id
|> TeamClient.get_app_folders()
|> Enum.sort_by(& &1.name)
end
defp parse_secret_errors(errors_map) do
Teams.Requests.to_error_list(Secret, errors_map)
end

View file

@ -24,6 +24,7 @@ defmodule Livebook.Hubs.TeamClient do
deployment_groups: [],
app_deployments: [],
agents: [],
app_folders: [],
app_deployment_statuses: nil
]
@ -172,6 +173,14 @@ defmodule Livebook.Hubs.TeamClient do
GenServer.call(registry_name(id), {:user_can_deploy?, user_id, deployment_group_id})
end
@doc """
Returns a list of cached app folders.
"""
@spec get_app_folders(String.t()) :: list(Teams.AppFolder.t())
def get_app_folders(id) do
GenServer.call(registry_name(id), :get_app_folders)
end
@doc """
Returns if the Team client is connected.
"""
@ -370,6 +379,10 @@ defmodule Livebook.Hubs.TeamClient do
end
end
def handle_call(:get_app_folders, _caller, state) do
{:reply, state.app_folders, state}
end
@impl true
def handle_info(:connected, state) do
Hubs.Broadcasts.hub_connected(state.hub.id)
@ -611,7 +624,8 @@ defmodule Livebook.Hubs.TeamClient do
file: nil,
deployed_by: app_deployment.deployed_by,
deployed_at: DateTime.from_gregorian_seconds(app_deployment.deployed_at),
authorization_groups: authorization_groups
authorization_groups: authorization_groups,
app_folder_id: nullify(app_deployment.app_folder_id)
}
end
@ -630,7 +644,8 @@ defmodule Livebook.Hubs.TeamClient do
for authorization_group <- authorization_groups do
%Teams.AuthorizationGroup{
provider_id: authorization_group.provider_id,
group_name: authorization_group.group_name
group_name: authorization_group.group_name,
app_folder_id: nullify(authorization_group.app_folder_id)
}
end
end
@ -664,6 +679,24 @@ defmodule Livebook.Hubs.TeamClient do
}
end
defp put_app_folder(state, app_folder) do
state = remove_app_folder(state, app_folder)
%{state | app_folders: [app_folder | state.app_folders]}
end
defp remove_app_folder(state, app_folder) do
%{state | app_folders: Enum.reject(state.app_folders, &(&1.id == app_folder.id))}
end
defp build_app_folder(state, %LivebookProto.AppFolder{} = app_folder) do
%Teams.AppFolder{
id: app_folder.id,
name: app_folder.name,
hub_id: state.hub.id
}
end
defp handle_event(:secret_created, %Secrets.Secret{} = secret, state) do
Hubs.Broadcasts.secret_created(secret)
@ -787,6 +820,7 @@ defmodule Livebook.Hubs.TeamClient do
|> dispatch_deployment_groups(user_connected)
|> dispatch_app_deployments(user_connected)
|> dispatch_agents(user_connected)
|> dispatch_app_folders(user_connected)
|> dispatch_connection()
end
@ -798,6 +832,7 @@ defmodule Livebook.Hubs.TeamClient do
|> dispatch_deployment_groups(agent_connected)
|> dispatch_app_deployments(agent_connected)
|> dispatch_agents(agent_connected)
|> dispatch_app_folders(agent_connected)
|> dispatch_connection()
end
@ -873,6 +908,43 @@ defmodule Livebook.Hubs.TeamClient do
update_hub(state, org_updated)
end
defp handle_event(:app_folder_created, %Teams.AppFolder{} = app_folder, state) do
Teams.Broadcasts.app_folder_created(app_folder)
put_app_folder(state, app_folder)
end
defp handle_event(:app_folder_created, app_folder_created, state) do
handle_event(
:app_folder_created,
build_app_folder(state, app_folder_created.app_folder),
state
)
end
defp handle_event(:app_folder_updated, %Teams.AppFolder{} = app_folder, state) do
Teams.Broadcasts.app_folder_updated(app_folder)
put_app_folder(state, app_folder)
end
defp handle_event(:app_folder_updated, app_folder_updated, state) do
handle_event(
:app_folder_updated,
build_app_folder(state, app_folder_updated.app_folder),
state
)
end
defp handle_event(:app_folder_deleted, %Teams.AppFolder{} = app_folder, state) do
Teams.Broadcasts.app_folder_deleted(app_folder)
remove_app_folder(state, app_folder)
end
defp handle_event(:app_folder_deleted, %{id: id}, state) do
with {:ok, app_folder} <- fetch_app_folder(id, state) do
handle_event(:app_folder_deleted, app_folder, state)
end
end
defp dispatch_secrets(state, %{secrets: secrets}) do
decrypted_secrets = Enum.map(secrets, &build_secret(state, &1))
@ -936,6 +1008,19 @@ defmodule Livebook.Hubs.TeamClient do
dispatch_events(state, agent_joined: joined, agent_left: left)
end
defp dispatch_app_folders(state, %{app_folders: app_folders}) do
app_folders = Enum.map(app_folders, &build_app_folder(state, &1))
{created, deleted, updated} =
diff(state.app_folders, app_folders, &(&1.id == &2.id))
dispatch_events(state,
app_folder_deleted: deleted,
app_folder_created: created,
app_folder_updated: updated
)
end
defp dispatch_connection(%{hub: %{id: id}} = state) do
Teams.Broadcasts.client_connected(id)
state
@ -1064,6 +1149,8 @@ defmodule Livebook.Hubs.TeamClient do
defp fetch_app_deployment_from_slug(slug, state),
do: fetch_entry(state.app_deployments, &(&1.slug == slug), state)
defp fetch_app_folder(id, state), do: fetch_entry(state.app_folders, &(&1.id == id), state)
defp fetch_entry(entries, fun, state) do
if entry = Enum.find(entries, fun) do
{:ok, entry}

View file

@ -113,7 +113,8 @@ defmodule Livebook.LiveMarkdown.Export do
:auto_shutdown_ms,
:access_type,
:show_source,
:output_type
:output_type,
:app_folder_id
]
put_unless_default(

View file

@ -495,6 +495,9 @@ defmodule Livebook.LiveMarkdown.Import do
{"show_source", show_source}, attrs ->
Map.put(attrs, :show_source, show_source)
{"app_folder_id", app_folder_id}, attrs ->
Map.put(attrs, :app_folder_id, app_folder_id)
{"output_type", output_type}, attrs when output_type in ["all", "rich"] ->
Map.put(attrs, :output_type, String.to_atom(output_type))
@ -664,7 +667,25 @@ defmodule Livebook.LiveMarkdown.Import do
# validate it against the public key).
teams_enabled = is_struct(hub, Livebook.Hubs.Team) and (hub.offline == nil or stamp_verified?)
{%{notebook | teams_enabled: teams_enabled}, stamp_verified?, messages}
{app_settings, messages} =
if app_folder_id = notebook.app_settings.app_folder_id do
app_folders = Hubs.Provider.get_app_folders(hub)
if Enum.any?(app_folders, &(&1.id == app_folder_id)) do
{notebook.app_settings, messages}
else
{Map.replace!(notebook.app_settings, :app_folder_id, nil),
messages ++
[
"notebook is assigned to a non-existent app folder, defaulting to ungrouped app folder"
]}
end
else
{notebook.app_settings, messages}
end
{%{notebook | app_settings: app_settings, teams_enabled: teams_enabled}, stamp_verified?,
messages}
end
defp safe_binary_split(binary, offset)

View file

@ -14,7 +14,8 @@ defmodule Livebook.Notebook.AppSettings do
access_type: access_type(),
password: String.t() | nil,
show_source: boolean(),
output_type: output_type()
output_type: output_type(),
app_folder_id: String.t() | nil
}
@type access_type :: :public | :protected
@ -33,6 +34,7 @@ defmodule Livebook.Notebook.AppSettings do
field :password, :string
field :show_source, :boolean
field :output_type, Ecto.Enum, values: [:all, :rich]
field :app_folder_id, :string
end
@doc """
@ -49,7 +51,8 @@ defmodule Livebook.Notebook.AppSettings do
access_type: :protected,
password: generate_password(),
show_source: false,
output_type: :all
output_type: :all,
app_folder_id: nil
}
end
@ -82,7 +85,8 @@ defmodule Livebook.Notebook.AppSettings do
:auto_shutdown_ms,
:access_type,
:show_source,
:output_type
:output_type,
:app_folder_id
])
|> validate_required([
:slug,

View file

@ -904,6 +904,7 @@ defmodule Livebook.Session do
def init({caller_pid, opts}) do
Livebook.Settings.subscribe()
Livebook.Hubs.Broadcasts.subscribe([:crud, :secrets, :file_systems])
Livebook.Teams.Broadcasts.subscribe(:app_folders)
id = Keyword.fetch!(opts, :id)
@ -2028,6 +2029,13 @@ defmodule Livebook.Session do
{:noreply, handle_operation(state, operation)}
end
def handle_info({event, app_folder}, state)
when event in [:app_folder_created, :app_folder_updated, :app_folder_deleted] and
app_folder.hub_id == state.data.notebook.hub_id do
operation = {:sync_hub_app_folders, @client_id}
{:noreply, handle_operation(state, operation)}
end
def handle_info({:hub_deleted, id}, %{data: %{notebook: %{hub_id: id}}} = state) do
# Since the hub got deleted, we close all sessions using that hub.
# This way we clean up all secrets and other in-memory state that

View file

@ -37,6 +37,7 @@ defmodule Livebook.Session.Data do
:secrets,
:hub_secrets,
:hub_file_systems,
:hub_app_folders,
:mode,
:deployed_app_slug,
:app_data
@ -247,6 +248,7 @@ defmodule Livebook.Session.Data do
| {:set_notebook_hub, client_id(), String.t()}
| {:sync_hub_secrets, client_id()}
| {:sync_hub_file_systems, client_id()}
| {:sync_hub_app_folders, client_id()}
| {:add_file_entries, client_id(), list(Notebook.file_entry())}
| {:rename_file_entry, client_id(), name :: String.t(), new_name :: String.t()}
| {:delete_file_entry, client_id(), String.t()}
@ -305,6 +307,7 @@ defmodule Livebook.Session.Data do
hub = Livebook.Hubs.fetch_hub!(notebook.hub_id)
hub_secrets = Livebook.Hubs.get_secrets(hub)
hub_file_systems = Livebook.Hubs.get_file_systems(hub)
hub_app_folders = Livebook.Hubs.Provider.get_app_folders(hub)
startup_secrets =
for secret <- Livebook.Secrets.get_startup_secrets(),
@ -338,6 +341,7 @@ defmodule Livebook.Session.Data do
secrets: secrets,
hub_secrets: hub_secrets,
hub_file_systems: hub_file_systems,
hub_app_folders: hub_app_folders,
mode: opts[:mode],
deployed_app_slug: nil,
app_data: app_data
@ -1074,6 +1078,14 @@ defmodule Livebook.Session.Data do
|> wrap_ok()
end
def apply_operation(data, {:sync_hub_app_folders, _client_id}) do
data
|> with_actions()
|> sync_hub_app_folders()
|> set_dirty()
|> wrap_ok()
end
def apply_operation(data, {:add_file_entries, _client_id, file_entries}) do
data
|> with_actions()
@ -1965,7 +1977,8 @@ defmodule Livebook.Session.Data do
teams_enabled: is_struct(hub, Livebook.Hubs.Team)
},
hub_secrets: Livebook.Hubs.get_secrets(hub),
hub_file_systems: Livebook.Hubs.get_file_systems(hub)
hub_file_systems: Livebook.Hubs.get_file_systems(hub),
hub_app_folders: Livebook.Hubs.Provider.get_app_folders(hub)
)
end
@ -1985,6 +1998,12 @@ defmodule Livebook.Session.Data do
set!(data_actions, hub_file_systems: file_systems)
end
defp sync_hub_app_folders({data, _} = data_actions) do
hub = Livebook.Hubs.fetch_hub!(data.notebook.hub_id)
app_folders = Livebook.Hubs.Provider.get_app_folders(hub)
set!(data_actions, hub_app_folders: app_folders)
end
defp update_notebook_hub_secret_names({data, _} = data_actions) do
hub_secret_names =
for {_name, secret} <- data.secrets, secret.hub_id == data.notebook.hub_id, do: secret.name

View file

@ -305,4 +305,12 @@ defmodule Livebook.Teams do
def user_can_deploy?(%Team{} = team, %Teams.DeploymentGroup{} = deployment_group) do
TeamClient.user_can_deploy?(team.id, team.user_id, deployment_group.id)
end
@doc """
Gets a list of app folders for a given Hub.
"""
@spec get_app_folders(Team.t()) :: list(Teams.AppFolder.t())
def get_app_folders(team) do
Hubs.Provider.get_app_folders(team)
end
end

View file

@ -14,6 +14,7 @@ defmodule Livebook.Teams.AppDeployment do
access_type: Livebook.Notebook.AppSettings.access_type(),
hub_id: String.t() | nil,
deployment_group_id: String.t() | nil,
app_folder_id: String.t() | nil,
file: binary() | nil,
deployed_by: String.t() | nil,
deployed_at: DateTime.t() | nil,
@ -32,6 +33,7 @@ defmodule Livebook.Teams.AppDeployment do
field :access_type, Ecto.Enum, values: @access_types
field :hub_id, :string
field :deployment_group_id, :string
field :app_folder_id, :string
field :file, :string
field :deployed_by, :string
field :deployed_at, :utc_datetime
@ -75,6 +77,7 @@ defmodule Livebook.Teams.AppDeployment do
title: notebook.name,
multi_session: notebook.app_settings.multi_session,
access_type: notebook.app_settings.access_type,
app_folder_id: notebook.app_settings.app_folder_id,
hub_id: notebook.hub_id,
deployment_group_id: notebook.deployment_group_id,
file: zip_content

View file

@ -0,0 +1,15 @@
defmodule Livebook.Teams.AppFolder do
use Ecto.Schema
@type t :: %__MODULE__{
id: String.t() | nil,
name: String.t() | nil,
hub_id: String.t() | nil
}
@primary_key {:id, :string, autogenerate: false}
embedded_schema do
field :name, :string
field :hub_id, :string
end
end

View file

@ -3,12 +3,14 @@ defmodule Livebook.Teams.AuthorizationGroup do
@type t :: %__MODULE__{
provider_id: String.t() | nil,
group_name: String.t() | nil
group_name: String.t() | nil,
app_folder_id: String.t() | nil
}
@primary_key false
embedded_schema do
field :provider_id, :string
field :group_name, :string
field :app_folder_id, :string
end
end

View file

@ -7,6 +7,7 @@ defmodule Livebook.Teams.Broadcasts do
@app_deployments_topic "teams:app_deployments"
@clients_topic "teams:clients"
@deployment_groups_topic "teams:deployment_groups"
@app_folders_topic "teams:app_folders"
@app_server_topic "teams:app_server"
@doc """
@ -40,6 +41,12 @@ defmodule Livebook.Teams.Broadcasts do
* `{:server_authorization_updated, DeploymentGroup.t()}`
Topic `#{@app_folders_topic}`:
* `{:app_folder_created, AppFolder.t()}`
* `{:app_folder_updated, AppFolder.t()}`
* `{:app_folder_deleted, AppFolder.t()}`
"""
@spec subscribe(atom() | list(atom())) :: :ok | {:error, term()}
def subscribe(topics) when is_list(topics) do
@ -154,6 +161,30 @@ defmodule Livebook.Teams.Broadcasts do
broadcast(@app_server_topic, {:server_authorization_updated, deployment_group})
end
@doc """
Broadcasts under `#{@app_folders_topic}` topic when hub received a new app folder.
"""
@spec app_folder_created(Teams.AppFolder.t()) :: broadcast()
def app_folder_created(%Teams.AppFolder{} = app_folder) do
broadcast(@app_folders_topic, {:app_folder_created, app_folder})
end
@doc """
Broadcasts under `#{@app_folders_topic}` topic when hub received an updated app folder.
"""
@spec app_folder_updated(Teams.AppFolder.t()) :: broadcast()
def app_folder_updated(%Teams.AppFolder{} = app_folder) do
broadcast(@app_folders_topic, {:app_folder_updated, app_folder})
end
@doc """
Broadcasts under `#{@app_folders_topic}` topic when hub received a deleted app folder.
"""
@spec app_folder_deleted(Teams.AppFolder.t()) :: broadcast()
def app_folder_deleted(%Teams.AppFolder{} = app_folder) do
broadcast(@app_folders_topic, {:app_folder_deleted, app_folder})
end
defp broadcast(topic, message) do
Phoenix.PubSub.broadcast(Livebook.PubSub, topic, message)
end

View file

@ -188,6 +188,7 @@ defmodule Livebook.Teams.Requests do
slug: app_deployment.slug,
multi_session: app_deployment.multi_session,
access_type: app_deployment.access_type,
app_folder_id: app_deployment.app_folder_id,
deployment_group_id: app_deployment.deployment_group_id,
sha: app_deployment.sha
}
@ -249,6 +250,7 @@ defmodule Livebook.Teams.Requests do
slug: app_deployment.slug,
multi_session: app_deployment.multi_session,
access_type: app_deployment.access_type,
app_folder_id: app_deployment.app_folder_id,
deployment_group_id: deployment_group_id,
sha: app_deployment.sha
}

View file

@ -1865,6 +1865,7 @@ defmodule LivebookWeb.SessionLive do
hub: Livebook.Hubs.fetch_hub!(data.notebook.hub_id),
hub_secrets: data.hub_secrets,
hub_file_systems: data.hub_file_systems,
hub_app_folders: data.hub_app_folders,
any_session_secrets?:
Session.Data.session_secrets(data.secrets, data.notebook.hub_id) != [],
file_entries: Enum.sort_by(data.notebook.file_entries, & &1.name),

View file

@ -12,10 +12,15 @@ defmodule LivebookWeb.SessionLive.AppSettingsComponent do
_ -> AppSettings.change(assigns.settings)
end
app_folder_options =
for app_folder <- assigns.app_folders do
{app_folder.name, app_folder.id}
end
{:ok,
socket
|> assign(assigns)
|> assign(changeset: changeset)}
|> assign(app_folder_options: app_folder_options, changeset: changeset)}
end
@impl true
@ -42,6 +47,17 @@ defmodule LivebookWeb.SessionLive.AppSettingsComponent do
>
<div class="flex flex-col space-y-4">
<.text_field field={f[:slug]} label="Slug" spellcheck="false" phx-debounce />
<.select_field
field={f[:app_folder_id]}
label="Folder"
prompt="Select a folder..."
options={@app_folder_options}
help={
~S'''
You can create folders inside Teams to organize how apps are displayed.
'''
}
/>
<div class="flex flex-col space-y-1">
<.checkbox_field
field={f[:access_type]}

View file

@ -212,7 +212,11 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do
<div :if={@app_deployment} class="space-y-3">
<p class="text-gray-700">Current version:</p>
<.app_deployment_card app_deployment={@app_deployment} deployment_group={@deployment_group} />
<.app_deployment_card
app_deployment={@app_deployment}
deployment_group={@deployment_group}
hub={@hub}
/>
</div>
<.message_box :if={@num_agents[@deployment_group.id] == nil} kind="warning">
@ -293,6 +297,7 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do
:if={@app_deployment}
app_deployment={@app_deployment}
deployment_group={@deployment_group}
hub={@hub}
/>
<div>
<.button color="gray" outlined phx-click="go_deployment_groups">
@ -392,6 +397,9 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do
<.labeled_text label="Title">
{@app_deployment.title}
</.labeled_text>
<.labeled_text label="Folder">
{app_folder_name(@hub, @app_deployment.app_folder_id)}
</.labeled_text>
<.labeled_text label="Deployed by">
{@app_deployment.deployed_by}
</.labeled_text>
@ -593,4 +601,12 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do
String.replace(acc, "%{#{key}}", to_string(value))
end)
end
defp app_folder_name(_hub, id) when id in [nil, ""], do: "Ungrouped apps"
defp app_folder_name(hub, id) do
hub
|> Teams.get_app_folders()
|> Enum.find_value(&(&1.id == id && &1.name))
end
end

View file

@ -103,6 +103,7 @@ defmodule LivebookWeb.SessionLive.Render do
settings={@data_view.app_settings}
context={@action_assigns.context}
deployed_app_slug={@data_view.deployed_app_slug}
app_folders={@data_view.hub_app_folders}
/>
</.modal>

View file

@ -3,5 +3,5 @@ defmodule LivebookProto.AuthorizationGroup do
field :provider_id, 1, type: :string, json_name: "providerId"
field :group_name, 2, type: :string, json_name: "groupName"
field :app_folder_ids, 3, repeated: true, type: :string, json_name: "appFolderIds"
field :app_folder_id, 3, type: :string, json_name: "appFolderId"
end

View file

@ -218,7 +218,7 @@ message EnvironmentVariable {
message AuthorizationGroup {
string provider_id = 1;
string group_name = 2;
repeated string app_folder_ids = 3;
string app_folder_id = 3;
}
message DeploymentUser {

View file

@ -1152,12 +1152,13 @@ defmodule Livebook.LiveMarkdown.ExportTest do
auto_shutdown_ms: 5_000,
access_type: :public,
show_source: true,
output_type: :rich
output_type: :rich,
app_folder_id: "123"
}
}
expected_document = """
<!-- livebook:{"app_settings":{"access_type":"public","auto_shutdown_ms":5000,"multi_session":true,"output_type":"rich","show_existing_sessions":true,"show_source":true,"slug":"app"}} -->
<!-- livebook:{"app_settings":{"access_type":"public","app_folder_id":"123","auto_shutdown_ms":5000,"multi_session":true,"output_type":"rich","show_existing_sessions":true,"show_source":true,"slug":"app"}} -->
# My Notebook
"""

View file

@ -785,7 +785,7 @@ defmodule Livebook.LiveMarkdown.ImportTest do
describe "app settings" do
test "imports settings" do
markdown = """
<!-- livebook:{"app_settings":{"access_type":"public","auto_shutdown_ms":5000,"multi_session":true,"output_type":"rich","show_existing_sessions":false,"show_source":true,"slug":"app"}} -->
<!-- livebook:{"app_settings":{"access_type":"public","app_folder_id":"123","auto_shutdown_ms":5000,"multi_session":true,"output_type":"rich","show_existing_sessions":false,"show_source":true,"slug":"app"}} -->
# My Notebook
"""
@ -802,7 +802,8 @@ defmodule Livebook.LiveMarkdown.ImportTest do
auto_shutdown_ms: 5_000,
access_type: :public,
show_source: true,
output_type: :rich
output_type: :rich,
app_folder_id: "123"
}
} = notebook
end

View file

@ -23,11 +23,14 @@ defmodule LivebookCLI.Integration.DeployTest do
app_path = Path.join(tmp_dir, "#{slug}.livemd")
{key, _} = TeamsRPC.create_org_token(node, org: org)
deployment_group = TeamsRPC.create_deployment_group(node, org: org, url: @url)
app_folder = TeamsRPC.create_app_folder(node, org: org)
hub_id = team.id
deployment_group_id = to_string(deployment_group.id)
app_folder_id = to_string(app_folder.id)
stamp_notebook(app_path, """
<!-- livebook:{"app_settings":{"access_type":"public","slug":"#{slug}"},"hub_id":"#{hub_id}"} -->
<!-- livebook:{"app_settings":{"access_type":"public","app_folder_id":"#{app_folder_id}","slug":"#{slug}"},"hub_id":"#{hub_id}"} -->
# #{title}
@ -56,6 +59,7 @@ defmodule LivebookCLI.Integration.DeployTest do
title: ^title,
slug: ^slug,
deployment_group_id: ^deployment_group_id,
app_folder_id: ^app_folder_id,
hub_id: ^hub_id,
deployed_by: "CLI"
}}
@ -106,6 +110,7 @@ defmodule LivebookCLI.Integration.DeployTest do
title: ^title,
slug: ^slug,
deployment_group_id: ^deployment_group_id,
app_folder_id: nil,
hub_id: ^hub_id,
deployed_by: "CLI"
}}
@ -168,6 +173,7 @@ defmodule LivebookCLI.Integration.DeployTest do
title: ^title,
slug: ^slug,
deployment_group_id: ^deployment_group_id,
app_folder_id: nil,
hub_id: ^hub_id,
deployed_by: "CLI"
}}

View file

@ -6,7 +6,13 @@ defmodule Livebook.Hubs.TeamClientTest do
setup :teams
@moduletag subscribe_to_hubs_topics: [:crud, :connection, :file_systems, :secrets]
@moduletag subscribe_to_teams_topics: [:clients, :deployment_groups, :app_deployments, :agents]
@moduletag subscribe_to_teams_topics: [
:clients,
:deployment_groups,
:app_deployments,
:agents,
:app_folders
]
describe "connect" do
@describetag teams_for: :user
@ -303,6 +309,44 @@ defmodule Livebook.Hubs.TeamClientTest do
assert_receive {:agent_left, ^agent}
refute agent in TeamClient.get_agents(team.id)
end
test "dispatches the app folders list",
%{team: team, pid: pid, user_connected: user_connected} do
app_folder = build(:app_folder, hub_id: team.id)
livebook_proto_app_folder =
%LivebookProto.AppFolder{
id: app_folder.id,
name: app_folder.name
}
# creates the app folder
user_connected = %{user_connected | app_folders: [livebook_proto_app_folder]}
refute_received {:app_folder_created, ^app_folder}
send(pid, {:event, :user_connected, user_connected})
assert_receive {:app_folder_created, ^app_folder}
assert app_folder in TeamClient.get_app_folders(team.id)
# updates the app folder
updated_app_folder = %{app_folder | name: "ChonkiestCat"}
updated_livebook_proto_app_folder = %{
livebook_proto_app_folder
| name: updated_app_folder.name
}
user_connected = %{user_connected | app_folders: [updated_livebook_proto_app_folder]}
send(pid, {:event, :user_connected, user_connected})
assert_receive {:app_folder_updated, ^updated_app_folder}
refute app_folder in TeamClient.get_app_folders(team.id)
assert updated_app_folder in TeamClient.get_app_folders(team.id)
# deletes the app folder
user_connected = %{user_connected | app_folders: []}
send(pid, {:event, :user_connected, user_connected})
assert_receive {:app_folder_deleted, ^updated_app_folder}
refute updated_app_folder in TeamClient.get_app_folders(team.id)
end
end
describe "handle agent_connected event" do
@ -809,5 +853,43 @@ defmodule Livebook.Hubs.TeamClientTest do
assert_receive {:agent_left, ^agent}
refute agent in TeamClient.get_agents(team.id)
end
test "dispatches the app folders list",
%{team: team, pid: pid, agent_connected: agent_connected} do
app_folder = build(:app_folder, hub_id: team.id)
livebook_proto_app_folder =
%LivebookProto.AppFolder{
id: app_folder.id,
name: app_folder.name
}
# creates the app folder
agent_connected = %{agent_connected | app_folders: [livebook_proto_app_folder]}
refute_received {:app_folder_created, ^app_folder}
send(pid, {:event, :agent_connected, agent_connected})
assert_receive {:app_folder_created, ^app_folder}
assert app_folder in TeamClient.get_app_folders(team.id)
# updates the app folder
updated_app_folder = %{app_folder | name: "ChonkiestCat"}
updated_livebook_proto_app_folder = %{
livebook_proto_app_folder
| name: updated_app_folder.name
}
agent_connected = %{agent_connected | app_folders: [updated_livebook_proto_app_folder]}
send(pid, {:event, :agent_connected, agent_connected})
assert_receive {:app_folder_updated, ^updated_app_folder}
refute app_folder in TeamClient.get_app_folders(team.id)
assert updated_app_folder in TeamClient.get_app_folders(team.id)
# deletes the app folder
agent_connected = %{agent_connected | app_folders: []}
send(pid, {:event, :agent_connected, agent_connected})
assert_receive {:app_folder_deleted, ^updated_app_folder}
refute updated_app_folder in TeamClient.get_app_folders(team.id)
end
end
end

View file

@ -0,0 +1,48 @@
defmodule Livebook.Integration.LiveMarkdown.ImportTest do
use Livebook.TeamsIntegrationCase, async: true
alias Livebook.Notebook
alias Livebook.LiveMarkdown
@moduletag teams_for: :user
setup :teams
@moduletag subscribe_to_hubs_topics: [:connection]
@moduletag subscribe_to_teams_topics: [:clients, :app_folders]
describe "app settings" do
test "don't import app folder if does not exists anymore",
%{node: node, team: team, org: org} do
app_folder = TeamsRPC.create_app_folder(node, name: "delete me", org: org)
app_folder_id = to_string(app_folder.id)
hub_id = team.id
assert_receive {:app_folder_created, %{id: ^app_folder_id, hub_id: ^hub_id}}
notebook = %{
Notebook.new()
| name: "Deleted from folder",
hub_id: hub_id,
app_settings: %{Notebook.AppSettings.new() | app_folder_id: app_folder_id},
sections: [
%{
Notebook.Section.new()
| name: "Section 1",
cells: []
}
]
}
{markdown, []} = LiveMarkdown.Export.notebook_to_livemd(notebook)
TeamsRPC.delete_app_folder(node, app_folder)
assert_receive {:app_folder_deleted, %{id: ^app_folder_id, hub_id: ^hub_id}}
assert {%Notebook{name: "Deleted from folder", app_settings: %{app_folder_id: nil}},
%{warnings: warnings}} = LiveMarkdown.Import.notebook_from_livemd(markdown)
assert "notebook is assigned to a non-existent app folder, defaulting to ungrouped app folder" in warnings
end
end
end

View file

@ -440,7 +440,8 @@ defmodule LivebookWeb.Integration.SessionLiveTest do
:agents,
:app_deployments,
:deployment_groups,
:app_server
:app_server,
:app_folders
]
test "shows a message when non-teams hub is selected", %{conn: conn, session: session} do
@ -491,7 +492,6 @@ defmodule LivebookWeb.Integration.SessionLiveTest do
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)
@ -505,9 +505,8 @@ defmodule LivebookWeb.Integration.SessionLiveTest do
|> element("button", "Deploy")
|> render_click()
assert render(view) =~
"App deployment created successfully"
assert render(view) =~ "App deployment created successfully"
assert render(view) =~ "Ungrouped apps"
assert render(view) =~ "#{Livebook.Config.teams_url()}/orgs/#{team.org_id}"
end
@ -567,8 +566,71 @@ defmodule LivebookWeb.Integration.SessionLiveTest do
|> element("button", "Deploy")
|> render_click()
assert render(view) =~
"App deployment created successfully"
assert render(view) =~ "App deployment created successfully"
assert render(view) =~ "Ungrouped apps"
end
test "deployment flow with existing app folders in the hub",
%{team: team, conn: conn, node: node, session: session, org: org} do
Session.set_notebook_hub(session.pid, team.id)
id = insert_deployment_group(mode: :online, hub_id: team.id).id
assert_receive {:deployment_group_created, %{id: ^id} = deployment_group}
app_folder_id = to_string(TeamsRPC.create_app_folder(node, org: org).id)
assert_receive {:app_folder_created, %{id: ^app_folder_id} = app_folder}
{: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, "app_folder_id" => app_folder_id}})
# 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()
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 created successfully"
assert render(view) =~ app_folder.name
end
test "shows tooltip message if user is unauthorized to deploy apps",
@ -740,4 +802,43 @@ defmodule LivebookWeb.Integration.SessionLiveTest do
refute has_element?(view, ~s{button[id*="file-system-#{file_system.id}"]})
end
end
describe "app settings" do
@describetag subscribe_to_teams_topics: [:clients, :app_folders]
test "updates the list of app folders",
%{team: team, conn: conn, node: node, session: session, org: org} do
Session.set_notebook_hub(session.pid, team.id)
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
assert view
|> element(~s/[data-el-app-info] a/, "Configure")
|> render_click() =~ ~s(name="app_settings[app_folder_id]")
assert render(view) =~ ~s(<option value="">Select a folder...</option></select>)
app_folder = TeamsRPC.create_app_folder(node, name: "Tidewave", org: org)
id = to_string(app_folder.id)
assert_receive {:app_folder_created, %{id: ^id, name: "Tidewave"}}
assert_receive {:operation, {:sync_hub_app_folders, _}}
assert render(view) =~
~s(<option value="">Select a folder...</option><option value="#{id}">Tidewave</option></select>)
{:ok, %{name: "Wavetide"}} = TeamsRPC.update_app_folder(node, app_folder, name: "Wavetide")
assert_receive {:app_folder_updated, %{id: ^id, name: "Wavetide"}}
assert_receive {:operation, {:sync_hub_app_folders, _}}
refute render(view) =~ ~s(<option value="#{id}">Tidewave</option>)
assert render(view) =~ ~s(<option value="#{id}">Wavetide</option>)
TeamsRPC.delete_app_folder(node, app_folder)
assert_receive {:app_folder_deleted, %{id: ^id, name: "Wavetide"}}
assert_receive {:operation, {:sync_hub_app_folders, _}}
refute render(view) =~ ~s(<option value="#{id}">Tidewave</option>)
refute render(view) =~ ~s(<option value="#{id}">Wavetide</option>)
end
end
end

View file

@ -162,6 +162,13 @@ defmodule Livebook.Factory do
}
end
def build(:app_folder) do
%Livebook.Teams.AppFolder{
id: "#{unique_integer()}",
name: unique_value("app_folder")
}
end
def build(factory_name, attrs) do
factory_name |> build() |> struct!(attrs)
end

View file

@ -163,6 +163,10 @@ defmodule Livebook.TeamsRPC do
{key, :erpc.call(node, TeamsRPC, :create_org_token, [key, attrs])}
end
def create_app_folder(node, attrs \\ []) do
:erpc.call(node, TeamsRPC, :create_app_folder, [attrs])
end
# Update resource
def update_authorization_group(node, authorization_group, attrs) do
@ -191,6 +195,10 @@ defmodule Livebook.TeamsRPC do
:erpc.call(node, TeamsRPC, :update_file_system, [file_system.external_id, org_key, attrs])
end
def update_app_folder(node, app_folder, attrs \\ []) do
:erpc.call(node, TeamsRPC, :update_app_folder, [app_folder, attrs])
end
# Delete resource
def delete_user_org(node, user_id, org_id) do
@ -206,6 +214,10 @@ defmodule Livebook.TeamsRPC do
:erpc.call(node, TeamsRPC, :delete_file_system, [id, org_key, livebook_version])
end
def delete_app_folder(node, app_folder) do
:erpc.call(node, TeamsRPC, :delete_app_folder, [app_folder])
end
# Actions
def upload_app_deployment(