diff --git a/ui/src/components/sidebar-utils.ts b/ui/src/components/sidebar-utils.ts new file mode 100644 index 00000000..65ff2b11 --- /dev/null +++ b/ui/src/components/sidebar-utils.ts @@ -0,0 +1,160 @@ +import type { ReactNode } from 'react'; +import type { SidebarTreeNode } from '@cameleer/design-system'; + +/* ------------------------------------------------------------------ */ +/* Domain types (moved out of DS — no longer exported there) */ +/* ------------------------------------------------------------------ */ + +export interface SidebarRoute { + id: string; + name: string; + exchangeCount: number; +} + +export interface SidebarAgent { + id: string; + name: string; + status: 'live' | 'stale' | 'dead'; + tps?: number; +} + +export interface SidebarApp { + id: string; + name: string; + health: 'live' | 'stale' | 'dead'; + exchangeCount: number; + routes: SidebarRoute[]; + agents: SidebarAgent[]; +} + +/* ------------------------------------------------------------------ */ +/* Formatting helpers */ +/* ------------------------------------------------------------------ */ + +export function formatCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}m`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(n); +} + +/* ------------------------------------------------------------------ */ +/* localStorage collapse helpers */ +/* ------------------------------------------------------------------ */ + +export function readCollapsed(key: string, defaultValue: boolean): boolean { + try { + const raw = localStorage.getItem(key); + if (raw === null) return defaultValue; + return raw === 'true'; + } catch { + return defaultValue; + } +} + +export function writeCollapsed(key: string, value: boolean): void { + try { + localStorage.setItem(key, String(value)); + } catch { + // ignore quota errors + } +} + +/* ------------------------------------------------------------------ */ +/* Tree builders */ +/* ------------------------------------------------------------------ */ + +/** + * Apps tree — one node per app, routes as children. + * Paths: /apps/{appId}, /apps/{appId}/{routeId} + */ +export function buildAppTreeNodes( + apps: SidebarApp[], + statusDot: (health: string) => ReactNode, + chevron: () => ReactNode, +): SidebarTreeNode[] { + return apps.map((app) => ({ + id: app.id, + label: app.name, + icon: statusDot(app.health), + badge: formatCount(app.exchangeCount), + path: `/apps/${app.id}`, + starrable: true, + starKey: `app:${app.id}`, + children: app.routes.map((r) => ({ + id: `${app.id}/${r.id}`, + label: r.name, + icon: chevron(), + badge: formatCount(r.exchangeCount), + path: `/apps/${app.id}/${r.id}`, + starrable: true, + starKey: `route:${app.id}/${r.id}`, + })), + })); +} + +/** + * Agents tree — one node per app, agents as children. + * Paths: /agents/{appId}, /agents/{appId}/{agentId} + * Badge shows "N/M live". + */ +export function buildAgentTreeNodes( + apps: SidebarApp[], + statusDot: (health: string) => 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, +): 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: chevron(), + badge: formatCount(r.exchangeCount), + path: `/routes/${app.id}/${r.id}`, + })), + })); +} + +/** + * Admin tree — static 6 nodes. + */ +export function buildAdminTreeNodes(): SidebarTreeNode[] { + return [ + { id: 'admin:rbac', label: 'Users & Roles', path: '/admin/rbac' }, + { id: 'admin:audit', label: 'Audit Log', path: '/admin/audit' }, + { id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' }, + { id: 'admin:appconfig', label: 'App Config', path: '/admin/appconfig' }, + { id: 'admin:database', label: 'Database', path: '/admin/database' }, + { id: 'admin:clickhouse', label: 'ClickHouse', path: '/admin/clickhouse' }, + ]; +}