From 7ad88d8aff88c14804b893889697bdc83fad1a6d Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Wed, 5 Nov 2025 10:49:34 -0300 Subject: [PATCH 01/12] Improve the function to get app folder name --- lib/livebook_web/live/session_live/app_teams_live.ex | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/livebook_web/live/session_live/app_teams_live.ex b/lib/livebook_web/live/session_live/app_teams_live.ex index 816167c1e13..d5709db483a 100644 --- a/lib/livebook_web/live/session_live/app_teams_live.ex +++ b/lib/livebook_web/live/session_live/app_teams_live.ex @@ -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 From 2a1f335dc9c39be9e482e1c2c75a6531a645dd4a Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Mon, 20 Oct 2025 18:47:04 -0300 Subject: [PATCH 02/12] Add app folder id for teams app spec --- lib/livebook/apps/teams_app_spec.ex | 2 +- lib/livebook/hubs/team.ex | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/livebook/apps/teams_app_spec.ex b/lib/livebook/apps/teams_app_spec.ex index b440929dbde..27ded7c7792 100644 --- a/lib/livebook/apps/teams_app_spec.ex +++ b/lib/livebook/apps/teams_app_spec.ex @@ -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 diff --git a/lib/livebook/hubs/team.ex b/lib/livebook/hubs/team.ex index b7bc8c566c7..5c9196f1057 100644 --- a/lib/livebook/hubs/team.ex +++ b/lib/livebook/hubs/team.ex @@ -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 From 341d370f9cbb31c97c95223546496e5ad48ae72d Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Tue, 4 Nov 2025 11:44:29 -0300 Subject: [PATCH 03/12] Update rpc function to accept app folders list --- test/support/integration/teams_rpc.ex | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/support/integration/teams_rpc.ex b/test/support/integration/teams_rpc.ex index 4e292e7136c..a3e1c36f794 100644 --- a/test/support/integration/teams_rpc.ex +++ b/test/support/integration/teams_rpc.ex @@ -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 From 7f5cfae46b5f15d7bef4ec94a20bb0b3fe07c267 Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Thu, 6 Nov 2025 16:16:22 -0300 Subject: [PATCH 04/12] Redeploy the app them app folder id changes --- lib/livebook/apps/manager.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/livebook/apps/manager.ex b/lib/livebook/apps/manager.ex index ccee0a92ead..b1477134dc0 100644 --- a/lib/livebook/apps/manager.ex +++ b/lib/livebook/apps/manager.ex @@ -185,7 +185,9 @@ defmodule Livebook.Apps.Manager do reduce: {state, [], false} do {state, up_to_date_app_specs, schedule_sync?} -> case fetch_app(app_spec.slug) do - {:ok, _state, app} when app.app_spec.version == app_spec.version -> + {:ok, _state, app} + when app.app_spec.version == app_spec.version and + app.app_spec.app_folder_id == app_spec.app_folder_id -> {state, [app_spec | up_to_date_app_specs], schedule_sync?} {:ok, :reachable, app} -> From a901ec86e65f3032f3533113d9e126f5ff678ff6 Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Wed, 29 Oct 2025 17:21:22 -0300 Subject: [PATCH 05/12] Update the apps page --- lib/livebook_web/live/apps_live.ex | 312 +++++++++++++++++++++++------ 1 file changed, 251 insertions(+), 61 deletions(-) diff --git a/lib/livebook_web/live/apps_live.ex b/lib/livebook_web/live/apps_live.ex index 80a15520cfc..717fb7501bb 100644 --- a/lib/livebook_web/live/apps_live.ex +++ b/lib/livebook_web/live/apps_live.ex @@ -1,30 +1,30 @@ 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, - empty_apps_path?: empty_apps_path?, - logout_enabled?: - Livebook.Config.logout_enabled?() and socket.assigns.current_user.email != nil - )} + socket + |> assign(search_term: "", selected_app_folder: "") + |> load_data()} end @impl true def render(assigns) do ~H""" -
-
+
+
<.menu id="apps-menu" position="bottom-right" md_position="bottom-left"> <:toggle> @@ -42,74 +42,198 @@ defmodule LivebookWeb.AppsLive do
- <.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" + > Dashboard <.remix_icon icon="arrow-right-line" class="align-middle ml-1" />
-
-
-

- Apps -

-
-
- <.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" - > - {app.notebook_name} - <.remix_icon :if={not app.public?} icon="lock-password-line" /> - -
-
-
- <.no_entries :if={@apps == []}> - No apps running. - + +
+
+
+

Apps

+

Find and manage your Livebook applications

-
-
- No app notebooks found. Follow these steps to list your apps here: + + <%= if @apps != [] do %> +
+
+
+
+
+ <.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 mt-6 pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" + /> +
+
+
+
+ <.select_field + id="select-app-folder" + name="app_folder" + label="Folder" + prompt="Select a folder..." + value={@selected_app_folder} + options={@app_folder_options} + /> +
+
+
+
-
    -
  1. - Open a notebook -
  2. -
  3. - Click <.remix_icon icon="rocket-line" class="align-baseline text-lg" /> - in the sidebar and configure the app as public -
  4. -
  5. - Save the notebook to the - {Livebook.Config.apps_path()} - folder -
  6. -
  7. - Relaunch your Livebook app -
  8. -
-
+ +
+ <.remix_icon icon="search-line" class="mx-auto h-12 w-12 text-gray-300 mb-4" /> +

No apps found

+

Try adjusting your search or filter criteria

+
+
+

+ <.remix_icon icon="folder-line" class="mr-2" /> + {app_folder} + ({length(apps)}) +

+
+ <.link + :for={app <- apps_listing(apps)} + id={"app-#{app.slug}"} + navigate={~p"/apps/#{app.slug}"} + class="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md hover:border-blue-300 transition-all duration-200 group" + > +
+
+

+ {app.notebook_name} +

+
+
+ <.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" + /> +
+
+ +
+
+ <% else %> +
+ <.remix_icon icon="folder-add-line" class="mx-auto h-16 w-16 text-gray-300 mb-6" /> +

No app notebooks found

+

+ Follow these steps to list your apps here: +

+
+
    +
  1. + + 1 + + Open a notebook +
  2. +
  3. + + 2 + +
    + Click <.remix_icon icon="rocket-line" class="inline align-baseline text-base" /> + in the sidebar and configure the app as public +
    +
  4. +
  5. + + 3 + +
    + Save the notebook to the + + {Livebook.Config.apps_path()} + + folder +
    +
  6. +
  7. + + 4 + + Relaunch your Livebook app +
  8. +
+
+
+
+ <.remix_icon icon="file-line" class="mx-auto h-16 w-16 text-gray-300 mb-6" /> +

No apps running

+

Start some apps to see them listed here

+
+ <% end %>
""" end + @impl true + def handle_event("search", %{"value" => search_term}, socket) do + {:noreply, + socket + |> assign(search_term: search_term) + |> load_data()} + end + + def handle_event("select_app_folder", %{"app_folder" => app_folder_id}, socket) do + {:noreply, + socket + |> assign(selected_app_folder: app_folder_id) + |> load_data()} + 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))} + apps = LivebookWeb.AppComponents.update_app_list(socket.assigns.apps, event) + {:noreply, load_data(socket, apps)} 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, load_data(socket)} + end + + def handle_info({type, _app_folder}, socket) when type in @events do + {:noreply, load_data(socket)} end def handle_info(_message, socket), do: {:noreply, socket} @@ -117,4 +241,70 @@ defmodule LivebookWeb.AppsLive do defp apps_listing(apps) do Enum.sort_by(apps, & &1.notebook_name) end + + defp load_data(socket, apps \\ nil) do + apps = apps || Livebook.Apps.list_authorized_apps(socket.assigns.current_user) + + filtered_apps = + filter_apps(apps, socket.assigns.search_term, socket.assigns.selected_app_folder) + + empty_apps_path? = Livebook.Apps.empty_apps_path?() + + app_folders = + Enum.flat_map(Livebook.Hubs.get_hubs(), fn + %{id: "team-" <> _} = team -> Livebook.Teams.get_app_folders(team) + _ -> [] + end) + + app_folder_options = + for app_folder <- app_folders do + {app_folder.name, app_folder.id} + end + + grouped_apps = + filtered_apps + |> Enum.group_by(& &1.app_spec.app_folder_id) + |> Enum.map(fn + {nil, apps} -> + {"Ungrouped apps", "ungrouped-apps", apps} + + {id, apps} -> + {Enum.find_value(app_folders, &(&1.id == id && &1.name)), "app-folder-#{id}", apps} + end) + |> Enum.sort_by(&elem(&1, 0)) + + assign(socket, + apps: apps, + grouped_apps: grouped_apps, + app_folders: app_folders, + app_folder_options: app_folder_options, + filtered_apps: filtered_apps, + empty_apps_path?: empty_apps_path?, + logout_enabled?: + Livebook.Config.logout_enabled?() and socket.assigns.current_user.email != nil + ) + 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, &(&1.app_spec.app_folder_id == app_folder_id)) + end end From b13dabb3e90166952235c0273a2795d4ed3ba894 Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Thu, 6 Nov 2025 12:58:26 -0300 Subject: [PATCH 06/12] Fix tests --- lib/livebook/hubs/team_client.ex | 2 + test/livebook_teams/web/admin_live_test.exs | 14 +++-- .../web/app_session_live_test.exs | 58 ++++++++++++++++--- test/livebook_teams/web/apps_live_test.exs | 51 ++++++++++++---- test/support/app_helpers.ex | 5 +- 5 files changed, 102 insertions(+), 28 deletions(-) diff --git a/lib/livebook/hubs/team_client.ex b/lib/livebook/hubs/team_client.ex index 9e67335b8a3..f5bf4bec65d 100644 --- a/lib/livebook/hubs/team_client.ex +++ b/lib/livebook/hubs/team_client.ex @@ -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 """ diff --git a/test/livebook_teams/web/admin_live_test.exs b/test/livebook_teams/web/admin_live_test.exs index 1aecdcfed44..26f41978d1c 100644 --- a/test/livebook_teams/web/admin_live_test.exs +++ b/test/livebook_teams/web/admin_live_test.exs @@ -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 ) diff --git a/test/livebook_teams/web/app_session_live_test.exs b/test/livebook_teams/web/app_session_live_test.exs index edd97ce57ed..a938d103440 100644 --- a/test/livebook_teams/web/app_session_live_test.exs +++ b/test/livebook_teams/web/app_session_live_test.exs @@ -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) diff --git a/test/livebook_teams/web/apps_live_test.exs b/test/livebook_teams/web/apps_live_test.exs index eaffce17ed5..009ecf5dd84 100644 --- a/test/livebook_teams/web/apps_live_test.exs +++ b/test/livebook_teams/web/apps_live_test.exs @@ -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) diff --git a/test/support/app_helpers.ex b/test/support/app_helpers.ex index 33946d47022..1c3d69ac6f0 100644 --- a/test/support/app_helpers.ex +++ b/test/support/app_helpers.ex @@ -26,12 +26,13 @@ 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) do app_path = Path.join(tmp_dir, "#{slug}.livemd") + app_folder = if app_folder, do: ~s(,"app_folder_id":"#{app_folder.id}") source = stamp_notebook(app_path, """ - + # LivebookApp:#{slug} From 4b53399f2e859358ca110e635c03434799c64c13 Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Thu, 6 Nov 2025 12:58:42 -0300 Subject: [PATCH 07/12] Add more tests --- .../web/app_session_live_test.exs | 65 +++++ test/livebook_teams/web/apps_live_test.exs | 268 ++++++++++++++++++ test/support/app_helpers.ex | 14 +- 3 files changed, 345 insertions(+), 2 deletions(-) diff --git a/test/livebook_teams/web/app_session_live_test.exs b/test/livebook_teams/web/app_session_live_test.exs index a938d103440..a3a2765bb3c 100644 --- a/test/livebook_teams/web/app_session_live_test.exs +++ b/test/livebook_teams/web/app_session_live_test.exs @@ -247,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 diff --git a/test/livebook_teams/web/apps_live_test.exs b/test/livebook_teams/web/apps_live_test.exs index 009ecf5dd84..1db6a8c32f0 100644 --- a/test/livebook_teams/web/apps_live_test.exs +++ b/test/livebook_teams/web/apps_live_test.exs @@ -245,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: nil}}} + + # 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 diff --git a/test/support/app_helpers.ex b/test/support/app_helpers.ex index 1c3d69ac6f0..aa812b8a589 100644 --- a/test/support/app_helpers.ex +++ b/test/support/app_helpers.ex @@ -26,15 +26,25 @@ defmodule Livebook.AppHelpers do end end - def deploy_app(slug, team, org, deployment_group, tmp_dir, node, app_folder \\ nil) 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, """ - # LivebookApp:#{slug} + # #{title} ```elixir IO.puts("Hi") From edcc1d48b776478e24e589d672d9e406a4250760 Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Thu, 6 Nov 2025 19:08:01 -0300 Subject: [PATCH 08/12] Apply suggestions --- lib/livebook_web/live/apps_live.ex | 95 ++++++++++++++---------------- 1 file changed, 45 insertions(+), 50 deletions(-) diff --git a/lib/livebook_web/live/apps_live.ex b/lib/livebook_web/live/apps_live.ex index 717fb7501bb..496dec1dbaf 100644 --- a/lib/livebook_web/live/apps_live.ex +++ b/lib/livebook_web/live/apps_live.ex @@ -23,7 +23,7 @@ defmodule LivebookWeb.AppsLive do @impl true def render(assigns) do ~H""" -
+
<.menu id="apps-menu" position="bottom-right" md_position="bottom-left"> @@ -54,45 +54,41 @@ defmodule LivebookWeb.AppsLive do
-
-

