mirror of
				https://github.com/livebook-dev/livebook.git
				synced 2025-10-31 07:46:18 +08:00 
			
		
		
		
	
		
			
				
	
	
		
			343 lines
		
	
	
	
		
			12 KiB
		
	
	
	
		
			Elixir
		
	
	
	
	
	
			
		
		
	
	
			343 lines
		
	
	
	
		
			12 KiB
		
	
	
	
		
			Elixir
		
	
	
	
	
	
| defmodule LivebookWeb.AppsDashboardLive do
 | |
|   use LivebookWeb, :live_view
 | |
| 
 | |
|   import LivebookWeb.AppComponents
 | |
| 
 | |
|   alias LivebookWeb.LayoutComponents
 | |
| 
 | |
|   on_mount LivebookWeb.SidebarHook
 | |
| 
 | |
|   @impl true
 | |
|   def mount(_params, _session, socket) do
 | |
|     if connected?(socket) do
 | |
|       Livebook.Apps.subscribe()
 | |
|     end
 | |
| 
 | |
|     apps = Livebook.Apps.list_apps()
 | |
| 
 | |
|     {:ok, assign(socket, apps: apps, page_title: "Apps - Livebook")}
 | |
|   end
 | |
| 
 | |
|   @impl true
 | |
|   def render(assigns) do
 | |
|     ~H"""
 | |
|     <LayoutComponents.layout
 | |
|       current_page={~p"/apps-dashboard"}
 | |
|       current_user={@current_user}
 | |
|       teams_auth={@teams_auth}
 | |
|       saved_hubs={@saved_hubs}
 | |
|     >
 | |
|       <div class="space-y-2 p-4 md:px-12 md:py-7 max-w-screen-lg mx-auto">
 | |
|         <div class="flex items-center justify-between">
 | |
|           <LayoutComponents.title text="Local apps" />
 | |
|           <.link navigate={~p"/apps"} class="flex items-center text-blue-600">
 | |
|             <span class="font-semibold">Listing</span>
 | |
|             <.remix_icon icon="arrow-right-line" class="align-middle ml-1" />
 | |
|           </.link>
 | |
|         </div>
 | |
|         <p class="text-gray-700 text-sm">
 | |
|           An overview of all deployed applications and previews running on this instance.
 | |
|         </p>
 | |
|         <div class="pt-6">
 | |
|           <.app_list apps={@apps} />
 | |
|         </div>
 | |
|       </div>
 | |
|     </LayoutComponents.layout>
 | |
|     """
 | |
|   end
 | |
| 
 | |
|   defp app_list(%{apps: []} = assigns) do
 | |
|     ~H"""
 | |
|     <.no_entries>
 | |
|       You do not have any apps running. <br />
 | |
|       You can preview and deploy new apps by opening a notebook and clicking
 | |
|       <.remix_icon icon="rocket-line" class="align-top text-lg" /> in the sidebar.
 | |
|     </.no_entries>
 | |
|     """
 | |
|   end
 | |
| 
 | |
|   defp app_list(assigns) do
 | |
