Files
cameleer-server/ui/src/components/LayoutShell.tsx
hsiegeln b0484459a2
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 22s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
feat: add application config overview and inline editing
Add admin page at /admin/appconfig with a DataTable showing all
application configurations. Inline dropdowns allow editing log level,
engine level, payload capture mode, and metrics toggle directly from
the table. Changes push to agents via SSE immediately.

Also adds a config bar on the AgentHealth page (/agents/:appId) for
per-application config management with the same 4 settings.

Backend: GET /api/v1/config list endpoint, findAll() on repository,
sensible defaults for logForwardingLevel/engineLevel/payloadCaptureMode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:51:07 +01:00

221 lines
6.9 KiB
TypeScript

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 } from '../api/queries/executions';
import { useAuthStore } from '../auth/auth-store';
import { useState, useMemo, useCallback, useEffect } 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 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 { username, logout } = useAuthStore();
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
// Exchange full-text search via command palette
const [paletteQuery, setPaletteQuery] = useState('');
const debouncedQuery = useDebouncedValue(paletteQuery, 300);
const { data: exchangeResults } = useSearchExecutions(
{ text: debouncedQuery || undefined, offset: 0, limit: 10 },
false,
);
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 catalogData = useMemo(
() => buildSearchData(catalog, agents as any[]),
[catalog, agents],
);
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.applicationName ?? ''} · ${formatDuration(e.durationMs)}`,
path: `/exchanges/${e.executionId}`,
serverFiltered: true,
matchContext: e.highlight ?? undefined,
}));
return [...catalogData, ...exchangeItems];
}, [catalogData, exchangeResults]);
const breadcrumb = useMemo(() => {
const LABELS: Record<string, string> = {
apps: 'Applications',
agents: 'Agents',
exchanges: 'Exchanges',
routes: 'Routes',
admin: 'Admin',
'api-docs': 'API Docs',
rbac: 'Users & Roles',
audit: 'Audit Log',
oidc: 'OIDC',
database: 'Database',
opensearch: 'OpenSearch',
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('/') } : {}),
}));
}, [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)}
onOpen={() => setPaletteOpen(true)}
onSelect={handlePaletteSelect}
onQueryChange={setPaletteQuery}
data={searchData}
/>
<main style={{ flex: 1, overflow: 'auto', padding: '1.5rem' }}>
<Outlet />
</main>
</AppShell>
);
}
export function LayoutShell() {
return (
<ToastProvider>
<CommandPaletteProvider>
<GlobalFilterProvider>
<BreadcrumbProvider>
<LayoutContent />
</BreadcrumbProvider>
</GlobalFilterProvider>
</CommandPaletteProvider>
</ToastProvider>
);
}