Apps

-

Find and manage your Livebook applications

-
+

Apps

+

Find your Livebook applications

<%= if @apps != [] do %> -
-
-
-
-
- <.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 mt-6 pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" - /> -
-
-
-
- <.select_field - id="select-app-folder" - name="app_folder" - label="Folder" - prompt="Select a folder..." - value={@selected_app_folder} - options={@app_folder_options} - /> -
+
+
+
+
+ <.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 mt-6 pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" + />
+
+
+ <.select_field + id="select-app-folder" + name="app_folder" + label="Folder" + prompt="Select a folder..." + value={@selected_app_folder} + options={@app_folder_options} + /> +
+
@@ -100,18 +96,18 @@ defmodule LivebookWeb.AppsLive do :if={@filtered_apps == []} class="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center" > - <.remix_icon icon="search-line" class="mx-auto h-12 w-12 text-gray-300 mb-4" /> -

No apps found

+ <.remix_icon icon="windy-line" class="text-gray-400 text-2xl" /> +

No apps found

Try adjusting your search or filter criteria

- <.remix_icon icon="folder-line" class="mr-2" /> + <.remix_icon icon={icon} class="mr-2" /> {app_folder} ({length(apps)})

@@ -120,15 +116,13 @@ defmodule LivebookWeb.AppsLive do :for={app <- apps_listing(apps)} id={"app-#{app.slug}"} navigate={~p"/apps/#{app.slug}"} - class="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md hover:border-blue-300 transition-all duration-200 group" + class="border bg-gray-50 border-gray-300 rounded-lg p-4 hover:shadow-md hover:border-blue-300 transition-all duration-200" > -
-
-

