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>
221 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|