|     ~H"""
 | |
|     <div class="flex flex-col space-y-4">
 | |
|       <div :for={app <- Enum.sort_by(@apps, & &1.slug)} data-app-slug={app.slug}>
 | |
|         <a
 | |
|           phx-click={JS.toggle(to: "[data-app-slug=#{app.slug}] [data-toggle]")}
 | |
|           class="flex items-center justify-between mb-2 hover:cursor-pointer"
 | |
|         >
 | |
|           <span class="text-gray-800 font-medium text-xl break-all">
 | |
|             {"/" <> app.slug}
 | |
|           </span>
 | |
|           <div class="flex items-center gap-2">
 | |
|             <.app_group_tag app_spec={app.app_spec} />
 | |
|             <.remix_icon icon="arrow-down-s-line" class="text-xl text-gray-700" data-toggle />
 | |
|             <.remix_icon icon="arrow-right-s-line" class="text-xl text-gray-700 hidden" data-toggle />
 | |
|           </div>
 | |
|         </a>
 | |
|         <div data-toggle>
 | |
|           <div :if={app.warnings != []} class="my-3 flex flex-col gap-3">
 | |
|             <.message_box :for={warning <- app.warnings} kind="warning" message={warning} />
 | |
|           </div>
 | |
|           <div class="flex-col mb-8">
 | |
|             <div class="p-4 border-x border-t border-gray-200 rounded-t-lg ">
 | |
|               <div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-[minmax(0,_2fr)_minmax(0,_2fr)_minmax(0,_1fr)_minmax(0,_1fr)_minmax(0,_1fr)] gap-4">
 | |
|                 <div class="break-words">
 | |
|                   <.labeled_text label="Name">
 | |
|                     {app.notebook_name}
 | |
|                     <span
 | |
|                       :if={not app.public?}
 | |
|                       class="!inline tooltip top"
 | |
|                       data-tooltip="Password protected"
 | |
|                     >
 | |
|                       <.remix_icon icon="lock-line" />
 | |
|                     </span>
 | |
|                   </.labeled_text>
 | |
|                 </div>
 | |
|                 <div class="break-all">
 | |
|                   <.labeled_text label="URL">
 | |
|                     <a href={~p"/apps/#{app.slug}"}>
 | |
|                       {~p"/apps/#{app.slug}"}
 | |
|                     </a>
 | |
|                   </.labeled_text>
 | |
|                 </div>
 | |
|                 <div>
 | |
|                   <.labeled_text label="Latest version" one_line>
 | |
|                     v{app.version}
 | |
|                   </.labeled_text>
 | |
|                 </div>
 | |
|                 <div>
 | |
|                   <.labeled_text label="Session type" one_line>
 | |
|                     {if(app.multi_session, do: "Multi", else: "Single")}
 | |
|                   </.labeled_text>
 | |
|                 </div>
 | |
|                 <div class="flex flex-col md:flex-row md:items-center justify-start lg:justify-end">
 | |
|                   <%= if app.permanent do %>
 | |
|                     <span class="tooltip top" data-tooltip="Permanent apps cannot be terminated">
 | |
|                       <.icon_button disabled>
 | |
|                         <.remix_icon icon="delete-bin-6-line" />
 | |
|                       </.icon_button>
 | |
|                     </span>
 | |
|                   <% else %>
 | |
|                     <span class="tooltip top" data-tooltip="Terminate">
 | |
|                       <.icon_button
 | |
|                         aria-label="terminate app"
 | |
|                         phx-click={JS.push("terminate_app", value: %{slug: app.slug})}
 | |
|                       >
 | |
|                         <.remix_icon icon="delete-bin-6-line" />
 | |
|                       </.icon_button>
 | |
|                     </span>
 | |
|                   <% end %>
 | |
|                 </div>
 | |
|               </div>
 | |
|             </div>
 | |
|             <div class="border border-gray-200 rounded-b-lg overflow-auto tiny-scrollbar whitespace-none">
 | |
|               <%= if Enum.any?(app.sessions) do %>
 | |
|                 <div class="uppercase text-gray-500 text-sm font-medium leading-normal tracking-wider px-4 pt-4 pb-3">
 | |
|                   Running sessions
 | |
|                 </div>
 | |
|                 <.grid rows={app.sessions}>
 | |
|                   <:col :let={app_session} label="Status">
 | |
|                     <a
 | |
|                       aria-label="debug app"
 | |
|                       href={app_session.app_status == :error && ~p"/sessions/#{app_session.id}"}
 | |
|                       target="_blank"
 | |
|                     >
 | |
|                       <.app_status status={app_session.app_status} />
 | |
|                     </a>
 | |
|                   </:col>
 | |
|                   <:col :let={app_session} label="Uptime">
 | |
|                     {LivebookWeb.HTMLHelpers.format_datetime_relatively(app_session.created_at)}
 | |
|                   </:col>
 | |
|                   <:col :let={app_session} label="Version">
 | |
|                     v{app_session.version}
 | |
|                   </:col>
 | |
|                   <:col :let={app_session} label="Clients">
 | |
|                     {app_session.client_count}
 | |
|                   </:col>
 | |
|                   <:actions :let={app_session}>
 | |
|                     <span class="tooltip left" data-tooltip="Open">
 | |
|                       <.icon_button
 | |
|                         disabled={app_session.app_status.lifecycle != :active}
 | |
|                         aria-label="open app"
 | |
|                         href={~p"/apps/#{app.slug}/sessions/#{app_session.id}"}
 | |
|                       >
 | |
|                         <.remix_icon icon="link" />
 | |
|                       </.icon_button>
 | |
|                     </span>
 | |
|                     <span class="tooltip left" data-tooltip="Debug">
 | |
|                       <.icon_button aria-label="debug app" href={~p"/sessions/#{app_session.id}"}>
 | |
|                         <.remix_icon icon="terminal-line" />
 | |
|                       </.icon_button>
 | |
|                     </span>
 | |
|                     <%= if app_session.app_status.lifecycle == :active do %>
 | |
|                       <span class="tooltip left" data-tooltip="Deactivate">
 | |
|                         <.icon_button
 | |
|                           aria-label="deactivate app session"
 | |
|                           phx-click={
 | |
|                             JS.push("deactivate_app_session",
 | |
|                               value: %{slug: app.slug, session_id: app_session.id}
 | |
|                             )
 | |
|                           }
 | |
|                         >
 | |
|                           <.remix_icon icon="stop-circle-line" />
 | |
|                         </.icon_button>
 | |
|                       </span>
 | |
|                     <% else %>
 | |
|                       <span class="tooltip left" data-tooltip="Terminate">
 | |
|                         <.icon_button
 | |
|                           aria-label="terminate app session"
 | |
|                           phx-click={
 | |
|                             JS.push("terminate_app_session",
 | |
|                               value: %{slug: app.slug, session_id: app_session.id}
 | |
|                             )
 | |
|                           }
 | |
|                         >
 | |
|                           <.remix_icon icon="delete-bin-6-line" />
 | |
|                         </.icon_button>
 | |
|                       </span>
 | |
|                     <% end %>
 | |
|                   </:actions>
 | |
|                 </.grid>
 | |
|               <% else %>
 | |
|                 <div class="p-4 uppercase text-gray-500 text-sm font-medium leading-normal tracking-wider">
 | |
|                   No running sessions
 | |
|                 </div>
 | |
|               <% end %>
 | |
|             </div>
 | |
|           </div>
 | |
|         </div>
 | |
|       </div>
 | |
|     </div>
 | |
|     """
 | |
