Introducing the new Apps page (#3091)

This commit is contained in:
Alexandre de Souza 2025-11-12 15:14:41 -03:00 committed by GitHub
parent c7762469f2
commit e7098f51a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 757 additions and 105 deletions

View file

@ -3,7 +3,7 @@ defmodule Livebook.Apps.TeamsAppSpec do
@enforce_keys [:slug, :version, :hub_id, :app_deployment_id]
defstruct [:slug, :version, :hub_id, :app_deployment_id]
defstruct [:slug, :version, :hub_id, :app_deployment_id, :app_folder_id]
end
defimpl Livebook.Apps.AppSpec, for: Livebook.Apps.TeamsAppSpec do

View file

@ -254,7 +254,8 @@ defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Team do
slug: app_deployment.slug,
version: app_deployment.version,
hub_id: app_deployment.hub_id,
app_deployment_id: app_deployment.id
app_deployment_id: app_deployment.id,
app_folder_id: app_deployment.app_folder_id
}
end
end

View file

@ -179,6 +179,8 @@ defmodule Livebook.Hubs.TeamClient do
@spec get_app_folders(String.t()) :: list(Teams.AppFolder.t())
def get_app_folders(id) do
GenServer.call(registry_name(id), :get_app_folders)
catch
:exit, _ -> []
end
@doc """
@ -865,6 +867,19 @@ defmodule Livebook.Hubs.TeamClient do
defp handle_event(:app_deployment_updated, %Teams.AppDeployment{} = app_deployment, state) do
manager_sync(app_deployment, state)
Teams.Broadcasts.app_deployment_updated(app_deployment)
with {:ok, current_app_deployment} <- fetch_app_deployment(app_deployment.id, state) do
if state.deployment_group_id &&
(current_app_deployment.app_folder_id !=
app_deployment.app_folder_id or
current_app_deployment.authorization_groups != app_deployment.authorization_groups) do
{:ok, deployment_group} =
fetch_deployment_group(app_deployment.deployment_group_id, state)
Teams.Broadcasts.server_authorization_updated(deployment_group)
end
end
put_app_deployment(state, app_deployment)
end

View file

@ -667,25 +667,23 @@ 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?)
{app_settings, messages} =
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}
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"
]}
messages ++
[
"notebook is assigned to a non-existent app folder, defaulting to ungrouped app folder"
]
end
else
{notebook.app_settings, messages}
messages
end
{%{notebook | app_settings: app_settings, teams_enabled: teams_enabled}, stamp_verified?,
messages}
{%{notebook | teams_enabled: teams_enabled}, stamp_verified?, messages}
end
defp safe_binary_split(binary, offset)

View file

@ -1,30 +1,40 @@
defmodule LivebookWeb.AppsLive do
use LivebookWeb, :live_view
@events [
:app_folder_created,
:app_folder_updated,
:app_folder_deleted
]
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Livebook.Teams.Broadcasts.subscribe(:app_server)
Livebook.Teams.Broadcasts.subscribe([:app_server, :app_folders])
Livebook.Apps.subscribe()
end
apps = Livebook.Apps.list_authorized_apps(socket.assigns.current_user)
empty_apps_path? = Livebook.Apps.empty_apps_path?()
{:ok,
assign(socket,
apps: apps,
socket
|> assign(
search_term: "",
selected_app_folder: "",
apps: Livebook.Apps.list_authorized_apps(socket.assigns.current_user),
empty_apps_path?: empty_apps_path?,
logout_enabled?:
Livebook.Config.logout_enabled?() and socket.assigns.current_user.email != nil
)}
)
|> load_app_folders()
|> apply_filters()}
end
@impl true
def render(assigns) do
~H"""
<div class="h-full flex flex-col overflow-y-auto">
<div class="px-4 py-3 flex items-center justify-between">
<div class="px-6 py-4 bg-white border-b border-gray-200 flex items-center justify-between">
<div class="w-10 h-10">
<.menu id="apps-menu" position="bottom-right" md_position="bottom-left">
<:toggle>
@ -42,58 +52,158 @@ defmodule LivebookWeb.AppsLive do
</.menu>
</div>
<div>
<.link navigate={~p"/apps-dashboard"} class="flex items-center text-blue-600">
<.link
navigate={~p"/apps-dashboard"}
class="flex items-center text-blue-600 hover:text-blue-700 transition-colors"
>
<span class="font-semibold">Dashboard</span>
<.remix_icon icon="arrow-right-line" class="align-middle ml-1" />
</.link>
</div>
</div>
<div class="w-full max-w-screen-lg px-4 md:px-20 py-4 mx-auto">
<div class="flex flex-col items-center">
<h1 class="text-2xl text-gray-800 font-medium">
Apps
</h1>
<div :if={@apps != []} class="w-full mt-5 max-w-[400px]">
<div class="w-full flex flex-col space-y-4">
<.link
:for={app <- apps_listing(@apps)}
navigate={~p"/apps/#{app.slug}"}
class="px-4 py-3 border border-gray-200 rounded-xl text-gray-800 pointer hover:bg-gray-50 flex items-center justify-between"
>
<span class="font-semibold">{app.notebook_name}</span>
<.remix_icon :if={not app.public?} icon="lock-password-line" />
</.link>
<div class="flex-1 px-6 py-6">
<div class="max-w-7xl mx-auto">
<div class="flex flex-col gap-y-4 w-full">
<div class="flex flex-col gap-y-2">
<h1 class="text-3xl font-bold text-gray-900">Apps</h1>
<p class="text-gray-600">Find your applications</p>
</div>
</div>
<div
:if={@apps == [] and not @empty_apps_path?}
class="mt-5 flex flex-col w-full max-w-[400px]"
>
<.no_entries :if={@apps == []}>
No apps running.
</.no_entries>
</div>
<div :if={@apps == [] and @empty_apps_path?} class="mt-5 text-gray-600">
<div>
No app notebooks found. Follow these steps to list your apps here:
</div>
<ol class="mt-4 pl-4 flex flex-col space-y-1 list-decimal list-inside">
<li>
Open a notebook
</li>
<li>
Click <.remix_icon icon="rocket-line" class="align-baseline text-lg" />
in the sidebar and configure the app as public
</li>
<li>
Save the notebook to the
<span class="font-medium">{Livebook.Config.apps_path()}</span>
folder
</li>
<li>
Relaunch your Livebook app
</li>
</ol>
<%= if @apps != [] do %>
<div class="flex flex-col gap-y-8">
<div class="flex flex-col md:flex-row gap-4">
<div class="flex-1">
<div class="relative">
<.remix_icon
icon="search-line"
class="absolute left-3 bottom-[8px] text-gray-400"
/>
<.text_field
id="search-app"
name="search_term"
placeholder="Search apps..."
value={@search_term}
phx-keyup="search"
phx-debounce="300"
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
/>
</div>
</div>
<div :if={@show_app_folders?} class="md:w-48">
<form id="select-app-folder-form" phx-change="select_app_folder" phx-nosubmit>
<.select_field
id="select-app-folder"
name="app_folder"
prompt="Select a folder..."
value={@selected_app_folder}
options={@app_folder_options}
/>
</form>
</div>
</div>
<div
:if={@filtered_apps == []}
class="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center"
>
<.remix_icon icon="windy-line" class="text-gray-400 text-2xl" />
<h3 class="text-lg font-medium text-gray-900">No apps found</h3>
<p class="text-gray-600">Try adjusting your search or filter criteria</p>
</div>
<div class="flex flex-col h-full gap-y-8 pr-2">
<div
:for={{app_folder, id, icon, apps} <- @grouped_apps}
:if={@filtered_apps != []}
id={id}
class="flex flex-col gap-y-4"
>
<h2 class="flex items-center gap-x-3 text-xl font-semibold text-gray-900">
<.remix_icon icon={icon} />
{app_folder}
<span class="text-sm font-normal text-gray-500">({length(apps)})</span>
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<.link
:for={app <- apps_listing(apps)}
id={"app-#{app.slug}"}
navigate={~p"/apps/#{app.slug}"}
class="border bg-gray-50 border-gray-300 rounded-lg p-4 hover:shadow-md hover:border-blue-300 transition-all duration-200"
>
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-900 group-hover:text-blue-600 transition-colors truncate">
{app.notebook_name}
</h3>
<div class="space-x-1 ml-2">
<.remix_icon
:if={not app.public?}
icon="lock-password-line"
class="h-4 w-4 text-gray-400"
/>
<.remix_icon
icon="arrow-right-line"
class="h-4 w-4 text-gray-400 group-hover:text-blue-600 transition-colors"
/>
</div>
</div>
</.link>
</div>
</div>
</div>
</div>
<% else %>
<div class="flex flex-col bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
<div :if={@empty_apps_path?} class="flex flex-col gap-y-4">
<div>
<.remix_icon icon="windy-line" class="size-16 text-gray-400 text-2xl" />
<h3 class="text-lg font-medium text-gray-900">No apps found</h3>
<p class="text-gray-600">Follow these steps to list your apps here:</p>
</div>
<div class="p-6 text-left max-w-md mx-auto">
<ol class="space-y-3 text-sm text-gray-700">
<li class="flex items-center">
<span class="inline-flex items-center justify-center w-6 h-6 bg-blue-100 text-blue-600 rounded-full text-xs font-medium mr-3 mt-0.5">
1
</span>
Open a notebook
</li>
<li class="flex items-center">
<span class="inline-flex items-center justify-center w-6 h-6 bg-blue-100 text-blue-600 rounded-full text-xs font-medium mr-3 mt-0.5">
2
</span>
<div class="flex gap-x-1 items-center">
Click
<.remix_icon icon="rocket-line" class="inline align-baseline text-base" />
in the sidebar and configure the app as public
</div>
</li>
<li class="flex items-center">
<span class="inline-flex items-center justify-center w-6 h-6 bg-blue-100 text-blue-600 rounded-full text-xs font-medium mr-3 mt-0.5">
3
</span>
<div class="flex gap-x-1 items-center">
Save the notebook to the
<span class="font-medium bg-gray-100 px-1 rounded text-xs">
{Livebook.Config.apps_path()}
</span>
folder
</div>
</li>
<li class="flex items-center">
<span class="inline-flex items-center justify-center w-6 h-6 bg-blue-100 text-blue-600 rounded-full text-xs font-medium mr-3 mt-0.5">
4
</span>
Relaunch your Livebook app
</li>
</ol>
</div>
</div>
<div :if={not @empty_apps_path?}>
<.remix_icon icon="windy-line" class="size-16 text-gray-400 text-2xl" />
<h3 class="text-lg font-medium text-gray-900">No apps running</h3>
<p class="text-gray-600">Start some apps to see them listed here</p>
</div>
</div>
<% end %>
</div>
</div>
</div>
@ -101,15 +211,46 @@ defmodule LivebookWeb.AppsLive do
"""
end
@impl true
def handle_event("search", %{"value" => search_term}, socket) do
{:noreply,
socket
|> assign(search_term: search_term)
|> apply_filters()}
end
def handle_event("select_app_folder", %{"app_folder" => app_folder_id}, socket) do
{:noreply,
socket
|> assign(selected_app_folder: app_folder_id)
|> apply_filters()}
end
@impl true
def handle_info({type, _app} = event, socket)
when type in [:app_created, :app_updated, :app_closed] do
{:noreply, update(socket, :apps, &LivebookWeb.AppComponents.update_app_list(&1, event))}
{:noreply,
socket
|> assign(apps: LivebookWeb.AppComponents.update_app_list(socket.assigns.apps, event))
|> apply_filters()}
end
def handle_info({:server_authorization_updated, _}, socket) do
apps = Livebook.Apps.list_authorized_apps(socket.assigns.current_user)
{:noreply, assign(socket, :apps, apps)}
{:noreply,
socket
|> assign(
apps: Livebook.Apps.list_authorized_apps(socket.assigns.current_user),
logout_enabled?:
Livebook.Config.logout_enabled?() and socket.assigns.current_user.email != nil
)
|> apply_filters()}
end
def handle_info({type, _app_folder}, socket) when type in @events do
{:noreply,
socket
|> load_app_folders()
|> apply_filters()}
end
def handle_info(_message, socket), do: {:noreply, socket}
@ -117,4 +258,74 @@ defmodule LivebookWeb.AppsLive do
defp apps_listing(apps) do
Enum.sort_by(apps, & &1.notebook_name)
end
def load_app_folders(socket) do
app_folders =
Enum.flat_map(Livebook.Hubs.get_hubs(), &Livebook.Hubs.Provider.get_app_folders/1)
app_folder_options =
for app_folder <- app_folders do
{app_folder.name, app_folder.id}
end
assign(socket, app_folders: app_folders, app_folder_options: app_folder_options)
end
defp apply_filters(socket) do
apps = socket.assigns.apps
app_folders = socket.assigns.app_folders
filtered_apps =
filter_apps(apps, socket.assigns.search_term, socket.assigns.selected_app_folder)
grouped_apps =
filtered_apps
|> Enum.group_by(fn
%{app_spec: %{app_folder_id: id}} -> Enum.find_value(app_folders, &(&1.id == id && id))
_ -> nil
end)
|> Enum.map(fn
{nil, apps} ->
{"Ungrouped apps", "ungrouped-apps", "asterisk", apps}
{id, apps} ->
app_folder_name = Enum.find_value(app_folders, &(&1.id == id && &1.name))
{app_folder_name, "app-folder-#{id}", "folder-line", apps}
end)
|> Enum.sort_by(&elem(&1, 0))
show_app_folders? = Enum.any?(apps, &is_struct(&1.app_spec, Livebook.Apps.TeamsAppSpec))
assign(socket,
grouped_apps: grouped_apps,
filtered_apps: filtered_apps,
show_app_folders?: show_app_folders?
)
end
defp filter_apps(apps, term, app_folder_id) do
apps
|> search_apps(term)
|> filter_by_app_folder(app_folder_id)
end
defp search_apps(apps, ""), do: apps
defp search_apps(apps, term) do
term = String.downcase(term)
Enum.filter(apps, fn app ->
String.contains?(String.downcase(app.notebook_name), term) or
String.contains?(app.slug, term)
end)
end
defp filter_by_app_folder(apps, ""), do: apps
defp filter_by_app_folder(apps, app_folder_id) do
Enum.filter(apps, fn
%{app_spec: %{app_folder_id: id}} -> id == app_folder_id
_otherwise -> false
end)
end
end

View file

@ -20,7 +20,10 @@ defmodule LivebookWeb.SessionLive.AppSettingsComponent do
{:ok,
socket
|> assign(assigns)
|> assign(app_folder_options: app_folder_options, changeset: changeset)}
|> assign(
app_folder_options: app_folder_options,
changeset: changeset
)}
end
@impl true
@ -48,6 +51,7 @@ 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
:if={@hub_id != Livebook.Hubs.Personal.id()}
field={f[:app_folder_id]}
label="Folder"
prompt="Select a folder..."

View file

@ -602,11 +602,9 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do
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))
|> Enum.find_value("Ungrouped apps", &(&1.id == id && &1.name))
end
end

View file

@ -104,6 +104,7 @@ defmodule LivebookWeb.SessionLive.Render do
context={@action_assigns.context}
deployed_app_slug={@data_view.deployed_app_slug}
app_folders={@data_view.hub_app_folders}
hub_id={@data_view.hub.id}
/>
</.modal>

View file

@ -11,7 +11,7 @@ defmodule Livebook.Integration.LiveMarkdown.ImportTest do
@moduletag subscribe_to_teams_topics: [:clients, :app_folders]
describe "app settings" do
test "don't import app folder if does not exists anymore",
test "keep the app folder id even if it does not exist anymore",
%{node: node, team: team, org: org} do
app_folder = TeamsRPC.create_app_folder(node, name: "delete me", org: org)
@ -39,8 +39,10 @@ defmodule Livebook.Integration.LiveMarkdown.ImportTest do
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{
name: "Deleted from folder",
app_settings: %{app_folder_id: ^app_folder_id}
}, %{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

View file

@ -16,12 +16,13 @@ defmodule LivebookWeb.Integration.AdminLiveTest do
%{conn: conn, node: node, code: code} = context do
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
authorization_group =
TeamsRPC.create_authorization_group(node,
group_name: "marketing",
access_type: :apps,
prefixes: ["dev-"],
app_folders: [app_folder],
oidc_provider: oidc_provider,
deployment_group: context.deployment_group
)
@ -76,6 +77,7 @@ defmodule LivebookWeb.Integration.AdminLiveTest do
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
authorization_group =
TeamsRPC.create_authorization_group(node,
@ -99,10 +101,9 @@ defmodule LivebookWeb.Integration.AdminLiveTest do
{:ok, view, _html} = live(conn, ~p"/settings")
assert render(view) =~ "System settings"
TeamsRPC.update_authorization_group(node, authorization_group, %{
access_type: :apps,
prefixes: ["ops-"]
})
TeamsRPC.update_authorization_group(node, authorization_group, %{access_type: :apps}, [
app_folder
])
id = to_string(deployment_group.id)
assert_receive {:server_authorization_updated, %{id: ^id}}
@ -121,12 +122,13 @@ defmodule LivebookWeb.Integration.AdminLiveTest do
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
authorization_group =
TeamsRPC.create_authorization_group(node,
group_name: "marketing",
access_type: :apps,
prefixes: ["ops-"],
app_folders: [app_folder],
oidc_provider: oidc_provider,
deployment_group: deployment_group
)

View file

@ -8,7 +8,13 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
setup :teams
@moduletag subscribe_to_hubs_topics: [:connection]
@moduletag subscribe_to_teams_topics: [:clients, :agents, :app_deployments, :app_server]
@moduletag subscribe_to_teams_topics: [
:clients,
:agents,
:app_deployments,
:app_server,
:app_folders
]
setup do
Livebook.Apps.subscribe()
@ -23,12 +29,13 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
%{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
authorization_group =
TeamsRPC.create_authorization_group(node,
group_name: "marketing",
access_type: :apps,
prefixes: ["dev-"],
app_folders: [app_folder],
oidc_provider: oidc_provider,
deployment_group: context.deployment_group
)
@ -46,7 +53,16 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
slug = "dev-oban-app"
context = change_to_user_session(context)
deploy_app(slug, context.team, context.org, context.deployment_group, tmp_dir, node)
deploy_app(
slug,
context.team,
context.org,
context.deployment_group,
tmp_dir,
node,
app_folder
)
change_to_agent_session(context)
pid = wait_livebook_app_start(slug)
@ -111,12 +127,13 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
authorization_group =
TeamsRPC.create_authorization_group(node,
group_name: "marketing",
access_type: :apps,
prefixes: ["mkt-"],
app_folders: [app_folder],
oidc_provider: oidc_provider,
deployment_group: deployment_group
)
@ -134,7 +151,16 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
slug = "mkt-analytics-#{Livebook.Utils.random_short_id()}"
context = change_to_user_session(context)
deploy_app(slug, context.team, context.org, context.deployment_group, tmp_dir, node)
deploy_app(
slug,
context.team,
context.org,
context.deployment_group,
tmp_dir,
node,
app_folder
)
change_to_agent_session(context)
pid = wait_livebook_app_start(slug)
@ -144,8 +170,11 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
{:ok, view, _html} = live(conn, path)
assert render(view) =~ "LivebookApp:#{slug}"
{:ok, %{prefixes: ["ops-"]}} =
TeamsRPC.update_authorization_group(node, authorization_group, %{prefixes: ["ops-"]})
app_folder2 = TeamsRPC.create_app_folder(node, org: context.org)
app_folder_id = app_folder2.id
{:ok, %{app_folders: [%{id: ^app_folder_id}]}} =
TeamsRPC.update_authorization_group(node, authorization_group, %{}, [app_folder2])
id = to_string(deployment_group.id)
@ -164,12 +193,14 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
app_folder2 = TeamsRPC.create_app_folder(node, org: context.org)
authorization_group =
TeamsRPC.create_authorization_group(node,
group_name: "marketing",
access_type: :apps,
prefixes: ["mkt-"],
app_folders: [app_folder],
oidc_provider: oidc_provider,
deployment_group: deployment_group
)
@ -187,7 +218,16 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
slug = "analytics-app-#{Livebook.Utils.random_short_id()}"
context = change_to_user_session(context)
deploy_app(slug, context.team, context.org, context.deployment_group, tmp_dir, node)
deploy_app(
slug,
context.team,
context.org,
context.deployment_group,
tmp_dir,
node,
app_folder2
)
change_to_agent_session(context)
pid = wait_livebook_app_start(slug)
@ -207,5 +247,70 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
{:ok, view, _html} = live(conn, path)
assert render(view) =~ "LivebookApp:#{slug}"
end
@tag :tmp_dir
test "renders unauthorized if app's folder is deleted in real-time",
%{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do
{:ok, deployment_group} =
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
authorization_group =
TeamsRPC.create_authorization_group(node,
group_name: "marketing",
access_type: :apps,
app_folders: [app_folder],
oidc_provider: oidc_provider,
deployment_group: deployment_group
)
TeamsRPC.update_user_info_groups(
node,
code,
[
%{
"provider_id" => to_string(oidc_provider.id),
"group_name" => authorization_group.group_name
}
]
)
slug = "mkt-analytics-#{Livebook.Utils.random_short_id()}"
context = change_to_user_session(context)
deploy_app(
slug,
context.team,
context.org,
context.deployment_group,
tmp_dir,
node,
app_folder
)
change_to_agent_session(context)
pid = wait_livebook_app_start(slug)
session_id = Livebook.App.get_session_id(pid, user: Livebook.Users.User.new())
path = ~p"/apps/#{slug}/sessions/#{session_id}"
{:ok, view, _html} = live(conn, path)
assert render(view) =~ "LivebookApp:#{slug}"
app_folder_id = to_string(app_folder.id)
TeamsRPC.delete_app_folder(node, app_folder)
assert_receive {:app_folder_deleted, %{id: ^app_folder_id}}
id = to_string(deployment_group.id)
assert_receive {:server_authorization_updated, %{id: ^id}}
assert_receive {:app_deployment_updated, %{slug: ^slug, app_folder_id: nil}}
assert_redirect view, path
{:ok, view, _html} = live(conn, path)
assert render(view) =~ "Not authorized"
end
end
end

View file

@ -13,9 +13,12 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
:agents,
:deployment_groups,
:app_deployments,
:app_server
:app_server,
:app_folders
]
@moduletag :tmp_dir
setup do
Livebook.Apps.subscribe()
:ok
@ -24,17 +27,17 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
describe "authorized apps" do
setup :livebook_teams_auth
@tag :tmp_dir
test "shows one app if user doesn't have full access",
%{conn: conn, code: code, node: node, tmp_dir: tmp_dir} = context do
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
authorization_group =
TeamsRPC.create_authorization_group(node,
group_name: "marketing",
access_type: :apps,
prefixes: ["dev-"],
app_folders: [app_folder],
oidc_provider: oidc_provider,
deployment_group: context.deployment_group
)
@ -52,7 +55,16 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
slug = "dev-app-#{Livebook.Utils.random_short_id()}"
context = change_to_user_session(context)
deploy_app(slug, context.team, context.org, context.deployment_group, tmp_dir, node)
deploy_app(
slug,
context.team,
context.org,
context.deployment_group,
tmp_dir,
node,
app_folder
)
change_to_agent_session(context)
wait_livebook_app_start(slug)
@ -66,7 +78,6 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
assert html =~ slug
end
@tag :tmp_dir
test "shows all apps if user have full access",
%{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
@ -121,7 +132,6 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
end
end
@tag :tmp_dir
test "updates the apps list in real-time",
%{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do
{:ok, %{groups_auth: true} = deployment_group} =
@ -131,12 +141,13 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
assert_receive {:deployment_group_updated, %{id: ^id, groups_auth: true}}
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
authorization_group =
TeamsRPC.create_authorization_group(node,
group_name: "marketing",
access_type: :apps,
prefixes: ["mkt-"],
app_folders: [app_folder],
oidc_provider: oidc_provider,
deployment_group: deployment_group
)
@ -154,7 +165,15 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
slug = "marketing-report-#{Livebook.Utils.random_short_id()}"
context = change_to_user_session(context)
deploy_app(slug, context.team, context.org, context.deployment_group, tmp_dir, node)
deploy_app(
slug,
context.team,
context.org,
context.deployment_group,
tmp_dir,
node
)
change_to_agent_session(context)
wait_livebook_app_start(slug)
@ -169,7 +188,6 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
assert render(view) =~ slug
end
@tag :tmp_dir
test "shows all apps if disable the authentication in real-time",
%{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do
{:ok, %{groups_auth: true} = deployment_group} =
@ -179,12 +197,14 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
assert_receive {:deployment_group_updated, %{id: ^id, groups_auth: true}}
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
app_folder2 = TeamsRPC.create_app_folder(node, org: context.org)
authorization_group =
TeamsRPC.create_authorization_group(node,
group_name: "marketing",
access_type: :apps,
prefixes: ["mkt-"],
app_folders: [app_folder],
oidc_provider: oidc_provider,
deployment_group: deployment_group
)
@ -202,7 +222,16 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
slug = "marketing-app-#{Livebook.Utils.random_short_id()}"
context = change_to_user_session(context)
deploy_app(slug, context.team, context.org, context.deployment_group, tmp_dir, node)
deploy_app(
slug,
context.team,
context.org,
context.deployment_group,
tmp_dir,
node,
app_folder2
)
change_to_agent_session(context)
wait_livebook_app_start(slug)
@ -216,5 +245,273 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
{:ok, view, _} = live(conn, ~p"/apps")
assert render(view) =~ slug
end
test "updates the folder name in real-time",
%{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do
{:ok, %{groups_auth: true} = deployment_group} =
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
id = to_string(deployment_group.id)
assert_receive {:deployment_group_updated, %{id: ^id, groups_auth: true}}
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
authorization_group =
TeamsRPC.create_authorization_group(node,
group_name: "marketing",
access_type: :apps,
app_folders: [app_folder],
oidc_provider: oidc_provider,
deployment_group: deployment_group
)
TeamsRPC.update_user_info_groups(
node,
code,
[
%{
"provider_id" => to_string(oidc_provider.id),
"group_name" => authorization_group.group_name
}
]
)
slug = Livebook.Utils.random_short_id()
context = change_to_user_session(context)
deploy_app(
slug,
context.team,
context.org,
context.deployment_group,
tmp_dir,
node,
app_folder
)
change_to_agent_session(context)
wait_livebook_app_start(slug)
{:ok, view, _} = live(conn, ~p"/apps")
assert render(view) =~ app_folder.name
assert render(view) =~ slug
new_name = "NewAppFolderName"
app_folder_id = to_string(app_folder.id)
{:ok, _app_folder} = TeamsRPC.update_app_folder(node, app_folder, name: new_name)
assert_receive {:app_folder_updated, %{id: ^app_folder_id, name: ^new_name}}
refute render(view) =~ app_folder.name
assert render(view) =~ new_name
assert render(view) =~ slug
end
test "deletes the folder and move the app to ungrouped apps folder in real-time",
%{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do
{:ok, %{groups_auth: true} = deployment_group} =
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
id = to_string(deployment_group.id)
assert_receive {:deployment_group_updated, %{id: ^id, groups_auth: true}}
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
authorization_group =
TeamsRPC.create_authorization_group(node,
group_name: "marketing",
access_type: :apps,
app_folders: [app_folder],
oidc_provider: oidc_provider,
deployment_group: deployment_group
)
TeamsRPC.update_user_info_groups(
node,
code,
[
%{
"provider_id" => to_string(oidc_provider.id),
"group_name" => authorization_group.group_name
}
]
)
slug = Livebook.Utils.random_short_id()
context = change_to_user_session(context)
deploy_app(
slug,
context.team,
context.org,
context.deployment_group,
tmp_dir,
node,
app_folder
)
change_to_agent_session(context)
wait_livebook_app_start(slug)
{:ok, view, _} = live(conn, ~p"/apps")
assert render(view) =~ app_folder.name
assert render(view) =~ slug
id = to_string(deployment_group.id)
app_folder_id = to_string(app_folder.id)
TeamsRPC.delete_app_folder(node, app_folder)
assert_receive {:app_folder_deleted, %{id: ^app_folder_id}}
assert_receive {:app_deployment_updated, %{slug: ^slug, app_folder_id: nil}}
assert_receive {:app_updated, %{slug: ^slug, app_spec: %{app_folder_id: ^app_folder_id}}}
# Once the folder is deleted, all apps are moved to a "Ungrouped apps" folder,
# which only users with full access will be able to see and access them.
refute render(view) =~ app_folder.name
refute render(view) =~ slug
# To validate this behaivour, updates the authorization group to be full access
{:ok, %{access_type: :app_server}} =
TeamsRPC.update_authorization_group(node, authorization_group, %{access_type: :app_server})
# Since we're updating the authorization group access type, the app deployment must receive the updated version
assert_receive {:server_authorization_updated, %{id: ^id}}, 3_000
assert_receive {:app_deployment_updated, %{slug: ^slug, app_folder_id: nil}}
refute render(view) =~ app_folder.name
assert render(view) =~ "Ungrouped apps"
assert render(view) =~ slug
end
test "filter the apps based on slug, name and app folder",
%{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do
{:ok, %{groups_auth: true} = deployment_group} =
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
id = to_string(deployment_group.id)
assert_receive {:deployment_group_updated, %{id: ^id, groups_auth: true}}
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
app_folder2 = TeamsRPC.create_app_folder(node, org: context.org)
authorization_group =
TeamsRPC.create_authorization_group(node,
access_type: :app_server,
oidc_provider: oidc_provider,
deployment_group: deployment_group
)
TeamsRPC.update_user_info_groups(
node,
code,
[
%{
"provider_id" => to_string(oidc_provider.id),
"group_name" => authorization_group.group_name
}
]
)
apps_to_deploy = [
app_to_deploy1 = %{
slug: "app-from-folder1",
title: "Super Admin Tools",
app_folder: app_folder,
folder_id: "app-folder-#{app_folder.id}",
folder_name: app_folder.name
},
app_to_deploy2 = %{
slug: "app-from-folder2",
title: "Accounting daily report",
app_folder: app_folder2,
folder_id: "app-folder-#{app_folder2.id}",
folder_name: app_folder2.name
},
app_to_deploy3 = %{
slug: "app-from-ungrouped-folder",
title: "List of the chonkiest cats",
app_folder: nil,
folder_id: "ungrouped-apps",
folder_name: "Ungrouped apps"
}
]
context = change_to_user_session(context)
for app_to_deploy <- apps_to_deploy do
deploy_app(
app_to_deploy.slug,
context.team,
context.org,
context.deployment_group,
tmp_dir,
node,
app_to_deploy.app_folder,
app_to_deploy.title
)
end
change_to_agent_session(context)
for %{slug: slug} <- apps_to_deploy do
wait_livebook_app_start(slug)
end
{:ok, view, _} = live(conn, ~p"/apps")
Enum.each(apps_to_deploy, &assert_app(view, &1))
# filter by slug
render_keyup(view, "search", %{value: app_to_deploy1.slug})
assert_app(view, app_to_deploy1)
apps_to_deploy
|> Enum.reject(&(&1 == app_to_deploy1))
|> Enum.each(&refute_app(view, &1))
# filter by title
render_keyup(view, "search", %{value: app_to_deploy3.title})
assert_app(view, app_to_deploy3)
apps_to_deploy
|> Enum.reject(&(&1 == app_to_deploy3))
|> Enum.each(&refute_app(view, &1))
# reset filter
render_keyup(view, "search", %{value: ""})
# filter by app folder
view
|> element("#select-app-folder-form")
|> render_change(%{app_folder: app_to_deploy2.app_folder.id})
assert_app(view, app_to_deploy2)
apps_to_deploy
|> Enum.reject(&(&1 == app_to_deploy2))
|> Enum.each(&refute_app(view, &1))
end
end
defp assert_app(view, app_to_deploy) do
assert view
|> element("##{app_to_deploy.folder_id}", app_to_deploy.folder_name)
|> has_element?()
assert view
|> element("#app-#{app_to_deploy.slug}", app_to_deploy.title)
|> has_element?()
end
defp refute_app(view, app_to_deploy) do
refute view
|> element("##{app_to_deploy.folder_id}", app_to_deploy.folder_name)
|> has_element?()
refute view
|> element("#app-#{app_to_deploy.slug}", app_to_deploy.title)
|> has_element?()
end
end

View file

@ -2786,6 +2786,9 @@ defmodule LivebookWeb.SessionLiveTest do
|> element(~s/[data-el-app-info] a/, "Configure")
|> render_click()
# doesn't show the app folder select
refute has_element?(view, ~s/#app-settings-modal input[name="app_folder_id"]/)
view
|> element(~s/#app-settings-modal form/)
|> render_change(%{"app_settings" => %{"slug" => slug}})

View file

@ -26,14 +26,25 @@ defmodule Livebook.AppHelpers do
end
end
def deploy_app(slug, team, org, deployment_group, tmp_dir, node) do
def deploy_app(
slug,
team,
org,
deployment_group,
tmp_dir,
node,
app_folder \\ nil,
title \\ nil
) do
app_path = Path.join(tmp_dir, "#{slug}.livemd")
app_folder = if app_folder, do: ~s(,"app_folder_id":"#{app_folder.id}")
title = if title, do: title, else: "LivebookApp:#{slug}"
source =
stamp_notebook(app_path, """
<!-- livebook:{"app_settings":{"access_type":"public","slug":"#{slug}"},"hub_id":"#{team.id}","deployment_group_id":"#{deployment_group.id}"} -->
<!-- livebook:{"app_settings":{"access_type":"public"#{app_folder},"slug":"#{slug}"},"hub_id":"#{team.id}","deployment_group_id":"#{deployment_group.id}"} -->
# LivebookApp:#{slug}
# #{title}
```elixir
IO.puts("Hi")

View file

@ -169,8 +169,12 @@ defmodule Livebook.TeamsRPC do
# Update resource
def update_authorization_group(node, authorization_group, attrs) do
:erpc.call(node, TeamsRPC, :update_authorization_group, [authorization_group, attrs])
def update_authorization_group(node, authorization_group, attrs, app_folders \\ []) do
:erpc.call(node, TeamsRPC, :update_authorization_group, [
authorization_group,
attrs,
app_folders
])
end
def update_user_info_groups(node, code, groups) do