Files
cameleer-server/ui/src/components/LayoutShell.tsx

345 lines
12 KiB
TypeScript
Raw Normal View History

import { Outlet, useNavigate, useLocation } from 'react-router';
import { AppShell, Sidebar, TopBar, CommandPalette, CommandPaletteProvider, GlobalFilterProvider, ToastProvider, BreadcrumbProvider, useCommandPalette, useGlobalFilters } 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 { useSearchExecutions, useAttributeKeys } from '../api/queries/executions';
import { useAuthStore } from '../auth/auth-store';
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { ContentTabs } from './ContentTabs';
import { useScope } from '../hooks/useScope';
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,
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: healthToColor(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: healthToColor((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 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<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
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();
// Exchange full-text search via command palette (scoped to current sidebar selection)
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 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]);
const catalogData = useMemo(
() => buildSearchData(catalog, agents as any[], attributeKeys),
[catalog, agents, attributeKeys],
);
// Stable reference for catalog data — only changes when catalog/agents actually change,
// not on every poll cycle (prevents cmd-k scroll reset)
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<string, string>)) {
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]);
const isAdminPage = location.pathname.startsWith('/admin');
const breadcrumb = useMemo(() => {
if (isAdminPage) {
const LABELS: Record<string, string> = {
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('/') } : {}),
}));
}
// Scope trail as breadcrumb items
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 });
}
// Last item has no href (current location)
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]);
const handleLogout = useCallback(() => {
logout();
navigate('/login');
}, [logout, navigate]);
const handlePaletteSelect = useCallback((result: any) => {
if (result.path) {
const state: Record<string, unknown> = { sidebarReveal: result.path };
// For exchange/attribute results, pass selectedExchange in state
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.
// Pass sidebarReveal state so the DS Sidebar can highlight the clicked entry.
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]);
return (
<AppShell
sidebar={
isAdminPage ? null : <Sidebar apps={sidebarApps} onNavigate={handleSidebarNavigate} />
}
>
{!isAdminPage && (
<TopBar
breadcrumb={breadcrumb}
user={username ? { name: username } : undefined}
onLogout={handleLogout}
/>
)}
{!isAdminPage && (
<CommandPalette
open={paletteOpen}
onClose={() => setPaletteOpen(false)}
onOpen={() => setPaletteOpen(true)}
onSelect={handlePaletteSelect}
onSubmit={handlePaletteSubmit}
onQueryChange={setPaletteQuery}
data={searchData}
/>
)}
{!isAdminPage && (
<ContentTabs active={scope.tab} onChange={setTab} scope={scope} />
)}
<main style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0, padding: 0 }}>
<Outlet />
</main>
</AppShell>
);
}
export function LayoutShell() {
return (
<ToastProvider>
<CommandPaletteProvider>
<GlobalFilterProvider>
<BreadcrumbProvider>
<LayoutContent />
</BreadcrumbProvider>
</GlobalFilterProvider>
</CommandPaletteProvider>
</ToastProvider>
);
}