|   end
 | |
| 
 | |
|   defp app_group_tag(%{app_spec: %Livebook.Apps.PreviewAppSpec{}} = assigns) do
 | |
|     ~H"""
 | |
|     <span
 | |
|       class="tooltip top"
 | |
|       data-tooltip={
 | |
|         ~S'''
 | |
|         This is an app preview, it has
 | |
|         been started manually
 | |
|         '''
 | |
|       }
 | |
|     >
 | |
|       <span class="font-medium bg-gray-100 text-gray-800 text-xs px-2.5 py-0.5 rounded cursor-default">
 | |
|         Preview
 | |
|       </span>
 | |
|     </span>
 | |
|     """
 | |
|   end
 | |
| 
 | |
|   defp app_group_tag(%{app_spec: %Livebook.Apps.PathAppSpec{}} = assigns) do
 | |
|     ~H"""
 | |
|     <span
 | |
|       class="tooltip top"
 | |
|       data-tooltip={
 | |
|         ~S'''
 | |
|         This is a permanent app started
 | |
|         from LIVEBOOK_APPS_PATH
 | |
|         '''
 | |
|       }
 | |
|     >
 | |
|       <span class="font-medium bg-blue-100 text-blue-800 text-xs px-2.5 py-0.5 rounded cursor-default">
 | |
|         Apps directory
 | |
|       </span>
 | |
|     </span>
 | |
|     """
 | |
|   end
 | |
| 
 | |
|   defp app_group_tag(%{app_spec: %Livebook.Apps.TeamsAppSpec{}} = assigns) do
 | |
|     ~H"""
 | |
|     <span
 | |
|       class="tooltip top"
 | |
|       data-tooltip={
 | |
|         ~S'''
 | |
|         This is a permanent app started
 | |
|         deployed via Livebook Teams
 | |
|         '''
 | |
|       }
 | |
|     >
 | |
|       <span class="font-medium bg-green-100 text-green-800 text-xs px-2.5 py-0.5 rounded cursor-default">
 | |
|         Livebook Teams
 | |
|       </span>
 | |
|     </span>
 | |
|     """
 | |
