Migrate all page components from the @cameleer/design-system v0.0.3 example UI, replacing mock data with real backend API hooks. This brings richer visuals (KpiStrip, GroupCard, RouteFlow, ProcessorTimeline, DateRangePicker, expandable rows) while preserving all existing API integration, auth, and routing infrastructure. Pages migrated: Dashboard, RoutesMetrics, RouteDetail, ExchangeDetail, AgentHealth, AgentInstance, OidcConfig, AuditLog, RBAC (Users/Groups/Roles). Also enhanced LayoutShell CommandPalette with real search data from catalog. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
155 lines
4.6 KiB
TypeScript
155 lines
4.6 KiB
TypeScript
import { Outlet, useNavigate, useLocation } from 'react-router';
|
|
import { AppShell, Sidebar, TopBar, CommandPalette, CommandPaletteProvider, GlobalFilterProvider, ToastProvider, useCommandPalette } from '@cameleer/design-system';
|
|
import type { SidebarApp, SearchResult } from '@cameleer/design-system';
|
|
import { useRouteCatalog } from '../api/queries/catalog';
|
|
import { useAgents } from '../api/queries/agents';
|
|
import { useAuthStore } from '../auth/auth-store';
|
|
import { useMemo, useCallback } from 'react';
|
|
|
|
function healthToColor(health: string): string {
|
|
switch (health) {
|
|
case 'live': return 'success';
|
|
case 'stale': return 'warning';
|
|
case 'dead': return 'error';
|
|
default: return 'auto';
|
|
}
|
|
}
|
|
|
|
function buildSearchData(
|
|
catalog: any[] | undefined,
|
|
agents: any[] | 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: healthToColor(app.health) }],
|
|
meta: `${(app.routes || []).length} routes · ${(app.agents || []).length} agents (${liveAgents} live) · ${(app.exchangeCount ?? 0).toLocaleString()} exchanges`,
|
|
path: `/apps/${app.appId}`,
|
|
});
|
|
|
|
for (const route of (app.routes || [])) {
|
|
results.push({
|
|
id: route.routeId,
|
|
category: 'route',
|
|
title: route.routeId,
|
|
badges: [{ label: app.appId }],
|
|
meta: `${(route.exchangeCount ?? 0).toLocaleString()} exchanges`,
|
|
path: `/apps/${app.appId}/${route.routeId}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (agents) {
|
|
for (const agent of agents) {
|
|
results.push({
|
|
id: agent.id,
|
|
category: 'agent',
|
|
title: agent.name,
|
|
badges: [{ label: (agent.state || 'unknown').toUpperCase(), color: healthToColor((agent.state || '').toLowerCase()) }],
|
|
meta: `${agent.application} · ${agent.version || ''}${agent.agentTps != null ? ` · ${agent.agentTps.toFixed(1)} msg/s` : ''}`,
|
|
path: `/agents/${agent.application}/${agent.id}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
function LayoutContent() {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const { data: catalog } = useRouteCatalog();
|
|
const { data: agents } = useAgents();
|
|
const { username, logout } = useAuthStore();
|
|
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
|
|
|
|
const sidebarApps: SidebarApp[] = useMemo(() => {
|
|
if (!catalog) return [];
|
|
return catalog.map((app: any) => ({
|
|
id: app.appId,
|
|
name: app.appId,
|
|
health: app.health as 'live' | 'stale' | 'dead',
|
|
exchangeCount: app.exchangeCount,
|
|
routes: (app.routes || []).map((r: any) => ({
|
|
id: r.routeId,
|
|
name: r.routeId,
|
|
exchangeCount: r.exchangeCount,
|
|
})),
|
|
agents: (app.agents || []).map((a: any) => ({
|
|
id: a.id,
|
|
name: a.name,
|
|
status: a.status as 'live' | 'stale' | 'dead',
|
|
tps: a.tps,
|
|
})),
|
|
}));
|
|
}, [catalog]);
|
|
|
|
const searchData = useMemo(
|
|
() => buildSearchData(catalog, agents as any[]),
|
|
[catalog, agents],
|
|
);
|
|
|
|
const breadcrumb = useMemo(() => {
|
|
const parts = location.pathname.split('/').filter(Boolean);
|
|
return parts.map((part, i) => ({
|
|
label: part,
|
|
href: '/' + parts.slice(0, i + 1).join('/'),
|
|
}));
|
|
}, [location.pathname]);
|
|
|
|
const handleLogout = useCallback(() => {
|
|
logout();
|
|
navigate('/login');
|
|
}, [logout, navigate]);
|
|
|
|
const handlePaletteSelect = useCallback((result: any) => {
|
|
if (result.path) {
|
|
navigate(result.path, { state: result.path ? { sidebarReveal: result.path } : undefined });
|
|
}
|
|
setPaletteOpen(false);
|
|
}, [navigate, setPaletteOpen]);
|
|
|
|
return (
|
|
<AppShell
|
|
sidebar={
|
|
<Sidebar
|
|
apps={sidebarApps}
|
|
/>
|
|
}
|
|
>
|
|
<TopBar
|
|
breadcrumb={breadcrumb}
|
|
user={username ? { name: username } : undefined}
|
|
onLogout={handleLogout}
|
|
/>
|
|
<CommandPalette
|
|
open={paletteOpen}
|
|
onClose={() => setPaletteOpen(false)}
|
|
onSelect={handlePaletteSelect}
|
|
data={searchData}
|
|
/>
|
|
<main style={{ flex: 1, overflow: 'auto', padding: '1.5rem' }}>
|
|
<Outlet />
|
|
</main>
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
export function LayoutShell() {
|
|
return (
|
|
<ToastProvider>
|
|
<CommandPaletteProvider>
|
|
<GlobalFilterProvider>
|
|
<LayoutContent />
|
|
</GlobalFilterProvider>
|
|
</CommandPaletteProvider>
|
|
</ToastProvider>
|
|
);
|
|
}
|