diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx index 8739d0e9..673bafb3 100644 --- a/ui/src/components/LayoutShell.tsx +++ b/ui/src/components/LayoutShell.tsx @@ -1,22 +1,40 @@ import { Outlet, useNavigate, useLocation } from 'react-router'; -import { AppShell, Sidebar, TopBar, CommandPalette, CommandPaletteProvider, GlobalFilterProvider, ToastProvider, BreadcrumbProvider, useCommandPalette, useGlobalFilters } from '@cameleer/design-system'; -import type { SidebarApp, SearchResult } from '@cameleer/design-system'; +import { + AppShell, + Sidebar, + SidebarTree, + StatusDot, + TopBar, + CommandPalette, + CommandPaletteProvider, + GlobalFilterProvider, + ToastProvider, + BreadcrumbProvider, + useCommandPalette, + useGlobalFilters, + useStarred, +} from '@cameleer/design-system'; +import type { SearchResult, SidebarTreeNode } from '@cameleer/design-system'; +import { Box, Cpu, GitBranch, Settings, FileText, ChevronRight } from 'lucide-react'; import { useRouteCatalog } from '../api/queries/catalog'; import { useAgents } from '../api/queries/agents'; import { useSearchExecutions, useAttributeKeys } from '../api/queries/executions'; import { useAuthStore } from '../auth/auth-store'; -import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; +import { useState, useMemo, useCallback, useEffect, useRef, createElement } from 'react'; import { ContentTabs } from './ContentTabs'; import { useScope } from '../hooks/useScope'; +import { + buildAppTreeNodes, + buildAgentTreeNodes, + buildAdminTreeNodes, + readCollapsed, + writeCollapsed, +} from './sidebar-utils'; +import type { SidebarApp } from './sidebar-utils'; -function healthToColor(health: string): string { - switch (health) { - case 'live': return 'success'; - case 'stale': return 'warning'; - case 'dead': return 'error'; - default: return 'auto'; - } -} +/* ------------------------------------------------------------------ */ +/* Search data builder (unchanged) */ +/* ------------------------------------------------------------------ */ function buildSearchData( catalog: any[] | undefined, @@ -32,7 +50,7 @@ function buildSearchData( id: app.appId, category: 'application', title: app.appId, - badges: [{ label: (app.health || 'unknown').toUpperCase(), color: healthToColor(app.health) }], + badges: [{ label: (app.health || 'unknown').toUpperCase(), color: healthToSearchColor(app.health) }], meta: `${(app.routes || []).length} routes · ${(app.agents || []).length} agents (${liveAgents} live) · ${(app.exchangeCount ?? 0).toLocaleString()} exchanges`, path: `/exchanges/${app.appId}`, }); @@ -55,7 +73,7 @@ function buildSearchData( id: agent.instanceId, category: 'agent', title: agent.displayName, - badges: [{ label: (agent.status || 'unknown').toUpperCase(), color: healthToColor((agent.status || '').toLowerCase()) }], + badges: [{ label: (agent.status || 'unknown').toUpperCase(), color: healthToSearchColor((agent.status || '').toLowerCase()) }], meta: `${agent.applicationId} · ${agent.version || ''}${agent.tps != null ? ` · ${agent.tps.toFixed(1)} msg/s` : ''}`, path: `/runtime/${agent.applicationId}/${agent.instanceId}`, }); @@ -76,6 +94,15 @@ function buildSearchData( return results; } +function healthToSearchColor(health: string): string { + switch (health) { + case 'live': return 'success'; + case 'stale': return 'warning'; + case 'dead': return 'error'; + default: return 'auto'; + } +} + function formatDuration(ms: number): string { if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`; if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`; @@ -100,6 +127,31 @@ function useDebouncedValue(value: T, delayMs: number): T { return debounced; } +/* ------------------------------------------------------------------ */ +/* Icon factories for tree builders */ +/* ------------------------------------------------------------------ */ + +function makeStatusDot(health: string) { + return createElement(StatusDot, { variant: health as any }); +} + +function makeChevron() { + return createElement(ChevronRight, { size: 14 }); +} + +/* ------------------------------------------------------------------ */ +/* Section open-state keys */ +/* ------------------------------------------------------------------ */ + +const SK_APPS = 'sidebar:section:apps'; +const SK_AGENTS = 'sidebar:section:agents'; +const SK_ADMIN = 'sidebar:section:admin'; +const SK_COLLAPSED = 'sidebar:collapsed'; + +/* ------------------------------------------------------------------ */ +/* Main layout content */ +/* ------------------------------------------------------------------ */ + function LayoutContent() { const navigate = useNavigate(); const location = useLocation(); @@ -111,20 +163,86 @@ function LayoutContent() { const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette(); const { scope, setTab } = useScope(); - // Exchange full-text search via command palette (scoped to current sidebar selection) - const [paletteQuery, setPaletteQuery] = useState(''); - const debouncedQuery = useDebouncedValue(paletteQuery, 300); - const { data: exchangeResults } = useSearchExecutions( - { - text: debouncedQuery || undefined, - applicationId: scope.appId || undefined, - routeId: scope.routeId || undefined, - offset: 0, - limit: 10, - }, - false, - ); + // --- Starred items ------------------------------------------------ + const { isStarred, toggleStar } = useStarred(); + // --- Sidebar collapse --------------------------------------------- + const [sidebarCollapsed, setSidebarCollapsed] = useState(() => readCollapsed(SK_COLLAPSED, false)); + const handleCollapseToggle = useCallback(() => { + setSidebarCollapsed((prev) => { + writeCollapsed(SK_COLLAPSED, !prev); + return !prev; + }); + }, []); + + // --- Sidebar filter ----------------------------------------------- + const [filterQuery, setFilterQuery] = useState(''); + + // --- Section open states ------------------------------------------ + const [appsOpen, setAppsOpen] = useState(() => readCollapsed(SK_APPS, true)); + const [agentsOpen, setAgentsOpen] = useState(() => readCollapsed(SK_AGENTS, false)); + const [adminOpen, setAdminOpen] = useState(() => readCollapsed(SK_ADMIN, false)); + + // Ref to remember operational section states when switching to admin + const opsStateRef = useRef({ apps: appsOpen, agents: agentsOpen }); + + const isAdminPage = location.pathname.startsWith('/admin'); + + // Accordion effect: when entering admin, collapse operational sections; when leaving, restore + const prevAdminRef = useRef(isAdminPage); + useEffect(() => { + if (isAdminPage && !prevAdminRef.current) { + // Entering admin — save operational states and collapse them + opsStateRef.current = { apps: appsOpen, agents: agentsOpen }; + setAppsOpen(false); + setAgentsOpen(false); + setAdminOpen(true); + writeCollapsed(SK_APPS, false); + writeCollapsed(SK_AGENTS, false); + writeCollapsed(SK_ADMIN, true); + } else if (!isAdminPage && prevAdminRef.current) { + // Leaving admin — restore operational states + setAppsOpen(opsStateRef.current.apps); + setAgentsOpen(opsStateRef.current.agents); + setAdminOpen(false); + writeCollapsed(SK_APPS, opsStateRef.current.apps); + writeCollapsed(SK_AGENTS, opsStateRef.current.agents); + writeCollapsed(SK_ADMIN, false); + } + prevAdminRef.current = isAdminPage; + }, [isAdminPage]); // eslint-disable-line react-hooks/exhaustive-deps + + const toggleApps = useCallback(() => { + if (isAdminPage) { + // Clicking operational section while in admin navigates away + navigate('/exchanges'); + return; + } + setAppsOpen((prev) => { + writeCollapsed(SK_APPS, !prev); + return !prev; + }); + }, [isAdminPage, navigate]); + + const toggleAgents = useCallback(() => { + if (isAdminPage) { + navigate('/exchanges'); + return; + } + setAgentsOpen((prev) => { + writeCollapsed(SK_AGENTS, !prev); + return !prev; + }); + }, [isAdminPage, navigate]); + + const toggleAdmin = useCallback(() => { + setAdminOpen((prev) => { + writeCollapsed(SK_ADMIN, !prev); + return !prev; + }); + }, []); + + // --- Build SidebarApp[] from catalog ------------------------------ const sidebarApps: SidebarApp[] = useMemo(() => { if (!catalog) return []; const cmp = (a: string, b: string) => a.localeCompare(b); @@ -153,13 +271,45 @@ function LayoutContent() { })); }, [catalog]); + // --- Tree nodes --------------------------------------------------- + const appTreeNodes: SidebarTreeNode[] = useMemo( + () => buildAppTreeNodes(sidebarApps, makeStatusDot, makeChevron), + [sidebarApps], + ); + + const agentTreeNodes: SidebarTreeNode[] = useMemo( + () => buildAgentTreeNodes(sidebarApps, makeStatusDot), + [sidebarApps], + ); + + const adminTreeNodes: SidebarTreeNode[] = useMemo( + () => buildAdminTreeNodes(), + [], + ); + + // --- Reveal path for SidebarTree auto-expand ---------------------- + const sidebarRevealPath = (location.state as any)?.sidebarReveal ?? null; + + // --- Exchange full-text search via command palette ----------------- + const [paletteQuery, setPaletteQuery] = useState(''); + const debouncedQuery = useDebouncedValue(paletteQuery, 300); + const { data: exchangeResults } = useSearchExecutions( + { + text: debouncedQuery || undefined, + applicationId: scope.appId || undefined, + routeId: scope.routeId || undefined, + offset: 0, + limit: 10, + }, + false, + ); + const catalogData = useMemo( () => buildSearchData(catalog, agents as any[], attributeKeys), [catalog, agents, attributeKeys], ); - // Stable reference for catalog data — only changes when catalog/agents actually change, - // not on every poll cycle (prevents cmd-k scroll reset) + // Stable reference for catalog data const catalogRef = useRef(catalogData); if (catalogData !== catalogRef.current && JSON.stringify(catalogData) !== JSON.stringify(catalogRef.current)) { catalogRef.current = catalogData; @@ -201,7 +351,7 @@ function LayoutContent() { return [...catalogRef.current, ...exchangeItems, ...attributeItems]; }, [catalogRef.current, exchangeResults, debouncedQuery]); - const isAdminPage = location.pathname.startsWith('/admin'); + // --- Breadcrumb --------------------------------------------------- const breadcrumb = useMemo(() => { if (isAdminPage) { const LABELS: Record = { @@ -219,7 +369,6 @@ function LayoutContent() { ...(i < parts.length - 1 ? { href: '/' + parts.slice(0, i + 1).join('/') } : {}), })); } - // Scope trail as breadcrumb items const items: { label: string; href?: string }[] = [ { label: 'All Applications', href: `/${scope.tab}` }, ]; @@ -229,13 +378,13 @@ function LayoutContent() { if (scope.routeId) { items.push({ label: scope.routeId }); } - // Last item has no href (current location) if (items.length > 0 && !scope.routeId && !scope.appId) { delete items[items.length - 1].href; } return items; }, [location.pathname, isAdminPage, scope.tab, scope.appId, scope.routeId]); + // --- Callbacks ---------------------------------------------------- const handleLogout = useCallback(() => { logout(); navigate('/login'); @@ -245,7 +394,6 @@ function LayoutContent() { if (result.path) { const state: Record = { sidebarReveal: result.path }; - // For exchange/attribute results, pass selectedExchange in state if (result.category === 'exchange' || result.category === 'attribute') { const parts = result.path.split('/').filter(Boolean); if (parts.length === 4 && parts[0] === 'exchanges') { @@ -270,11 +418,10 @@ function LayoutContent() { }, [navigate, scope.appId, scope.routeId]); // Translate Sidebar's internal paths to our URL structure. - // Pass sidebarReveal state so the DS Sidebar can highlight the clicked entry. const handleSidebarNavigate = useCallback((path: string) => { const state = { sidebarReveal: path }; - // /apps/:appId and /apps/:appId/:routeId → current tab + // /apps/:appId and /apps/:appId/:routeId -> current tab const appMatch = path.match(/^\/apps\/([^/]+)(?:\/(.+))?$/); if (appMatch) { const [, sAppId, sRouteId] = appMatch; @@ -282,7 +429,7 @@ function LayoutContent() { return; } - // /agents/:appId/:instanceId → runtime tab + // /agents/:appId/:instanceId -> runtime tab const agentMatch = path.match(/^\/agents\/([^/]+)(?:\/(.+))?$/); if (agentMatch) { const [, sAppId, sInstanceId] = agentMatch; @@ -293,12 +440,111 @@ function LayoutContent() { navigate(path, { state }); }, [navigate, scope.tab]); - return ( - - } + // --- Render ------------------------------------------------------- + const sidebarElement = ( + + + + {/* When on admin pages, show Admin section first (expanded) */} + {isAdminPage && ( + + + + )} + + + + + + + + + + {/* When NOT on admin pages, show Admin section at bottom */} + {!isAdminPage && ( + + + + )} + + + handleSidebarNavigate('/api-docs')} + /> + + + ); + + return ( +