|   end
 | |
| 
 | |
|   defp app_group_tag(assigns), do: ~H""
 | |
| 
 | |
|   defp grid(assigns) do
 | |
|     ~H"""
 | |
|     <div class="min-w-[650px]">
 | |
|       <div class="px-2 pb-2">
 | |
|         <div class="grid grid-cols-[minmax(0,_0.5fr)_minmax(0,_0.75fr)_minmax(0,_0.5fr)_minmax(0,_0.5fr)_minmax(0,_0.5fr)] md:grid-cols-[minmax(0,_2fr)_minmax(0,_2fr)_minmax(0,_1fr)_minmax(0,_1fr)_minmax(0,_1fr)] gap-4 px-2">
 | |
|           <div
 | |
|             :for={col <- @col}
 | |
|             class={[
 | |
|               "text-gray-500 text-sm font-normal flex items-center",
 | |
|               align_to_class(col[:align])
 | |
|             ]}
 | |
|           >
 | |
|             {col[:label]}
 | |
|           </div>
 | |
|         </div>
 | |
|         <div :for={row <- @rows} class="whitespace-nowrap px-2 hover:bg-gray-50 hover:rounded-md">
 | |
|           <div class="grid grid-cols-[minmax(0,_0.5fr)_minmax(0,_0.75fr)_minmax(0,_0.5fr)_minmax(0,_0.5fr)_minmax(0,_0.5fr)] md:grid-cols-[minmax(0,_2fr)_minmax(0,_2fr)_minmax(0,_1fr)_minmax(0,_1fr)_minmax(0,_1fr)] gap-4">
 | |
|             <div
 | |
|               :for={col <- @col}
 | |
|               class={[
 | |
|                 "py-2 text-gray-800 text-sm font-semibold flex items-center",
 | |
|                 align_to_class(col[:align])
 | |
|               ]}
 | |
|             >
 | |
|               {render_slot(col, row)}
 | |
|             </div>
 | |
|             <div class="py-2 flex flex-row items-center justify-end gap-2">
 | |
|               {render_slot(@actions, row)}
 | |
|             </div>
 | |
|           </div>
 | |
|         </div>
 | |
|       </div>
 | |
|     </div>
 | |
|     """
 | |
|   end
 | |
| 
 | |
|   defp align_to_class(:right), do: "text-right"
 | |
|   defp align_to_class(:center), do: "text-center"
 | |
|   defp align_to_class(_), do: "text-left"
 | |
| 
 | |
|   @impl true
 | |
|   def handle_info({type, _app} = event, socket)
 | |
|       when type in [:app_created, :app_updated, :app_closed] do
 | |
|     {:noreply, update(socket, :apps, &update_app_list(&1, event))}
 | |
|   end
 | |
| 
 | |
|   def handle_info(_message, socket), do: {:noreply, socket}
 | |
| 
 | |
|   @impl true
 | |
|   def handle_event("terminate_app", %{"slug" => slug}, socket) do
 | |
|     app = Enum.find(socket.assigns.apps, &(&1.slug == slug))
 | |
|     {:noreply, confirm_app_termination(socket, app.pid)}
 | |
|   end
 | |
| 
 | |
|   def handle_event("terminate_app_session", %{"slug" => slug, "session_id" => session_id}, socket) do
 | |
|     app_session = find_app_session(socket.assigns.apps, slug, session_id)
 | |
|     Livebook.Session.close(app_session.pid)
 | |
|     {:noreply, socket}
 | |
|   end
 | |
| 
 | |
|   def handle_event(
 | |
|         "deactivate_app_session",
 | |
|         %{"slug" => slug, "session_id" => session_id},
 | |
|         socket
 | |
|       ) do
 | |
|     app_session = find_app_session(socket.assigns.apps, slug, session_id)
 | |
|     Livebook.Session.app_deactivate(app_session.pid)
 | |
|     {:noreply, socket}
 | |
|   end
 | |
| 
 | |
|   defp find_app_session(apps, slug, session_id) do
 | |
|     app = Enum.find(apps, &(&1.slug == slug))
 | |
|     Enum.find(app.sessions, &(&1.id == session_id))
 | |
|   end
 | |
| end
 |