diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx index cd4dbd90..01f36eb3 100644 --- a/ui/src/components/LayoutShell.tsx +++ b/ui/src/components/LayoutShell.tsx @@ -15,26 +15,26 @@ import { useStarred, } from '@cameleer/design-system'; import type { SearchResult, SidebarTreeNode } from '@cameleer/design-system'; -import { Box, Cpu, GitBranch, Settings, FileText, ChevronRight, Square, Pause } from 'lucide-react'; +import { Box, Settings, FileText, ChevronRight, Square, Pause, Star, X } 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, createElement } from 'react'; +import type { ReactNode } from 'react'; import { ContentTabs } from './ContentTabs'; import { useScope } from '../hooks/useScope'; import { buildAppTreeNodes, - buildAgentTreeNodes, - buildRouteTreeNodes, buildAdminTreeNodes, + formatCount, readCollapsed, writeCollapsed, } from './sidebar-utils'; import type { SidebarApp } from './sidebar-utils'; /* ------------------------------------------------------------------ */ -/* Search data builder (unchanged) */ +/* Search data builder */ /* ------------------------------------------------------------------ */ function buildSearchData( @@ -129,7 +129,7 @@ function useDebouncedValue(value: T, delayMs: number): T { } /* ------------------------------------------------------------------ */ -/* Icon factories for tree builders */ +/* Icon factories */ /* ------------------------------------------------------------------ */ function makeStatusDot(health: string) { @@ -149,12 +149,69 @@ function makePauseIcon() { } /* ------------------------------------------------------------------ */ -/* Section open-state keys */ +/* Starred items */ +/* ------------------------------------------------------------------ */ + +interface StarredItem { + starKey: string; + label: string; + icon?: ReactNode; + path: string; + parentApp?: string; +} + +function collectStarredItems(apps: SidebarApp[], starredIds: Set): StarredItem[] { + const items: StarredItem[] = []; + for (const app of apps) { + if (starredIds.has(`app:${app.id}`)) { + items.push({ starKey: `app:${app.id}`, label: app.name, icon: makeStatusDot(app.health), path: `/apps/${app.id}` }); + } + for (const route of app.routes) { + const key = `app:${app.id}/${route.id}`; + if (starredIds.has(key)) { + items.push({ starKey: key, label: route.name, path: `/apps/${app.id}/${route.id}`, parentApp: app.name }); + } + } + } + return items; +} + +function StarredList({ items, onNavigate, onRemove }: { items: StarredItem[]; onNavigate: (path: string) => void; onRemove: (key: string) => void }) { + if (items.length === 0) return null; + return ( +
+ {items.map((item) => ( +
onNavigate(item.path)} + role="button" + tabIndex={0} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onNavigate(item.path); }} + > + {item.icon && {item.icon}} + + {item.label} + {item.parentApp && {item.parentApp}} + + +
+ ))} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Section state keys */ /* ------------------------------------------------------------------ */ const SK_APPS = 'sidebar:section:apps'; -const SK_AGENTS = 'sidebar:section:agents'; -const SK_ROUTES = 'sidebar:section:routes'; const SK_ADMIN = 'sidebar:section:admin'; const SK_COLLAPSED = 'sidebar:collapsed'; @@ -174,7 +231,7 @@ function LayoutContent() { const { scope, setTab } = useScope(); // --- Starred items ------------------------------------------------ - const { isStarred, toggleStar } = useStarred(); + const { starredIds, isStarred, toggleStar } = useStarred(); // --- Sidebar collapse --------------------------------------------- const [sidebarCollapsed, setSidebarCollapsed] = useState(() => readCollapsed(SK_COLLAPSED, false)); @@ -191,43 +248,28 @@ function LayoutContent() { // --- Section open states ------------------------------------------ const isAdminPage = location.pathname.startsWith('/admin'); const [appsOpen, setAppsOpen] = useState(() => isAdminPage ? false : readCollapsed(SK_APPS, true)); - const [agentsOpen, setAgentsOpen] = useState(() => isAdminPage ? false : readCollapsed(SK_AGENTS, false)); - const [routesOpen, setRoutesOpen] = useState(() => isAdminPage ? false : readCollapsed(SK_ROUTES, false)); const [adminOpen, setAdminOpen] = useState(() => isAdminPage ? true : readCollapsed(SK_ADMIN, false)); + const [starredOpen, setStarredOpen] = useState(true); - // Ref to remember operational section states when switching to admin - const opsStateRef = useRef({ apps: appsOpen, agents: agentsOpen, routes: routesOpen }); - - // Accordion effect: when entering admin, collapse operational sections; when leaving, restore + // Accordion: entering admin collapses apps + starred; leaving restores + const opsStateRef = useRef({ apps: appsOpen, starred: starredOpen }); const prevAdminRef = useRef(isAdminPage); useEffect(() => { if (isAdminPage && !prevAdminRef.current) { - // Entering admin — save operational states and collapse them - opsStateRef.current = { apps: appsOpen, agents: agentsOpen, routes: routesOpen }; + opsStateRef.current = { apps: appsOpen, starred: starredOpen }; setAppsOpen(false); - setAgentsOpen(false); - setRoutesOpen(false); + setStarredOpen(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); - setRoutesOpen(opsStateRef.current.routes); + setStarredOpen(opsStateRef.current.starred); setAdminOpen(false); - writeCollapsed(SK_APPS, opsStateRef.current.apps); - writeCollapsed(SK_AGENTS, opsStateRef.current.agents); - writeCollapsed(SK_ROUTES, opsStateRef.current.routes); - 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; } @@ -237,34 +279,16 @@ function LayoutContent() { }); }, [isAdminPage, navigate]); - const toggleAgents = useCallback(() => { - if (isAdminPage) { - navigate('/exchanges'); - return; - } - setAgentsOpen((prev) => { - writeCollapsed(SK_AGENTS, !prev); - return !prev; - }); - }, [isAdminPage, navigate]); - - const toggleRoutes = useCallback(() => { - if (isAdminPage) { - navigate('/exchanges'); - return; - } - setRoutesOpen((prev) => { - writeCollapsed(SK_ROUTES, !prev); - return !prev; - }); - }, [isAdminPage, navigate]); - const toggleAdmin = useCallback(() => { + if (!isAdminPage) { + navigate('/admin/rbac'); + return; + } setAdminOpen((prev) => { writeCollapsed(SK_ADMIN, !prev); return !prev; }); - }, []); + }, [isAdminPage, navigate]); // --- Build SidebarApp[] from catalog ------------------------------ const sidebarApps: SidebarApp[] = useMemo(() => { @@ -285,14 +309,7 @@ function LayoutContent() { exchangeCount: r.exchangeCount, routeState: r.routeState ?? undefined, })), - agents: [...(app.agents || [])] - .sort((a: any, b: any) => cmp(a.name, b.name)) - .map((a: any) => ({ - id: a.id, - name: a.name, - status: a.status as 'live' | 'stale' | 'dead', - tps: a.tps, - })), + agents: [], })); }, [catalog]); @@ -302,24 +319,28 @@ function LayoutContent() { [sidebarApps], ); - const agentTreeNodes: SidebarTreeNode[] = useMemo( - () => buildAgentTreeNodes(sidebarApps, makeStatusDot), - [sidebarApps], - ); - - const routeTreeNodes: SidebarTreeNode[] = useMemo( - () => buildRouteTreeNodes(sidebarApps, makeStatusDot, makeChevron, makeStopIcon, makePauseIcon), - [sidebarApps], - ); - const adminTreeNodes: SidebarTreeNode[] = useMemo( () => buildAdminTreeNodes(), [], ); + // --- Starred items ------------------------------------------------ + const starredItems = useMemo( + () => collectStarredItems(sidebarApps, starredIds), + [sidebarApps, starredIds], + ); + // --- Reveal path for SidebarTree auto-expand ---------------------- const sidebarRevealPath = (location.state as any)?.sidebarReveal ?? null; + useEffect(() => { + if (!sidebarRevealPath) return; + if (sidebarRevealPath.startsWith('/apps') && !appsOpen) setAppsOpen(true); + if (sidebarRevealPath.startsWith('/admin') && !adminOpen) setAdminOpen(true); + }, [sidebarRevealPath]); // eslint-disable-line react-hooks/exhaustive-deps + + const effectiveSelectedPath = sidebarRevealPath ?? location.pathname; + // --- Exchange full-text search via command palette ----------------- const [paletteQuery, setPaletteQuery] = useState(''); const debouncedQuery = useDebouncedValue(paletteQuery, 300); @@ -339,7 +360,6 @@ function LayoutContent() { [catalog, agents, attributeKeys], ); - // Stable reference for catalog data const catalogRef = useRef(catalogData); if (catalogData !== catalogRef.current && JSON.stringify(catalogData) !== JSON.stringify(catalogRef.current)) { catalogRef.current = catalogData; @@ -423,7 +443,6 @@ function LayoutContent() { const handlePaletteSelect = useCallback((result: any) => { if (result.path) { const state: Record = { sidebarReveal: result.path }; - if (result.category === 'exchange' || result.category === 'attribute') { const parts = result.path.split('/').filter(Boolean); if (parts.length === 4 && parts[0] === 'exchanges') { @@ -434,7 +453,6 @@ function LayoutContent() { }; } } - navigate(result.path, { state }); } setPaletteOpen(false); @@ -447,11 +465,9 @@ function LayoutContent() { navigate(`${baseParts.join('/')}?text=${encodeURIComponent(query)}`); }, [navigate, scope.appId, scope.routeId]); - // Translate Sidebar's internal paths to our URL structure. const handleSidebarNavigate = useCallback((path: string) => { const state = { sidebarReveal: path }; - // /apps/:appId and /apps/:appId/:routeId -> current tab const appMatch = path.match(/^\/apps\/([^/]+)(?:\/(.+))?$/); if (appMatch) { const [, sAppId, sRouteId] = appMatch; @@ -459,7 +475,6 @@ function LayoutContent() { return; } - // /agents/:appId/:instanceId -> runtime tab const agentMatch = path.match(/^\/agents\/([^/]+)(?:\/(.+))?$/); if (agentMatch) { const [, sAppId, sInstanceId] = agentMatch; @@ -471,6 +486,10 @@ function LayoutContent() { }, [navigate, scope.tab]); // --- Render ------------------------------------------------------- + const camelLogo = ( + + ); + const sidebarElement = ( - {/* When on admin pages, show Admin section first (expanded) */} + {/* Admin section — shown at top when on admin pages */} {isAdminPage && ( )} + {/* Applications section */} - - - - - - - - - {/* When NOT on admin pages, show Admin section at bottom */} - {!isAdminPage && ( + {/* Starred section — only when there are starred items */} + {starredItems.length > 0 && ( setStarredOpen((v) => !v)} > - )} + {/* Footer — Admin + API Docs */} + {!isAdminPage && ( + navigate('/admin/rbac')} + /> + )} ReactNode, -): SidebarTreeNode[] { - return apps.map((app) => { - const liveCount = app.agents.filter((a) => a.status === 'live').length; - return { - id: `agent:${app.id}`, - label: app.name, - icon: statusDot(app.health), - badge: `${liveCount}/${app.agents.length} live`, - path: `/agents/${app.id}`, - children: app.agents.map((a) => ({ - id: `agent:${app.id}/${a.id}`, - label: a.name, - icon: statusDot(a.status), - badge: a.tps != null ? `${a.tps.toFixed(1)} msg/s` : undefined, - path: `/agents/${app.id}/${a.id}`, - })), - }; - }); -} - -/** - * Routes stats tree — one node per app, routes as children. - * Paths: /routes/{appId}, /routes/{appId}/{routeId} - */ -export function buildRouteTreeNodes( - apps: SidebarApp[], - statusDot: (health: string) => ReactNode, - chevron: () => ReactNode, - stopIcon?: () => ReactNode, - pauseIcon?: () => ReactNode, -): SidebarTreeNode[] { - return apps.map((app) => ({ - id: `route:${app.id}`, - label: app.name, - icon: statusDot(app.health), - badge: `${app.routes.length} routes`, - path: `/routes/${app.id}`, - children: app.routes.map((r) => ({ - id: `route:${app.id}/${r.id}`, - label: r.name, - icon: r.routeState === 'stopped' && stopIcon - ? stopIcon() - : r.routeState === 'suspended' && pauseIcon - ? pauseIcon() - : chevron(), - badge: r.routeState - ? `${r.routeState.toUpperCase()} \u00b7 ${formatCount(r.exchangeCount)}` - : formatCount(r.exchangeCount), - path: `/routes/${app.id}/${r.id}`, - })), - })); -} - /** * Admin tree — static 6 nodes. */