import { Outlet, useNavigate, useLocation } from 'react-router'; import { config } from '../config'; import { AppShell, Sidebar, SidebarTree, StatusDot, TopBar, SearchTrigger, AutoRefreshToggle, ButtonGroup, TimeRangeDropdown, CommandPalette, CommandPaletteProvider, GlobalFilterProvider, ToastProvider, BreadcrumbProvider, useCommandPalette, useGlobalFilters, useStarred, } from '@cameleer/design-system'; import type { SearchResult, SidebarTreeNode, DropdownItem, ButtonGroupItem, ExchangeStatus } from '@cameleer/design-system'; import sidebarLogo from '@cameleer/design-system/assets/cameleer-logo.svg'; import { Box, Settings, FileText, ChevronRight, Square, Pause, Star, X, User, Plus, EyeOff, Bell } from 'lucide-react'; import { AboutMeDialog } from './AboutMeDialog'; import { NotificationBell } from './NotificationBell'; import css from './LayoutShell.module.css'; import { useQueryClient } from '@tanstack/react-query'; import { useCatalog } from '../api/queries/catalog'; import { useAgents } from '../api/queries/agents'; import { useSearchExecutions, useAttributeKeys } from '../api/queries/executions'; import { useUsers, useGroups, useRoles } from '../api/queries/admin/rbac'; import { useEnvironments } from '../api/queries/admin/environments'; import { useAlerts } from '../api/queries/alerts'; import { useAlertRules } from '../api/queries/alertRules'; import type { UserDetail, GroupDetail, RoleDetail } from '../api/queries/admin/rbac'; import { useAuthStore, useIsAdmin, useCanControl } from '../auth/auth-store'; import { useEnvironmentStore } from '../api/environment-store'; import { useState, useMemo, useCallback, useEffect, useRef, createElement } from 'react'; import type { ReactNode } from 'react'; import { ContentTabs } from './ContentTabs'; import { EnvironmentSwitcherButton } from './EnvironmentSwitcherButton'; import { EnvironmentSwitcherModal } from './EnvironmentSwitcherModal'; import { envColorVar } from './env-colors'; import { useScope } from '../hooks/useScope'; import { formatDuration } from '../utils/format-utils'; import { parseFacetQuery, formatAttrParam } from '../utils/attribute-filter'; import { buildAppTreeNodes, buildAdminTreeNodes, buildAlertsTreeNodes, formatCount, readCollapsed, writeCollapsed, } from './sidebar-utils'; import { useServerCapabilities } from '../api/queries/capabilities'; import type { SidebarApp } from './sidebar-utils'; /* ------------------------------------------------------------------ */ /* Search data builder */ /* ------------------------------------------------------------------ */ function buildSearchData( catalog: any[] | undefined, agents: any[] | undefined, attrKeys: string[] | undefined, ): SearchResult[] { if (!catalog) return []; const results: SearchResult[] = []; for (const app of catalog) { const slug = app.slug || app.appId; const name = app.displayName || slug; const liveAgents = (app.agents || []).filter((a: any) => a.status === 'live').length; results.push({ id: slug, category: 'application', title: name, 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/${slug}`, }); for (const route of (app.routes || [])) { results.push({ id: `${slug}/${route.routeId}`, category: 'route', title: route.routeId, badges: [{ label: name }], meta: `${(route.exchangeCount ?? 0).toLocaleString()} exchanges`, path: `/exchanges/${slug}/${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 — filter list', // Path carries the facet in query-string form; handlePaletteSelect routes // attribute results to the current scope, so the leading segment below is // only used as a fallback when no scope is active. path: `/exchanges?attr=${encodeURIComponent(key)}`, }); } } return results; } function buildAdminSearchData( users: UserDetail[] | undefined, groups: GroupDetail[] | undefined, roles: RoleDetail[] | undefined, ): SearchResult[] { const results: SearchResult[] = []; if (users) { for (const u of users) { results.push({ id: `user:${u.userId}`, category: 'user', title: u.displayName || u.userId, meta: u.userId, path: '/admin/rbac', }); } } if (groups) { for (const g of groups) { results.push({ id: `group:${g.id}`, category: 'group', title: g.name, meta: g.parentGroupId ? `parent: ${g.parentGroupId}` : 'top-level group', path: '/admin/rbac', }); } } if (roles) { for (const r of roles) { results.push({ id: `role:${r.id}`, category: 'role', title: r.name, meta: r.scope, path: '/admin/rbac', }); } } return results; } function buildAlertSearchData( alerts: any[] | undefined, rules: any[] | undefined, ): SearchResult[] { const results: SearchResult[] = []; if (alerts) { for (const a of alerts) { results.push({ id: `alert:${a.id}`, category: 'alert', title: a.title ?? '(untitled)', badges: [ { label: a.severity, color: severityToSearchColor(a.severity) }, { label: a.state, color: stateToSearchColor(a.state) }, ], meta: `${a.firedAt ?? ''}${a.silenced ? ' · silenced' : ''}`, path: `/alerts/inbox/${a.id}`, }); } } if (rules) { for (const r of rules) { results.push({ id: `rule:${r.id}`, category: 'alertRule', title: r.name, badges: [ { label: r.severity, color: severityToSearchColor(r.severity) }, { label: r.conditionKind, color: 'auto' }, ...(r.enabled ? [] : [{ label: 'DISABLED', color: 'warning' as const }]), ], meta: `${r.evaluationIntervalSeconds}s · ${r.targets?.length ?? 0} targets`, path: `/alerts/rules/${r.id}`, }); } } return results; } function severityToSearchColor(s: string): string { if (s === 'CRITICAL') return 'error'; if (s === 'WARNING') return 'warning'; return 'auto'; } function stateToSearchColor(s: string): string { if (s === 'FIRING') return 'error'; if (s === 'ACKNOWLEDGED') return 'warning'; if (s === 'RESOLVED') return 'success'; return 'auto'; } function healthToSearchColor(health: string): string { switch (health) { case 'live': return 'success'; case 'stale': return 'warning'; case 'dead': return 'error'; default: return 'auto'; } } 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 */ /* ------------------------------------------------------------------ */ function makeStatusDot(health: string) { return createElement(StatusDot, { variant: health as any }); } function makeChevron() { return createElement(ChevronRight, { size: 14 }); } function makeStopIcon() { return createElement(Square, { size: 12, style: { color: 'var(--error)' } }); } function makePauseIcon() { return createElement(Pause, { size: 12, style: { color: 'var(--amber)' } }); } /* ------------------------------------------------------------------ */ /* 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 = `route:${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 STATUS_ITEMS: ButtonGroupItem[] = [ { value: 'completed', label: 'Completed', color: 'var(--success)' }, { value: 'warning', label: 'Warning', color: 'var(--warning)' }, { value: 'failed', label: 'Failed', color: 'var(--error)' }, { value: 'running', label: 'Running', color: 'var(--running)' }, ] const SK_APPS = 'sidebar:section:apps'; const SK_ADMIN = 'sidebar:section:admin'; const SK_ALERTS = 'sidebar:section:alerts'; const SK_COLLAPSED = 'sidebar:collapsed'; /* ------------------------------------------------------------------ */ /* Main layout content */ /* ------------------------------------------------------------------ */ function LayoutContent() { const navigate = useNavigate(); const location = useLocation(); const queryClient = useQueryClient(); const globalFilters = useGlobalFilters(); const { timeRange, autoRefresh, refreshTimeRange } = globalFilters; // --- Server capabilities ------------------------------------------ const { data: capabilities } = useServerCapabilities(); // --- Role checks ---------------------------------------------------- const isAdmin = useIsAdmin(); const canControl = useCanControl(); // --- Environment filtering ----------------------------------------- const selectedEnv = useEnvironmentStore((s) => s.environment); const setSelectedEnvRaw = useEnvironmentStore((s) => s.setEnvironment); const catalogFrom = timeRange.start.toISOString(); const catalogTo = timeRange.end.toISOString(); const { data: catalog } = useCatalog(selectedEnv, catalogFrom, catalogTo); // Env is always required now (path-based endpoint). For cross-env "all agents" // we'd need a separate flat endpoint; sidebar uses env-filtered list directly. const { data: agents } = useAgents(); // env pulled from store internally const allAgents = agents; const { data: attributeKeys } = useAttributeKeys(); const { data: envRecords = [] } = useEnvironments(); // Open alerts + rules for CMD-K (env-scoped). const { data: cmdkAlerts } = useAlerts({ state: ['FIRING'], acked: false, limit: 100 }); const { data: cmdkRules } = useAlertRules(); // Merge environments from both the environments table and agent heartbeats const environments: string[] = useMemo(() => { const envSet = new Set(); for (const e of envRecords) envSet.add(e.slug); if (allAgents) { for (const a of allAgents as any[]) { envSet.add(a.environmentId || 'default'); } } if (envSet.size === 0) envSet.add('default'); return [...envSet].sort(); }, [allAgents, envRecords]); // --- Admin search data (only fetched on admin pages) ---------------- const isAdminPage = location.pathname.startsWith('/admin'); const isAlertsPage = location.pathname.startsWith('/alerts'); const { data: adminUsers } = useUsers(isAdminPage); const { data: adminGroups } = useGroups(isAdminPage); const { data: adminRoles } = useRoles(isAdminPage); const { username, logout } = useAuthStore(); const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette(); const { scope, setTab } = useScope(); // --- Starred items ------------------------------------------------ const { starredIds, 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(''); const [hideEmptyRoutes, setHideEmptyRoutes] = useState(() => readCollapsed('sidebar:hideEmptyRoutes', false)); const [hideOfflineApps, setHideOfflineApps] = useState(() => readCollapsed('sidebar:hideOfflineApps', false)); const toggleHideEmptyRoutes = useCallback(() => { setHideEmptyRoutes((prev) => { writeCollapsed('sidebar:hideEmptyRoutes', !prev); return !prev; }); }, []); const toggleHideOfflineApps = useCallback(() => { setHideOfflineApps((prev) => { writeCollapsed('sidebar:hideOfflineApps', !prev); return !prev; }); }, []); const setSelectedEnv = useCallback((env: string | undefined) => { setSelectedEnvRaw(env); setFilterQuery(''); if (location.search) { navigate(location.pathname, { replace: true }); } queryClient.invalidateQueries(); }, [setSelectedEnvRaw, navigate, location.pathname, location.search, queryClient]); // --- Env switcher modal ------------------------------------------- const [switcherOpen, setSwitcherOpen] = useState(false); // Force-open the switcher when we have envs loaded but no valid selection. // This replaces the old "All Envs" fallback: every session must pick one. const selectionInvalid = envRecords.length > 0 && (selectedEnv === undefined || !envRecords.some((e) => e.slug === selectedEnv)); const switcherForced = selectionInvalid; useEffect(() => { if (selectionInvalid) { if (selectedEnv !== undefined) setSelectedEnvRaw(undefined); setSwitcherOpen(true); } }, [selectionInvalid, selectedEnv, setSelectedEnvRaw]); const currentEnvRecord = envRecords.find((e) => e.slug === selectedEnv); const envBarColor = envColorVar(currentEnvRecord?.color); // --- Section open states ------------------------------------------ const [appsOpen, setAppsOpen] = useState(() => (isAdminPage || isAlertsPage) ? false : readCollapsed(SK_APPS, true)); const [adminOpen, setAdminOpen] = useState(() => isAdminPage ? true : readCollapsed(SK_ADMIN, false)); const [alertsOpen, setAlertsOpen] = useState(() => isAlertsPage ? true : readCollapsed(SK_ALERTS, false)); const [starredOpen, setStarredOpen] = useState(true); // Accordion: entering admin collapses apps + starred; leaving restores const opsStateRef = useRef({ apps: appsOpen, starred: starredOpen }); const prevAdminRef = useRef(isAdminPage); useEffect(() => { if (isAdminPage && !prevAdminRef.current) { opsStateRef.current = { apps: appsOpen, starred: starredOpen }; setAppsOpen(false); setStarredOpen(false); setAdminOpen(true); } else if (!isAdminPage && prevAdminRef.current) { setAppsOpen(opsStateRef.current.apps); setStarredOpen(opsStateRef.current.starred); setAdminOpen(false); } prevAdminRef.current = isAdminPage; }, [isAdminPage]); // eslint-disable-line react-hooks/exhaustive-deps // Accordion: entering alerts collapses apps + admin + starred; leaving restores const opsAlertsStateRef = useRef({ apps: appsOpen, admin: adminOpen, starred: starredOpen }); const prevAlertsRef = useRef(isAlertsPage); useEffect(() => { if (isAlertsPage && !prevAlertsRef.current) { opsAlertsStateRef.current = { apps: appsOpen, admin: adminOpen, starred: starredOpen }; setAppsOpen(false); setAdminOpen(false); setStarredOpen(false); setAlertsOpen(true); } else if (!isAlertsPage && prevAlertsRef.current) { setAppsOpen(opsAlertsStateRef.current.apps); setAdminOpen(opsAlertsStateRef.current.admin); setStarredOpen(opsAlertsStateRef.current.starred); setAlertsOpen(false); } prevAlertsRef.current = isAlertsPage; }, [isAlertsPage]); // eslint-disable-line react-hooks/exhaustive-deps const toggleAlerts = useCallback(() => { if (!isAlertsPage) { navigate('/alerts/inbox'); return; } setAlertsOpen((prev) => { writeCollapsed(SK_ALERTS, !prev); return !prev; }); }, [isAlertsPage, navigate]); const toggleApps = useCallback(() => { if (isAdminPage) { navigate('/exchanges'); return; } if (appsOpen) { // Already open — navigate to all applications if (!autoRefresh) { refreshTimeRange(); queryClient.invalidateQueries(); } navigate(`/${scope.tab}`); } else { setAppsOpen(true); writeCollapsed(SK_APPS, true); } }, [isAdminPage, appsOpen, navigate, scope.tab, autoRefresh, refreshTimeRange, queryClient]); 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(() => { if (!catalog) return []; const cmp = (a: string, b: string) => a.localeCompare(b); return [...catalog] .sort((a: any, b: any) => cmp(a.slug, b.slug)) .map((app: any) => ({ id: app.slug, name: app.displayName || app.slug, health: (app.health === 'offline' ? 'dead' : app.health) as SidebarApp['health'], healthTooltip: app.healthTooltip, 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, routeState: r.routeState ?? undefined, })), agents: [], })); }, [catalog]); // --- Apply sidebar filters ----------------------------------------- const filteredSidebarApps: SidebarApp[] = useMemo(() => { let apps = sidebarApps; if (hideOfflineApps) { apps = apps.filter((a) => a.health !== 'dead' && a.health !== 'stale'); } if (hideEmptyRoutes) { apps = apps .map((a) => ({ ...a, routes: a.routes.filter((r) => r.exchangeCount > 0), })) .filter((a) => a.exchangeCount > 0 || a.routes.length > 0); } return apps; }, [sidebarApps, hideOfflineApps, hideEmptyRoutes]); // --- Tree nodes --------------------------------------------------- const appTreeNodes: SidebarTreeNode[] = useMemo( () => buildAppTreeNodes(filteredSidebarApps, makeStatusDot, makeChevron, makeStopIcon, makePauseIcon), [filteredSidebarApps], ); const adminTreeNodes: SidebarTreeNode[] = useMemo( () => buildAdminTreeNodes({ infrastructureEndpoints: capabilities?.infrastructureEndpoints }), [capabilities?.infrastructureEndpoints], ); const alertsTreeNodes: SidebarTreeNode[] = useMemo( () => buildAlertsTreeNodes(), [], ); // --- 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); if (sidebarRevealPath.startsWith('/alerts') && !alertsOpen) setAlertsOpen(true); }, [sidebarRevealPath]); // eslint-disable-line react-hooks/exhaustive-deps // Normalize path so sidebar highlights the app regardless of which tab is active. // Sidebar nodes use /exchanges/{slug} paths, so map /dashboard/{slug}, /apps/{slug}, etc. const effectiveSelectedPath = useMemo(() => { const raw = sidebarRevealPath ?? location.pathname; const match = raw.match(/^\/(exchanges|dashboard|apps|runtime)\/([^/]+)(\/.*)?$/); if (match) return `/exchanges/${match[2]}${match[3] ?? ''}`; return raw; }, [sidebarRevealPath, location.pathname]); // --- About Me dialog ----------------------------------------------- const [aboutMeOpen, setAboutMeOpen] = useState(false); const userMenuItems: DropdownItem[] = useMemo(() => [ { label: 'About Me', icon: createElement(User, { size: 14 }), onClick: () => setAboutMeOpen(true) }, ], []); // --- 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], ); const catalogRef = useRef(catalogData); if (catalogData !== catalogRef.current && JSON.stringify(catalogData) !== JSON.stringify(catalogRef.current)) { catalogRef.current = catalogData; } const adminSearchData: SearchResult[] = useMemo( () => buildAdminSearchData(adminUsers, adminGroups, adminRoles), [adminUsers, adminGroups, adminRoles], ); const alertingSearchData: SearchResult[] = useMemo( () => buildAlertSearchData(cmdkAlerts, cmdkRules), [cmdkAlerts, cmdkRules], ); const operationalSearchData: SearchResult[] = useMemo(() => { if (isAdminPage) return []; const exchangeItems: SearchResult[] = (exchangeResults?.data || []).map((e: any) => ({ id: e.executionId, category: 'exchange' as const, title: `...${e.executionId.slice(-8)}`, 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, }); } } } } const facet = parseFacetQuery(debouncedQuery ?? ''); const facetItems: SearchResult[] = facet ? [{ id: `facet-${formatAttrParam(facet)}`, category: 'attribute' as const, title: `Filter: ${facet.key} = "${facet.value}"${facet.value?.includes('*') ? ' (wildcard)' : ''}`, meta: 'apply attribute filter', path: `/exchanges?attr=${encodeURIComponent(formatAttrParam(facet))}`, }] : []; return [...facetItems, ...catalogRef.current, ...exchangeItems, ...attributeItems, ...alertingSearchData]; }, [isAdminPage, catalogRef.current, exchangeResults, debouncedQuery, alertingSearchData]); const searchData = isAdminPage ? adminSearchData : operationalSearchData; // --- Breadcrumb --------------------------------------------------- const breadcrumb = useMemo(() => { if (isAdminPage) { const LABELS: Record = { admin: 'Admin', rbac: 'Users & Roles', audit: 'Audit Log', oidc: 'OIDC', database: 'Database', clickhouse: 'ClickHouse', 'server-metrics': 'Server Metrics', 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 ADMIN_CATEGORIES = new Set(['user', 'group', 'role']); const ADMIN_TAB_MAP: Record = { user: 'users', group: 'groups', role: 'roles' }; const handlePaletteSelect = useCallback((result: any) => { if (result.category === 'alert' || result.category === 'alertRule') { if (result.path) navigate(result.path); setPaletteOpen(false); return; } if (result.category === 'attribute') { // Three sources feed 'attribute' results: // - buildSearchData → id `attr-key-` (key-only) // - operationalSearchData per-exchange → id `-attr-`, title `key = "value"` // - synthetic facet (Task 9) → id `facet-` where is already // the URL `attr=` form (`key` or `key:value`) let attrParam: string | null = null; if (typeof result.id === 'string' && result.id.startsWith('attr-key-')) { attrParam = result.id.substring('attr-key-'.length); } else if (typeof result.id === 'string' && result.id.startsWith('facet-')) { attrParam = result.id.substring('facet-'.length); } else if (typeof result.title === 'string') { const m = /^([a-zA-Z0-9._-]+)\s*=\s*"([^"]*)"/.exec(result.title); if (m) attrParam = `${m[1]}:${m[2]}`; } if (attrParam) { const base = ['/exchanges']; if (scope.appId) base.push(scope.appId); if (scope.routeId) base.push(scope.routeId); navigate(`${base.join('/')}?attr=${encodeURIComponent(attrParam)}`); } setPaletteOpen(false); return; } if (result.path) { if (ADMIN_CATEGORIES.has(result.category)) { const itemId = result.id.split(':').slice(1).join(':'); navigate(result.path, { state: { tab: ADMIN_TAB_MAP[result.category], highlight: itemId }, }); } else { const state: Record = { sidebarReveal: result.path }; if (result.category === 'exchange') { 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, scope.appId, scope.routeId]); const handlePaletteSubmit = useCallback((query: string) => { if (isAdminPage) { // Find first matching admin result and navigate to it const q = query.toLowerCase(); const match = adminSearchData.find( (r) => r.title.toLowerCase().includes(q) || r.meta.toLowerCase().includes(q), ); if (match) { handlePaletteSelect(match); } else { navigate('/admin/rbac'); } return; } const facet = parseFacetQuery(query); const baseParts = ['/exchanges']; if (scope.appId) baseParts.push(scope.appId); if (scope.routeId) baseParts.push(scope.routeId); if (facet) { navigate(`${baseParts.join('/')}?attr=${encodeURIComponent(formatAttrParam(facet))}`); return; } navigate(`${baseParts.join('/')}?text=${encodeURIComponent(query)}`); }, [isAdminPage, adminSearchData, handlePaletteSelect, navigate, scope.appId, scope.routeId]); const handleSidebarNavigate = useCallback((path: string) => { const state = { sidebarReveal: path }; // When not auto-refreshing, treat navigation as a manual refresh if (!autoRefresh) { refreshTimeRange(); queryClient.invalidateQueries(); } const appMatch = path.match(/^\/apps\/([^/]+)(?:\/(.+))?$/); if (appMatch) { const [, sAppId, sRouteId] = appMatch; if (scope.tab === 'apps') { navigate(`/apps/${sAppId}`, { state }); } else if (scope.tab === 'runtime') { // Runtime tab has no route-level view — stay at app level navigate(`/runtime/${sAppId}`, { state }); } else { navigate(sRouteId ? `/${scope.tab}/${sAppId}/${sRouteId}` : `/${scope.tab}/${sAppId}`, { state }); } return; } const exchangeMatch = path.match(/^\/exchanges\/([^/]+)(?:\/(.+))?$/); if (exchangeMatch) { const [, sAppId, sRouteId] = exchangeMatch; if (scope.tab === 'apps') { navigate(`/apps/${sAppId}`, { state }); } else if (scope.tab === 'runtime') { navigate(`/runtime/${sAppId}`, { state }); } else { navigate(sRouteId ? `/${scope.tab}/${sAppId}/${sRouteId}` : `/${scope.tab}/${sAppId}`, { state }); } return; } 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, autoRefresh, refreshTimeRange, queryClient]); // --- Render ------------------------------------------------------- const camelLogo = ( ); const sidebarElement = ( {/* Sidebar filters */} {!sidebarCollapsed &&
} {/* Applications section */}
{canControl && !sidebarCollapsed && ( )}
{/* Alerts section */} {/* Starred section — only when there are starred items */} {starredItems.length > 0 && ( setStarredOpen((v) => !v)} maxHeight="30vh" > )} {/* Admin section — only visible to ADMIN role */} {isAdmin && ( )} {/* Footer */} handleSidebarNavigate('/api-docs')} />
); return (
setSwitcherOpen(false)} envs={envRecords} value={selectedEnv} onPick={(slug) => { setSelectedEnv(slug); setSwitcherOpen(false); }} forced={switcherForced} /> setSwitcherOpen(true)} />
} user={username ? { name: username } : undefined} userMenuItems={userMenuItems} onLogout={handleLogout} onNavigate={navigate} > setPaletteOpen(true)} /> { type S = 'completed' | 'warning' | 'failed' | 'running' const selected = sel as Set const current = globalFilters.statusFilters for (const v of selected) { if (!current.has(v)) globalFilters.toggleStatus(v) } for (const v of current) { if (!selected.has(v)) globalFilters.toggleStatus(v) } }} /> setAboutMeOpen(false)} /> setPaletteOpen(false)} onOpen={() => setPaletteOpen(true)} onSelect={handlePaletteSelect} onSubmit={handlePaletteSubmit} onQueryChange={setPaletteQuery} data={searchData} /> {!isAdminPage && !isAlertsPage && ( )}
); } export function LayoutShell() { return ( ); }