- {app.notebook_name} -

-
-
+
+

+ {app.notebook_name} +

+
<.remix_icon :if={not app.public?} icon="lock-password-line" @@ -266,10 +260,11 @@ defmodule LivebookWeb.AppsLive do |> Enum.group_by(& &1.app_spec.app_folder_id) |> Enum.map(fn {nil, apps} -> - {"Ungrouped apps", "ungrouped-apps", apps} + {"Ungrouped apps", "ungrouped-apps", "asterisk", apps} {id, apps} -> - {Enum.find_value(app_folders, &(&1.id == id && &1.name)), "app-folder-#{id}", apps} + {Enum.find_value(app_folders, &(&1.id == id && &1.name)), "app-folder-#{id}", + "folder-line", apps} end) |> Enum.sort_by(&elem(&1, 0)) From bc4857a72f620a6a76c4fd7d8186d38e829a5eea Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Fri, 7 Nov 2025 10:33:05 -0300 Subject: [PATCH 09/12] Fix bug with non-teams app specs --- lib/livebook/apps/manager.ex | 15 +++++++++++---- lib/livebook_web/live/apps_live.ex | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/lib/livebook/apps/manager.ex b/lib/livebook/apps/manager.ex index b1477134dc0..c1fcbb2017b 100644 --- a/lib/livebook/apps/manager.ex +++ b/lib/livebook/apps/manager.ex @@ -185,10 +185,14 @@ defmodule Livebook.Apps.Manager do reduce: {state, [], false} do {state, up_to_date_app_specs, schedule_sync?} -> case fetch_app(app_spec.slug) do - {:ok, _state, app} - when app.app_spec.version == app_spec.version and - app.app_spec.app_folder_id == app_spec.app_folder_id -> - {state, [app_spec | up_to_date_app_specs], schedule_sync?} + {:ok, _state, app} when app.app_spec.version == app_spec.version -> + if changed_app_folder?(app.app_spec, app_spec) do + ref = redeploy(app, app_spec) + state = track_deployment(state, app_spec, ref) + {state, up_to_date_app_specs, schedule_sync?} + else + {state, [app_spec | up_to_date_app_specs], schedule_sync?} + end {:ok, :reachable, app} -> ref = redeploy(app, app_spec) @@ -340,4 +344,7 @@ defmodule Livebook.Apps.Manager do deployment.ref == ref && deployment end) end + + defp changed_app_folder?(%{app_folder_id: id}, %{app_folder_id: id2}), do: id != id2 + defp changed_app_folder?(_, _), do: false end diff --git a/lib/livebook_web/live/apps_live.ex b/lib/livebook_web/live/apps_live.ex index 496dec1dbaf..45574f8e319 100644 --- a/lib/livebook_web/live/apps_live.ex +++ b/lib/livebook_web/live/apps_live.ex @@ -257,7 +257,7 @@ defmodule LivebookWeb.AppsLive do grouped_apps = filtered_apps - |> Enum.group_by(& &1.app_spec.app_folder_id) + |> Enum.group_by(&get_in(&1.app_spec.app_folder_id)) |> Enum.map(fn {nil, apps} -> {"Ungrouped apps", "ungrouped-apps", "asterisk", apps} From 23ef7764158c6a5e2aa4b6b5bb85918191c25073 Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Fri, 7 Nov 2025 14:16:06 -0300 Subject: [PATCH 10/12] Apply suggestions --- lib/livebook/apps/manager.ex | 11 +- lib/livebook/live_markdown/import.ex | 18 +- lib/livebook_web/live/apps_live.ex | 350 ++++++++++-------- .../session_live/app_settings_component.ex | 9 +- .../live_markdown/import_test.exs | 8 +- test/livebook_teams/web/apps_live_test.exs | 2 +- test/livebook_web/live/session_live_test.exs | 3 + 7 files changed, 217 insertions(+), 184 deletions(-) diff --git a/lib/livebook/apps/manager.ex b/lib/livebook/apps/manager.ex index c1fcbb2017b..ccee0a92ead 100644 --- a/lib/livebook/apps/manager.ex +++ b/lib/livebook/apps/manager.ex @@ -186,13 +186,7 @@ defmodule Livebook.Apps.Manager do {state, up_to_date_app_specs, schedule_sync?} -> case fetch_app(app_spec.slug) do {:ok, _state, app} when app.app_spec.version == app_spec.version -> - if changed_app_folder?(app.app_spec, app_spec) do - ref = redeploy(app, app_spec) - state = track_deployment(state, app_spec, ref) - {state, up_to_date_app_specs, schedule_sync?} - else - {state, [app_spec | up_to_date_app_specs], schedule_sync?} - end + {state, [app_spec | up_to_date_app_specs], schedule_sync?} {:ok, :reachable, app} -> ref = redeploy(app, app_spec) @@ -344,7 +338,4 @@ defmodule Livebook.Apps.Manager do deployment.ref == ref && deployment end) end - - defp changed_app_folder?(%{app_folder_id: id}, %{app_folder_id: id2}), do: id != id2 - defp changed_app_folder?(_, _), do: false end diff --git a/lib/livebook/live_markdown/import.ex b/lib/livebook/live_markdown/import.ex index 09473c72737..017a9604b30 100644 --- a/lib/livebook/live_markdown/import.ex +++ b/lib/livebook/live_markdown/import.ex @@ -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) diff --git a/lib/livebook_web/live/apps_live.ex b/lib/livebook_web/live/apps_live.ex index 45574f8e319..48ee9734909 100644 --- a/lib/livebook_web/live/apps_live.ex +++ b/lib/livebook_web/live/apps_live.ex @@ -14,16 +14,34 @@ defmodule LivebookWeb.AppsLive do Livebook.Apps.subscribe() end + empty_apps_path? = Livebook.Apps.empty_apps_path?() + + 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 + {:ok, socket - |> assign(search_term: "", selected_app_folder: "") + |> assign( + search_term: "", + selected_app_folder: "", + app_folders: app_folders, + app_folder_options: app_folder_options, + empty_apps_path?: empty_apps_path?, + logout_enabled?: + Livebook.Config.logout_enabled?() and socket.assigns.current_user.email != nil + ) |> load_data()} end @impl true def render(assigns) do ~H""" -
+
<.menu id="apps-menu" position="bottom-right" md_position="bottom-left"> @@ -52,149 +70,154 @@ defmodule LivebookWeb.AppsLive do
-
-
-

Apps

-

Find your Livebook applications

- - <%= if @apps != [] do %> -
-
-
-
- <.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 mt-6 pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" - /> -
-
-
-
- <.select_field - id="select-app-folder" - name="app_folder" - label="Folder" - prompt="Select a folder..." - value={@selected_app_folder} - options={@app_folder_options} - /> -
-
-
+
+ <.focus_wrap + id="apps-page-content" + class="flex relative p-6 h-full w-full max-w-[90%] max-h-[90%] bg-white overflow-y-auto rounded-lg shadow-2xl" + > +
+
+

Apps

+

Find your applications

-
- <.remix_icon icon="windy-line" class="text-gray-400 text-2xl" /> -

No apps found

-

Try adjusting your search or filter criteria

-
-
-

- <.remix_icon icon={icon} class="mr-2" /> - {app_folder} - ({length(apps)}) -

-
- <.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" - > -
-

- {app.notebook_name} -

-
+ <%= if @apps != [] do %> +
+
+
+
<.remix_icon - :if={not app.public?} - icon="lock-password-line" - class="h-4 w-4 text-gray-400" + icon="search-line" + class="absolute left-3 bottom-[8px] text-gray-400" /> - <.remix_icon - icon="arrow-right-line" - class="h-4 w-4 text-gray-400 group-hover:text-blue-600 transition-colors" + <.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" />
- -
-
- <% else %> -
- <.remix_icon icon="folder-add-line" class="mx-auto h-16 w-16 text-gray-300 mb-6" /> -

No app notebooks found

-

- Follow these steps to list your apps here: -

-
-
    -
  1. - - 1 - - Open a notebook -
  2. -
  3. - - 2 - -
    - Click <.remix_icon icon="rocket-line" class="inline align-baseline text-base" /> - in the sidebar and configure the app as public -
    -
  4. -
  5. - - 3 - -
    - Save the notebook to the - - {Livebook.Config.apps_path()} - - folder +
    +
    + <.select_field + id="select-app-folder" + name="app_folder" + prompt="Select a folder..." + value={@selected_app_folder} + options={@app_folder_options} + /> +
    +
    +
    + +
    + <.remix_icon icon="windy-line" class="text-gray-400 text-2xl" /> +

    No apps found

    +

    Try adjusting your search or filter criteria

    +
    +
    +
    +

    + <.remix_icon icon={icon} /> + {app_folder} + ({length(apps)}) +

    +
    + <.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" + > +
    +

    + {app.notebook_name} +

    +
    + <.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" + /> +
    +
    +
    -
  6. -
  7. - - 4 - - Relaunch your Livebook app -
  8. -
+
+
-
-
- <.remix_icon icon="file-line" class="mx-auto h-16 w-16 text-gray-300 mb-6" /> -

No apps running

-

Start some apps to see them listed here

-
- <% end %> -
+ <% else %> +
+
+
+ <.remix_icon icon="windy-line" class="size-16 text-gray-400 text-2xl" /> +

No apps found

+

Follow these steps to list your apps here:

+
+
+
    +
  1. + + 1 + + Open a notebook +
  2. +
  3. + + 2 + +
    + Click + <.remix_icon icon="rocket-line" class="inline align-baseline text-base" /> + in the sidebar and configure the app as public +
    +
  4. +
  5. + + 3 + +
    + Save the notebook to the + + {Livebook.Config.apps_path()} + + folder +
    +
  6. +
  7. + + 4 + + Relaunch your Livebook app +
  8. +
+
+
+
+ <.remix_icon icon="windy-line" class="size-16 text-gray-400 text-2xl" /> +

No apps running

+

Start some apps to see them listed here

+
+
+ <% end %> +
+
""" @@ -223,11 +246,31 @@ defmodule LivebookWeb.AppsLive do end def handle_info({:server_authorization_updated, _}, socket) do - {:noreply, load_data(socket)} + {:noreply, + socket + |> assign( + logout_enabled?: + Livebook.Config.logout_enabled?() and socket.assigns.current_user.email != nil + ) + |> load_data()} end def handle_info({type, _app_folder}, socket) when type in @events do - {:noreply, load_data(socket)} + 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 + + {:noreply, + socket + |> assign( + app_folders: app_folders, + app_folder_options: app_folder_options + ) + |> load_data()} end def handle_info(_message, socket), do: {:noreply, socket} @@ -238,45 +281,34 @@ defmodule LivebookWeb.AppsLive do defp load_data(socket, apps \\ nil) do apps = apps || Livebook.Apps.list_authorized_apps(socket.assigns.current_user) + app_folders = socket.assigns.app_folders filtered_apps = filter_apps(apps, socket.assigns.search_term, socket.assigns.selected_app_folder) - empty_apps_path? = Livebook.Apps.empty_apps_path?() - - app_folders = - Enum.flat_map(Livebook.Hubs.get_hubs(), fn - %{id: "team-" <> _} = team -> Livebook.Teams.get_app_folders(team) - _ -> [] - end) - - app_folder_options = - for app_folder <- app_folders do - {app_folder.name, app_folder.id} - end - grouped_apps = filtered_apps - |> Enum.group_by(&get_in(&1.app_spec.app_folder_id)) + |> 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} -> - {Enum.find_value(app_folders, &(&1.id == id && &1.name)), "app-folder-#{id}", - "folder-line", 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, apps: apps, grouped_apps: grouped_apps, - app_folders: app_folders, - app_folder_options: app_folder_options, filtered_apps: filtered_apps, - empty_apps_path?: empty_apps_path?, - logout_enabled?: - Livebook.Config.logout_enabled?() and socket.assigns.current_user.email != nil + show_app_folders?: show_app_folders? ) end diff --git a/lib/livebook_web/live/session_live/app_settings_component.ex b/lib/livebook_web/live/session_live/app_settings_component.ex index 037e567f4f7..3466dc1c322 100644 --- a/lib/livebook_web/live/session_live/app_settings_component.ex +++ b/lib/livebook_web/live/session_live/app_settings_component.ex @@ -17,10 +17,16 @@ defmodule LivebookWeb.SessionLive.AppSettingsComponent do {app_folder.name, app_folder.id} end + notebook = Livebook.Session.get_notebook(assigns.session.pid) + {:ok, socket |> assign(assigns) - |> assign(app_folder_options: app_folder_options, changeset: changeset)} + |> assign( + app_folder_options: app_folder_options, + changeset: changeset, + hub_id: notebook.hub_id + )} end @impl true @@ -48,6 +54,7 @@ defmodule LivebookWeb.SessionLive.AppSettingsComponent do
<.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..." diff --git a/test/livebook_teams/live_markdown/import_test.exs b/test/livebook_teams/live_markdown/import_test.exs index d14c443d350..873bcfba1f4 100644 --- a/test/livebook_teams/live_markdown/import_test.exs +++ b/test/livebook_teams/live_markdown/import_test.exs @@ -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 diff --git a/test/livebook_teams/web/apps_live_test.exs b/test/livebook_teams/web/apps_live_test.exs index 1db6a8c32f0..b9b98b26be7 100644 --- a/test/livebook_teams/web/apps_live_test.exs +++ b/test/livebook_teams/web/apps_live_test.exs @@ -365,7 +365,7 @@ defmodule LivebookWeb.Integration.AppsLiveTest do 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: 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. diff --git a/test/livebook_web/live/session_live_test.exs b/test/livebook_web/live/session_live_test.exs index d97530aef6e..ee4d1c4a7ce 100644 --- a/test/livebook_web/live/session_live_test.exs +++ b/test/livebook_web/live/session_live_test.exs @@ -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}}) From be435e20d8fb5130a57fd97e5c375689d992ae36 Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Mon, 10 Nov 2025 16:36:22 -0300 Subject: [PATCH 11/12] Apply suggestions from code review --- lib/livebook/hubs/team_client.ex | 13 ++++ lib/livebook_web/live/apps_live.ex | 64 +++++++++---------- .../session_live/app_settings_component.ex | 5 +- lib/livebook_web/live/session_live/render.ex | 1 + 4 files changed, 46 insertions(+), 37 deletions(-) diff --git a/lib/livebook/hubs/team_client.ex b/lib/livebook/hubs/team_client.ex index f5bf4bec65d..7874624ef20 100644 --- a/lib/livebook/hubs/team_client.ex +++ b/lib/livebook/hubs/team_client.ex @@ -867,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 diff --git a/lib/livebook_web/live/apps_live.ex b/lib/livebook_web/live/apps_live.ex index 48ee9734909..95f95b03faa 100644 --- a/lib/livebook_web/live/apps_live.ex +++ b/lib/livebook_web/live/apps_live.ex @@ -16,26 +16,18 @@ defmodule LivebookWeb.AppsLive do empty_apps_path? = Livebook.Apps.empty_apps_path?() - 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 - {:ok, socket |> assign( search_term: "", selected_app_folder: "", - app_folders: app_folders, - app_folder_options: app_folder_options, + 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_data()} + |> load_app_folders() + |> apply_filters()} end @impl true @@ -228,49 +220,41 @@ defmodule LivebookWeb.AppsLive do {:noreply, socket |> assign(search_term: search_term) - |> load_data()} + |> 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) - |> load_data()} + |> apply_filters()} end @impl true def handle_info({type, _app} = event, socket) when type in [:app_created, :app_updated, :app_closed] do - apps = LivebookWeb.AppComponents.update_app_list(socket.assigns.apps, event) - {:noreply, load_data(socket, apps)} + {:noreply, + socket + |> assign(apps: LivebookWeb.AppComponents.update_app_list(socket.assigns.apps, event)) + |> apply_filters()} end def handle_info({:server_authorization_updated, _}, socket) do {: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 ) - |> load_data()} + |> apply_filters()} end def handle_info({type, _app_folder}, socket) when type in @events 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 - {:noreply, socket - |> assign( - app_folders: app_folders, - app_folder_options: app_folder_options - ) - |> load_data()} + |> load_app_folders() + |> apply_filters()} end def handle_info(_message, socket), do: {:noreply, socket} @@ -279,8 +263,20 @@ defmodule LivebookWeb.AppsLive do Enum.sort_by(apps, & &1.notebook_name) end - defp load_data(socket, apps \\ nil) do - apps = apps || Livebook.Apps.list_authorized_apps(socket.assigns.current_user) + 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 = @@ -305,7 +301,6 @@ defmodule LivebookWeb.AppsLive do show_app_folders? = Enum.any?(apps, &is_struct(&1.app_spec, Livebook.Apps.TeamsAppSpec)) assign(socket, - apps: apps, grouped_apps: grouped_apps, filtered_apps: filtered_apps, show_app_folders?: show_app_folders? @@ -332,6 +327,9 @@ defmodule LivebookWeb.AppsLive do defp filter_by_app_folder(apps, ""), do: apps defp filter_by_app_folder(apps, app_folder_id) do - Enum.filter(apps, &(&1.app_spec.app_folder_id == app_folder_id)) + Enum.filter(apps, fn + %{app_spec: %{app_folder_id: id}} -> id == app_folder_id + _otherwise -> false + end) end end diff --git a/lib/livebook_web/live/session_live/app_settings_component.ex b/lib/livebook_web/live/session_live/app_settings_component.ex index 3466dc1c322..060f4331a66 100644 --- a/lib/livebook_web/live/session_live/app_settings_component.ex +++ b/lib/livebook_web/live/session_live/app_settings_component.ex @@ -17,15 +17,12 @@ defmodule LivebookWeb.SessionLive.AppSettingsComponent do {app_folder.name, app_folder.id} end - notebook = Livebook.Session.get_notebook(assigns.session.pid) - {:ok, socket |> assign(assigns) |> assign( app_folder_options: app_folder_options, - changeset: changeset, - hub_id: notebook.hub_id + changeset: changeset )} end diff --git a/lib/livebook_web/live/session_live/render.ex b/lib/livebook_web/live/session_live/render.ex index b11f11f1404..b847a829e79 100644 --- a/lib/livebook_web/live/session_live/render.ex +++ b/lib/livebook_web/live/session_live/render.ex @@ -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} /> From 672d3e96276109de5dba8efb9c2d56cccff718b5 Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Wed, 12 Nov 2025 15:11:36 -0300 Subject: [PATCH 12/12] Rollback modal design --- lib/livebook_web/live/apps_live.ex | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/lib/livebook_web/live/apps_live.ex b/lib/livebook_web/live/apps_live.ex index 95f95b03faa..285d407c2df 100644 --- a/lib/livebook_web/live/apps_live.ex +++ b/lib/livebook_web/live/apps_live.ex @@ -61,12 +61,8 @@ defmodule LivebookWeb.AppsLive do
- -
- <.focus_wrap - id="apps-page-content" - class="flex relative p-6 h-full w-full max-w-[90%] max-h-[90%] bg-white overflow-y-auto rounded-lg shadow-2xl" - > +
+

Apps

@@ -209,7 +205,7 @@ defmodule LivebookWeb.AppsLive do
<% end %>
- +
"""