import { Outlet, useNavigate, useLocation } from 'react-router'; 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, createElement } from 'react'; import { ContentTabs } from './ContentTabs'; import { useScope } from '../hooks/useScope'; import { buildAppTreeNodes, buildAgentTreeNodes, buildRouteTreeNodes, buildAdminTreeNodes, readCollapsed, writeCollapsed, } from './sidebar-utils'; import type { SidebarApp } from './sidebar-utils'; /* ------------------------------------------------------------------ */ /* Search data builder (unchanged) */ /* ------------------------------------------------------------------ */ function buildSearchData( catalog: any[] | undefined, agents: any[] | undefined, attrKeys: string[] | undefined, ): SearchResult[] { if (!catalog) return []; const results: SearchResult[] = []; for (const app of catalog) { const liveAgents = (app.agents || []).filter((a: any) => a.status === 'live').length; results.push({ id: app.appId, category: 'application', title: app.appId, 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}`, }); for (const route of (app.routes || [])) { results.push({ id: `${app.appId}/${route.routeId}`, category: 'route', title: route.routeId, badges: [{ label: app.appId }], meta: `${(route.exchangeCount ?? 0).toLocaleString()} exchanges`, path: `/exchanges/${app.appId}/${route.routeId}`, }); } } if (agents) { for (const agent of agents) { results.push({ id: agent.instanceId, category: 'agent', title: agent.displayName, 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}`, }); } } if (attrKeys) { for (const key of attrKeys) { results.push({ id: `attr-key-${key}`, category: 'attribute', title: key, meta: 'attribute key', }); } } 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`; return `${ms}ms`; } function statusToColor(status: string): string { switch (status) { case 'COMPLETED': return 'success'; case 'FAILED': return 'error'; case 'RUNNING': return 'running'; default: return 'warning'; } } function useDebouncedValue(value: T, delayMs: number): T { const [debounced, setDebounced] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebounced(value), delayMs); return () => clearTimeout(timer); }, [value, delayMs]); 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_ROUTES = 'sidebar:section:routes'; const SK_ADMIN = 'sidebar:section:admin'; const SK_COLLAPSED = 'sidebar:collapsed'; /* ------------------------------------------------------------------ */ /* Main layout content */ /* ------------------------------------------------------------------ */ function LayoutContent() { const navigate = useNavigate(); const location = useLocation(); const { timeRange } = useGlobalFilters(); const { data: catalog } = useRouteCatalog(timeRange.start.toISOString(), timeRange.end.toISOString()); const { data: agents } = useAgents(); const { data: attributeKeys } = useAttributeKeys(); const { username, logout } = useAuthStore(); const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette(); const { scope, setTab } = useScope(); // --- 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 [routesOpen, setRoutesOpen] = useState(() => readCollapsed(SK_ROUTES, 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, routes: routesOpen }); 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, routes: routesOpen }; setAppsOpen(false); setAgentsOpen(false); setRoutesOpen(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); 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; } 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 toggleRoutes = useCallback(() => { if (isAdminPage) { navigate('/exchanges'); return; } setRoutesOpen((prev) => { writeCollapsed(SK_ROUTES, !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); return [...catalog] .sort((a: any, b: any) => cmp(a.appId, b.appId)) .map((app: any) => ({ id: app.appId, name: app.appId, health: app.health as 'live' | 'stale' | 'dead', exchangeCount: app.exchangeCount, routes: [...(app.routes || [])] .sort((a: any, b: any) => cmp(a.routeId, b.routeId)) .map((r: any) => ({ id: r.routeId, name: r.routeId, exchangeCount: r.exchangeCount, })), 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, })), })); }, [catalog]); // --- Tree nodes --------------------------------------------------- const appTreeNodes: SidebarTreeNode[] = useMemo( () => buildAppTreeNodes(sidebarApps, makeStatusDot, makeChevron), [sidebarApps], ); const agentTreeNodes: SidebarTreeNode[] = useMemo( () => buildAgentTreeNodes(sidebarApps, makeStatusDot), [sidebarApps], ); const routeTreeNodes: SidebarTreeNode[] = useMemo( () => buildRouteTreeNodes(sidebarApps, makeStatusDot, makeChevron), [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 const catalogRef = useRef(catalogData); if (catalogData !== catalogRef.current && JSON.stringify(catalogData) !== JSON.stringify(catalogRef.current)) { catalogRef.current = catalogData; } const searchData: SearchResult[] = useMemo(() => { const exchangeItems: SearchResult[] = (exchangeResults?.data || []).map((e: any) => ({ id: e.executionId, category: 'exchange' as const, title: e.executionId, badges: [{ label: e.status, color: statusToColor(e.status) }], meta: `${e.routeId} · ${e.applicationId ?? ''} · ${formatDuration(e.durationMs)}`, path: `/exchanges/${e.applicationId ?? ''}/${e.routeId}/${e.executionId}`, serverFiltered: true, matchContext: e.highlight ?? undefined, })); const attributeItems: SearchResult[] = []; if (debouncedQuery) { const q = debouncedQuery.toLowerCase(); for (const e of exchangeResults?.data || []) { if (!e.attributes) continue; for (const [key, value] of Object.entries(e.attributes as Record)) { if (key.toLowerCase().includes(q) || String(value).toLowerCase().includes(q)) { attributeItems.push({ id: `${e.executionId}-attr-${key}`, category: 'attribute' as const, title: `${key} = "${value}"`, badges: [{ label: e.status, color: statusToColor(e.status) }], meta: `${e.executionId} · ${e.routeId} · ${e.applicationId ?? ''}`, path: `/exchanges/${e.applicationId ?? ''}/${e.routeId}/${e.executionId}`, serverFiltered: true, }); } } } } return [...catalogRef.current, ...exchangeItems, ...attributeItems]; }, [catalogRef.current, exchangeResults, debouncedQuery]); // --- Breadcrumb --------------------------------------------------- const breadcrumb = useMemo(() => { if (isAdminPage) { const LABELS: Record = { admin: 'Admin', rbac: 'Users & Roles', audit: 'Audit Log', oidc: 'OIDC', database: 'Database', clickhouse: 'ClickHouse', appconfig: 'App Config', }; const parts = location.pathname.split('/').filter(Boolean); return parts.map((part, i) => ({ label: LABELS[part] ?? part, ...(i < parts.length - 1 ? { href: '/' + parts.slice(0, i + 1).join('/') } : {}), })); } const items: { label: string; href?: string }[] = [ { label: 'All Applications', href: `/${scope.tab}` }, ]; if (scope.appId) { items.push({ label: scope.appId, href: `/${scope.tab}/${scope.appId}` }); } if (scope.routeId) { items.push({ label: scope.routeId }); } 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'); }, [logout, navigate]); 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') { state.selectedExchange = { executionId: parts[3], applicationId: parts[1], routeId: parts[2], }; } } navigate(result.path, { state }); } setPaletteOpen(false); }, [navigate, setPaletteOpen]); const handlePaletteSubmit = useCallback((query: string) => { const baseParts = ['/exchanges']; if (scope.appId) baseParts.push(scope.appId); if (scope.routeId) baseParts.push(scope.routeId); 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; navigate(sRouteId ? `/${scope.tab}/${sAppId}/${sRouteId}` : `/${scope.tab}/${sAppId}`, { state }); return; } // /agents/:appId/:instanceId -> runtime tab const agentMatch = path.match(/^\/agents\/([^/]+)(?:\/(.+))?$/); if (agentMatch) { const [, sAppId, sInstanceId] = agentMatch; navigate(sInstanceId ? `/runtime/${sAppId}/${sInstanceId}` : `/runtime/${sAppId}`, { state }); return; } navigate(path, { state }); }, [navigate, scope.tab]); // --- 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 ( setPaletteOpen(false)} onOpen={() => setPaletteOpen(true)} onSelect={handlePaletteSelect} onSubmit={handlePaletteSubmit} onQueryChange={setPaletteQuery} data={searchData} /> {!isAdminPage && ( )}
); } export function LayoutShell() { return ( ); }