mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-12-19 22:49:23 +08:00
Introducing the new Apps page (#3091)
This commit is contained in:
parent
c7762469f2
commit
e7098f51a9
15 changed files with 757 additions and 105 deletions
|
|
@ -3,7 +3,7 @@ defmodule Livebook.Apps.TeamsAppSpec do
|
||||||
|
|
||||||
@enforce_keys [:slug, :version, :hub_id, :app_deployment_id]
|
@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
|
end
|
||||||
|
|
||||||
defimpl Livebook.Apps.AppSpec, for: Livebook.Apps.TeamsAppSpec do
|
defimpl Livebook.Apps.AppSpec, for: Livebook.Apps.TeamsAppSpec do
|
||||||
|
|
|
||||||
|
|
@ -254,7 +254,8 @@ defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Team do
|
||||||
slug: app_deployment.slug,
|
slug: app_deployment.slug,
|
||||||
version: app_deployment.version,
|
version: app_deployment.version,
|
||||||
hub_id: app_deployment.hub_id,
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,8 @@ defmodule Livebook.Hubs.TeamClient do
|
||||||
@spec get_app_folders(String.t()) :: list(Teams.AppFolder.t())
|
@spec get_app_folders(String.t()) :: list(Teams.AppFolder.t())
|
||||||
def get_app_folders(id) do
|
def get_app_folders(id) do
|
||||||
GenServer.call(registry_name(id), :get_app_folders)
|
GenServer.call(registry_name(id), :get_app_folders)
|
||||||
|
catch
|
||||||
|
:exit, _ -> []
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -865,6 +867,19 @@ defmodule Livebook.Hubs.TeamClient do
|
||||||
defp handle_event(:app_deployment_updated, %Teams.AppDeployment{} = app_deployment, state) do
|
defp handle_event(:app_deployment_updated, %Teams.AppDeployment{} = app_deployment, state) do
|
||||||
manager_sync(app_deployment, state)
|
manager_sync(app_deployment, state)
|
||||||
Teams.Broadcasts.app_deployment_updated(app_deployment)
|
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)
|
put_app_deployment(state, app_deployment)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -667,25 +667,23 @@ defmodule Livebook.LiveMarkdown.Import do
|
||||||
# validate it against the public key).
|
# validate it against the public key).
|
||||||
teams_enabled = is_struct(hub, Livebook.Hubs.Team) and (hub.offline == nil or stamp_verified?)
|
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
|
if app_folder_id = notebook.app_settings.app_folder_id do
|
||||||
app_folders = Hubs.Provider.get_app_folders(hub)
|
app_folders = Hubs.Provider.get_app_folders(hub)
|
||||||
|
|
||||||
if Enum.any?(app_folders, &(&1.id == app_folder_id)) do
|
if Enum.any?(app_folders, &(&1.id == app_folder_id)) do
|
||||||
{notebook.app_settings, messages}
|
messages
|
||||||
else
|
else
|
||||||
{Map.replace!(notebook.app_settings, :app_folder_id, nil),
|
messages ++
|
||||||
messages ++
|
[
|
||||||
[
|
"notebook is assigned to a non-existent app folder, defaulting to ungrouped app folder"
|
||||||
"notebook is assigned to a non-existent app folder, defaulting to ungrouped app folder"
|
]
|
||||||
]}
|
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
{notebook.app_settings, messages}
|
messages
|
||||||
end
|
end
|
||||||
|
|
||||||
{%{notebook | app_settings: app_settings, teams_enabled: teams_enabled}, stamp_verified?,
|
{%{notebook | teams_enabled: teams_enabled}, stamp_verified?, messages}
|
||||||
messages}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp safe_binary_split(binary, offset)
|
defp safe_binary_split(binary, offset)
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,40 @@
|
||||||
defmodule LivebookWeb.AppsLive do
|
defmodule LivebookWeb.AppsLive do
|
||||||
use LivebookWeb, :live_view
|
use LivebookWeb, :live_view
|
||||||
|
|
||||||
|
@events [
|
||||||
|
:app_folder_created,
|
||||||
|
:app_folder_updated,
|
||||||
|
:app_folder_deleted
|
||||||
|
]
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
if connected?(socket) do
|
if connected?(socket) do
|
||||||
Livebook.Teams.Broadcasts.subscribe(:app_server)
|
Livebook.Teams.Broadcasts.subscribe([:app_server, :app_folders])
|
||||||
Livebook.Apps.subscribe()
|
Livebook.Apps.subscribe()
|
||||||
end
|
end
|
||||||
|
|
||||||
apps = Livebook.Apps.list_authorized_apps(socket.assigns.current_user)
|
|
||||||
empty_apps_path? = Livebook.Apps.empty_apps_path?()
|
empty_apps_path? = Livebook.Apps.empty_apps_path?()
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
assign(socket,
|
socket
|
||||||
apps: apps,
|
|> assign(
|
||||||
|
search_term: "",
|
||||||
|
selected_app_folder: "",
|
||||||
|
apps: Livebook.Apps.list_authorized_apps(socket.assigns.current_user),
|
||||||
empty_apps_path?: empty_apps_path?,
|
empty_apps_path?: empty_apps_path?,
|
||||||
logout_enabled?:
|
logout_enabled?:
|
||||||
Livebook.Config.logout_enabled?() and socket.assigns.current_user.email != nil
|
Livebook.Config.logout_enabled?() and socket.assigns.current_user.email != nil
|
||||||
)}
|
)
|
||||||
|
|> load_app_folders()
|
||||||
|
|> apply_filters()}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="h-full flex flex-col overflow-y-auto">
|
<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">
|
<div class="w-10 h-10">
|
||||||
<.menu id="apps-menu" position="bottom-right" md_position="bottom-left">
|
<.menu id="apps-menu" position="bottom-right" md_position="bottom-left">
|
||||||
<:toggle>
|
<:toggle>
|
||||||
|
|
@ -42,58 +52,158 @@ defmodule LivebookWeb.AppsLive do
|
||||||
</.menu>
|
</.menu>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<span class="font-semibold">Dashboard</span>
|
||||||
<.remix_icon icon="arrow-right-line" class="align-middle ml-1" />
|
<.remix_icon icon="arrow-right-line" class="align-middle ml-1" />
|
||||||
</.link>
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full max-w-screen-lg px-4 md:px-20 py-4 mx-auto">
|
<div class="flex-1 px-6 py-6">
|
||||||
<div class="flex flex-col items-center">
|
<div class="max-w-7xl mx-auto">
|
||||||
<h1 class="text-2xl text-gray-800 font-medium">
|
<div class="flex flex-col gap-y-4 w-full">
|
||||||
Apps
|
<div class="flex flex-col gap-y-2">
|
||||||
</h1>
|
<h1 class="text-3xl font-bold text-gray-900">Apps</h1>
|
||||||
<div :if={@apps != []} class="w-full mt-5 max-w-[400px]">
|
<p class="text-gray-600">Find your applications</p>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
|
||||||
<div
|
<%= if @apps != [] do %>
|
||||||
:if={@apps == [] and not @empty_apps_path?}
|
<div class="flex flex-col gap-y-8">
|
||||||
class="mt-5 flex flex-col w-full max-w-[400px]"
|
<div class="flex flex-col md:flex-row gap-4">
|
||||||
>
|
<div class="flex-1">
|
||||||
<.no_entries :if={@apps == []}>
|
<div class="relative">
|
||||||
No apps running.
|
<.remix_icon
|
||||||
</.no_entries>
|
icon="search-line"
|
||||||
</div>
|
class="absolute left-3 bottom-[8px] text-gray-400"
|
||||||
<div :if={@apps == [] and @empty_apps_path?} class="mt-5 text-gray-600">
|
/>
|
||||||
<div>
|
<.text_field
|
||||||
No app notebooks found. Follow these steps to list your apps here:
|
id="search-app"
|
||||||
</div>
|
name="search_term"
|
||||||
<ol class="mt-4 pl-4 flex flex-col space-y-1 list-decimal list-inside">
|
placeholder="Search apps..."
|
||||||
<li>
|
value={@search_term}
|
||||||
Open a notebook
|
phx-keyup="search"
|
||||||
</li>
|
phx-debounce="300"
|
||||||
<li>
|
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"
|
||||||
Click <.remix_icon icon="rocket-line" class="align-baseline text-lg" />
|
/>
|
||||||
in the sidebar and configure the app as public
|
</div>
|
||||||
</li>
|
</div>
|
||||||
<li>
|
<div :if={@show_app_folders?} class="md:w-48">
|
||||||
Save the notebook to the
|
<form id="select-app-folder-form" phx-change="select_app_folder" phx-nosubmit>
|
||||||
<span class="font-medium">{Livebook.Config.apps_path()}</span>
|
<.select_field
|
||||||
folder
|
id="select-app-folder"
|
||||||
</li>
|
name="app_folder"
|
||||||
<li>
|
prompt="Select a folder..."
|
||||||
Relaunch your Livebook app
|
value={@selected_app_folder}
|
||||||
</li>
|
options={@app_folder_options}
|
||||||
</ol>
|
/>
|
||||||
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -101,15 +211,46 @@ defmodule LivebookWeb.AppsLive do
|
||||||
"""
|
"""
|
||||||
end
|
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
|
@impl true
|
||||||
def handle_info({type, _app} = event, socket)
|
def handle_info({type, _app} = event, socket)
|
||||||
when type in [:app_created, :app_updated, :app_closed] do
|
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
|
end
|
||||||
|
|
||||||
def handle_info({:server_authorization_updated, _}, socket) do
|
def handle_info({:server_authorization_updated, _}, socket) do
|
||||||
apps = Livebook.Apps.list_authorized_apps(socket.assigns.current_user)
|
{:noreply,
|
||||||
{:noreply, assign(socket, :apps, apps)}
|
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
|
end
|
||||||
|
|
||||||
def handle_info(_message, socket), do: {:noreply, socket}
|
def handle_info(_message, socket), do: {:noreply, socket}
|
||||||
|
|
@ -117,4 +258,74 @@ defmodule LivebookWeb.AppsLive do
|
||||||
defp apps_listing(apps) do
|
defp apps_listing(apps) do
|
||||||
Enum.sort_by(apps, & &1.notebook_name)
|
Enum.sort_by(apps, & &1.notebook_name)
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,10 @@ defmodule LivebookWeb.SessionLive.AppSettingsComponent do
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(assigns)
|
|> assign(assigns)
|
||||||
|> assign(app_folder_options: app_folder_options, changeset: changeset)}
|
|> assign(
|
||||||
|
app_folder_options: app_folder_options,
|
||||||
|
changeset: changeset
|
||||||
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -48,6 +51,7 @@ defmodule LivebookWeb.SessionLive.AppSettingsComponent do
|
||||||
<div class="flex flex-col space-y-4">
|
<div class="flex flex-col space-y-4">
|
||||||
<.text_field field={f[:slug]} label="Slug" spellcheck="false" phx-debounce />
|
<.text_field field={f[:slug]} label="Slug" spellcheck="false" phx-debounce />
|
||||||
<.select_field
|
<.select_field
|
||||||
|
:if={@hub_id != Livebook.Hubs.Personal.id()}
|
||||||
field={f[:app_folder_id]}
|
field={f[:app_folder_id]}
|
||||||
label="Folder"
|
label="Folder"
|
||||||
prompt="Select a folder..."
|
prompt="Select a folder..."
|
||||||
|
|
|
||||||
|
|
@ -602,11 +602,9 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp app_folder_name(_hub, id) when id in [nil, ""], do: "Ungrouped apps"
|
|
||||||
|
|
||||||
defp app_folder_name(hub, id) do
|
defp app_folder_name(hub, id) do
|
||||||
hub
|
hub
|
||||||
|> Teams.get_app_folders()
|
|> Teams.get_app_folders()
|
||||||
|> Enum.find_value(&(&1.id == id && &1.name))
|
|> Enum.find_value("Ungrouped apps", &(&1.id == id && &1.name))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,7 @@ defmodule LivebookWeb.SessionLive.Render do
|
||||||
context={@action_assigns.context}
|
context={@action_assigns.context}
|
||||||
deployed_app_slug={@data_view.deployed_app_slug}
|
deployed_app_slug={@data_view.deployed_app_slug}
|
||||||
app_folders={@data_view.hub_app_folders}
|
app_folders={@data_view.hub_app_folders}
|
||||||
|
hub_id={@data_view.hub.id}
|
||||||
/>
|
/>
|
||||||
</.modal>
|
</.modal>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ defmodule Livebook.Integration.LiveMarkdown.ImportTest do
|
||||||
@moduletag subscribe_to_teams_topics: [:clients, :app_folders]
|
@moduletag subscribe_to_teams_topics: [:clients, :app_folders]
|
||||||
|
|
||||||
describe "app settings" do
|
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
|
%{node: node, team: team, org: org} do
|
||||||
app_folder = TeamsRPC.create_app_folder(node, name: "delete me", org: org)
|
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)
|
TeamsRPC.delete_app_folder(node, app_folder)
|
||||||
assert_receive {:app_folder_deleted, %{id: ^app_folder_id, hub_id: ^hub_id}}
|
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}},
|
assert {%Notebook{
|
||||||
%{warnings: warnings}} = LiveMarkdown.Import.notebook_from_livemd(markdown)
|
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
|
assert "notebook is assigned to a non-existent app folder, defaulting to ungrouped app folder" in warnings
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,13 @@ defmodule LivebookWeb.Integration.AdminLiveTest do
|
||||||
%{conn: conn, node: node, code: code} = context do
|
%{conn: conn, node: node, code: code} = context do
|
||||||
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
|
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
|
||||||
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
|
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
|
||||||
|
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
|
||||||
|
|
||||||
authorization_group =
|
authorization_group =
|
||||||
TeamsRPC.create_authorization_group(node,
|
TeamsRPC.create_authorization_group(node,
|
||||||
group_name: "marketing",
|
group_name: "marketing",
|
||||||
access_type: :apps,
|
access_type: :apps,
|
||||||
prefixes: ["dev-"],
|
app_folders: [app_folder],
|
||||||
oidc_provider: oidc_provider,
|
oidc_provider: oidc_provider,
|
||||||
deployment_group: context.deployment_group
|
deployment_group: context.deployment_group
|
||||||
)
|
)
|
||||||
|
|
@ -76,6 +77,7 @@ defmodule LivebookWeb.Integration.AdminLiveTest do
|
||||||
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
|
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
|
||||||
|
|
||||||
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
|
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
|
||||||
|
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
|
||||||
|
|
||||||
authorization_group =
|
authorization_group =
|
||||||
TeamsRPC.create_authorization_group(node,
|
TeamsRPC.create_authorization_group(node,
|
||||||
|
|
@ -99,10 +101,9 @@ defmodule LivebookWeb.Integration.AdminLiveTest do
|
||||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||||
assert render(view) =~ "System settings"
|
assert render(view) =~ "System settings"
|
||||||
|
|
||||||
TeamsRPC.update_authorization_group(node, authorization_group, %{
|
TeamsRPC.update_authorization_group(node, authorization_group, %{access_type: :apps}, [
|
||||||
access_type: :apps,
|
app_folder
|
||||||
prefixes: ["ops-"]
|
])
|
||||||
})
|
|
||||||
|
|
||||||
id = to_string(deployment_group.id)
|
id = to_string(deployment_group.id)
|
||||||
assert_receive {:server_authorization_updated, %{id: ^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)
|
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
|
||||||
|
|
||||||
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
|
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
|
||||||
|
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
|
||||||
|
|
||||||
authorization_group =
|
authorization_group =
|
||||||
TeamsRPC.create_authorization_group(node,
|
TeamsRPC.create_authorization_group(node,
|
||||||
group_name: "marketing",
|
group_name: "marketing",
|
||||||
access_type: :apps,
|
access_type: :apps,
|
||||||
prefixes: ["ops-"],
|
app_folders: [app_folder],
|
||||||
oidc_provider: oidc_provider,
|
oidc_provider: oidc_provider,
|
||||||
deployment_group: deployment_group
|
deployment_group: deployment_group
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,13 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
|
||||||
setup :teams
|
setup :teams
|
||||||
|
|
||||||
@moduletag subscribe_to_hubs_topics: [:connection]
|
@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
|
setup do
|
||||||
Livebook.Apps.subscribe()
|
Livebook.Apps.subscribe()
|
||||||
|
|
@ -23,12 +29,13 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
|
||||||
%{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do
|
%{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do
|
||||||
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
|
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
|
||||||
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
|
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
|
||||||
|
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
|
||||||
|
|
||||||
authorization_group =
|
authorization_group =
|
||||||
TeamsRPC.create_authorization_group(node,
|
TeamsRPC.create_authorization_group(node,
|
||||||
group_name: "marketing",
|
group_name: "marketing",
|
||||||
access_type: :apps,
|
access_type: :apps,
|
||||||
prefixes: ["dev-"],
|
app_folders: [app_folder],
|
||||||
oidc_provider: oidc_provider,
|
oidc_provider: oidc_provider,
|
||||||
deployment_group: context.deployment_group
|
deployment_group: context.deployment_group
|
||||||
)
|
)
|
||||||
|
|
@ -46,7 +53,16 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
|
||||||
|
|
||||||
slug = "dev-oban-app"
|
slug = "dev-oban-app"
|
||||||
context = change_to_user_session(context)
|
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)
|
change_to_agent_session(context)
|
||||||
pid = wait_livebook_app_start(slug)
|
pid = wait_livebook_app_start(slug)
|
||||||
|
|
@ -111,12 +127,13 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
|
||||||
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
|
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
|
||||||
|
|
||||||
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
|
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
|
||||||
|
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
|
||||||
|
|
||||||
authorization_group =
|
authorization_group =
|
||||||
TeamsRPC.create_authorization_group(node,
|
TeamsRPC.create_authorization_group(node,
|
||||||
group_name: "marketing",
|
group_name: "marketing",
|
||||||
access_type: :apps,
|
access_type: :apps,
|
||||||
prefixes: ["mkt-"],
|
app_folders: [app_folder],
|
||||||
oidc_provider: oidc_provider,
|
oidc_provider: oidc_provider,
|
||||||
deployment_group: deployment_group
|
deployment_group: deployment_group
|
||||||
)
|
)
|
||||||
|
|
@ -134,7 +151,16 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
|
||||||
|
|
||||||
slug = "mkt-analytics-#{Livebook.Utils.random_short_id()}"
|
slug = "mkt-analytics-#{Livebook.Utils.random_short_id()}"
|
||||||
context = change_to_user_session(context)
|
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)
|
change_to_agent_session(context)
|
||||||
pid = wait_livebook_app_start(slug)
|
pid = wait_livebook_app_start(slug)
|
||||||
|
|
@ -144,8 +170,11 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
|
||||||
{:ok, view, _html} = live(conn, path)
|
{:ok, view, _html} = live(conn, path)
|
||||||
assert render(view) =~ "LivebookApp:#{slug}"
|
assert render(view) =~ "LivebookApp:#{slug}"
|
||||||
|
|
||||||
{:ok, %{prefixes: ["ops-"]}} =
|
app_folder2 = TeamsRPC.create_app_folder(node, org: context.org)
|
||||||
TeamsRPC.update_authorization_group(node, authorization_group, %{prefixes: ["ops-"]})
|
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)
|
id = to_string(deployment_group.id)
|
||||||
|
|
||||||
|
|
@ -164,12 +193,14 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
|
||||||
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
|
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
|
||||||
|
|
||||||
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
|
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 =
|
authorization_group =
|
||||||
TeamsRPC.create_authorization_group(node,
|
TeamsRPC.create_authorization_group(node,
|
||||||
group_name: "marketing",
|
group_name: "marketing",
|
||||||
access_type: :apps,
|
access_type: :apps,
|
||||||
prefixes: ["mkt-"],
|
app_folders: [app_folder],
|
||||||
oidc_provider: oidc_provider,
|
oidc_provider: oidc_provider,
|
||||||
deployment_group: deployment_group
|
deployment_group: deployment_group
|
||||||
)
|
)
|
||||||
|
|
@ -187,7 +218,16 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
|
||||||
|
|
||||||
slug = "analytics-app-#{Livebook.Utils.random_short_id()}"
|
slug = "analytics-app-#{Livebook.Utils.random_short_id()}"
|
||||||
context = change_to_user_session(context)
|
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)
|
change_to_agent_session(context)
|
||||||
pid = wait_livebook_app_start(slug)
|
pid = wait_livebook_app_start(slug)
|
||||||
|
|
@ -207,5 +247,70 @@ defmodule LivebookWeb.Integration.AppSessionLiveTest do
|
||||||
{:ok, view, _html} = live(conn, path)
|
{:ok, view, _html} = live(conn, path)
|
||||||
assert render(view) =~ "LivebookApp:#{slug}"
|
assert render(view) =~ "LivebookApp:#{slug}"
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,12 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
|
||||||
:agents,
|
:agents,
|
||||||
:deployment_groups,
|
:deployment_groups,
|
||||||
:app_deployments,
|
:app_deployments,
|
||||||
:app_server
|
:app_server,
|
||||||
|
:app_folders
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@moduletag :tmp_dir
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
Livebook.Apps.subscribe()
|
Livebook.Apps.subscribe()
|
||||||
:ok
|
:ok
|
||||||
|
|
@ -24,17 +27,17 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
|
||||||
describe "authorized apps" do
|
describe "authorized apps" do
|
||||||
setup :livebook_teams_auth
|
setup :livebook_teams_auth
|
||||||
|
|
||||||
@tag :tmp_dir
|
|
||||||
test "shows one app if user doesn't have full access",
|
test "shows one app if user doesn't have full access",
|
||||||
%{conn: conn, code: code, node: node, tmp_dir: tmp_dir} = context do
|
%{conn: conn, code: code, node: node, tmp_dir: tmp_dir} = context do
|
||||||
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
|
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
|
||||||
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
|
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
|
||||||
|
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
|
||||||
|
|
||||||
authorization_group =
|
authorization_group =
|
||||||
TeamsRPC.create_authorization_group(node,
|
TeamsRPC.create_authorization_group(node,
|
||||||
group_name: "marketing",
|
group_name: "marketing",
|
||||||
access_type: :apps,
|
access_type: :apps,
|
||||||
prefixes: ["dev-"],
|
app_folders: [app_folder],
|
||||||
oidc_provider: oidc_provider,
|
oidc_provider: oidc_provider,
|
||||||
deployment_group: context.deployment_group
|
deployment_group: context.deployment_group
|
||||||
)
|
)
|
||||||
|
|
@ -52,7 +55,16 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
|
||||||
|
|
||||||
slug = "dev-app-#{Livebook.Utils.random_short_id()}"
|
slug = "dev-app-#{Livebook.Utils.random_short_id()}"
|
||||||
context = change_to_user_session(context)
|
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)
|
change_to_agent_session(context)
|
||||||
wait_livebook_app_start(slug)
|
wait_livebook_app_start(slug)
|
||||||
|
|
@ -66,7 +78,6 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
|
||||||
assert html =~ slug
|
assert html =~ slug
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :tmp_dir
|
|
||||||
test "shows all apps if user have full access",
|
test "shows all apps if user have full access",
|
||||||
%{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do
|
%{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do
|
||||||
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
|
TeamsRPC.toggle_groups_authorization(node, context.deployment_group)
|
||||||
|
|
@ -121,7 +132,6 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :tmp_dir
|
|
||||||
test "updates the apps list in real-time",
|
test "updates the apps list in real-time",
|
||||||
%{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do
|
%{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do
|
||||||
{:ok, %{groups_auth: true} = deployment_group} =
|
{: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}}
|
assert_receive {:deployment_group_updated, %{id: ^id, groups_auth: true}}
|
||||||
|
|
||||||
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
|
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
|
||||||
|
app_folder = TeamsRPC.create_app_folder(node, org: context.org)
|
||||||
|
|
||||||
authorization_group =
|
authorization_group =
|
||||||
TeamsRPC.create_authorization_group(node,
|
TeamsRPC.create_authorization_group(node,
|
||||||
group_name: "marketing",
|
group_name: "marketing",
|
||||||
access_type: :apps,
|
access_type: :apps,
|
||||||
prefixes: ["mkt-"],
|
app_folders: [app_folder],
|
||||||
oidc_provider: oidc_provider,
|
oidc_provider: oidc_provider,
|
||||||
deployment_group: deployment_group
|
deployment_group: deployment_group
|
||||||
)
|
)
|
||||||
|
|
@ -154,7 +165,15 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
|
||||||
|
|
||||||
slug = "marketing-report-#{Livebook.Utils.random_short_id()}"
|
slug = "marketing-report-#{Livebook.Utils.random_short_id()}"
|
||||||
context = change_to_user_session(context)
|
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)
|
change_to_agent_session(context)
|
||||||
wait_livebook_app_start(slug)
|
wait_livebook_app_start(slug)
|
||||||
|
|
@ -169,7 +188,6 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
|
||||||
assert render(view) =~ slug
|
assert render(view) =~ slug
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :tmp_dir
|
|
||||||
test "shows all apps if disable the authentication in real-time",
|
test "shows all apps if disable the authentication in real-time",
|
||||||
%{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do
|
%{conn: conn, node: node, code: code, tmp_dir: tmp_dir} = context do
|
||||||
{:ok, %{groups_auth: true} = deployment_group} =
|
{: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}}
|
assert_receive {:deployment_group_updated, %{id: ^id, groups_auth: true}}
|
||||||
|
|
||||||
oidc_provider = TeamsRPC.create_oidc_provider(node, context.org)
|
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 =
|
authorization_group =
|
||||||
TeamsRPC.create_authorization_group(node,
|
TeamsRPC.create_authorization_group(node,
|
||||||
group_name: "marketing",
|
group_name: "marketing",
|
||||||
access_type: :apps,
|
access_type: :apps,
|
||||||
prefixes: ["mkt-"],
|
app_folders: [app_folder],
|
||||||
oidc_provider: oidc_provider,
|
oidc_provider: oidc_provider,
|
||||||
deployment_group: deployment_group
|
deployment_group: deployment_group
|
||||||
)
|
)
|
||||||
|
|
@ -202,7 +222,16 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
|
||||||
|
|
||||||
slug = "marketing-app-#{Livebook.Utils.random_short_id()}"
|
slug = "marketing-app-#{Livebook.Utils.random_short_id()}"
|
||||||
context = change_to_user_session(context)
|
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)
|
change_to_agent_session(context)
|
||||||
wait_livebook_app_start(slug)
|
wait_livebook_app_start(slug)
|
||||||
|
|
@ -216,5 +245,273 @@ defmodule LivebookWeb.Integration.AppsLiveTest do
|
||||||
{:ok, view, _} = live(conn, ~p"/apps")
|
{:ok, view, _} = live(conn, ~p"/apps")
|
||||||
assert render(view) =~ slug
|
assert render(view) =~ slug
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2786,6 +2786,9 @@ defmodule LivebookWeb.SessionLiveTest do
|
||||||
|> element(~s/[data-el-app-info] a/, "Configure")
|
|> element(~s/[data-el-app-info] a/, "Configure")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
|
# doesn't show the app folder select
|
||||||
|
refute has_element?(view, ~s/#app-settings-modal input[name="app_folder_id"]/)
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element(~s/#app-settings-modal form/)
|
|> element(~s/#app-settings-modal form/)
|
||||||
|> render_change(%{"app_settings" => %{"slug" => slug}})
|
|> render_change(%{"app_settings" => %{"slug" => slug}})
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,25 @@ defmodule Livebook.AppHelpers do
|
||||||
end
|
end
|
||||||
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_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 =
|
source =
|
||||||
stamp_notebook(app_path, """
|
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
|
```elixir
|
||||||
IO.puts("Hi")
|
IO.puts("Hi")
|
||||||
|
|
|
||||||
|
|
@ -169,8 +169,12 @@ defmodule Livebook.TeamsRPC do
|
||||||
|
|
||||||
# Update resource
|
# Update resource
|
||||||
|
|
||||||
def update_authorization_group(node, authorization_group, attrs) do
|
def update_authorization_group(node, authorization_group, attrs, app_folders \\ []) do
|
||||||
:erpc.call(node, TeamsRPC, :update_authorization_group, [authorization_group, attrs])
|
:erpc.call(node, TeamsRPC, :update_authorization_group, [
|
||||||
|
authorization_group,
|
||||||
|
attrs,
|
||||||
|
app_folders
|
||||||
|
])
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_user_info_groups(node, code, groups) do
|
def update_user_info_groups(node, code, groups) do
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue