Add attributes_text flattened field to OpenSearch indexing for both execution and processor levels. Include in full-text search queries, wildcard matching, and highlighting. Merge processor-level attributes into ExecutionSummary. Add 'attribute' category to CommandPalette (design-system 0.1.17) with per-key-value results in the search UI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
243 lines
7.8 KiB
TypeScript
243 lines
7.8 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,
|
|
}));
|
|
|
|
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.applicationName ?? ''}`,
|
|
path: `/exchanges/${e.executionId}`,
|
|
serverFiltered: true,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...catalogData, ...exchangeItems, ...attributeItems];
|
|
}, [catalogData, exchangeResults, debouncedQuery]);
|
|
|
|
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>
|
|
);
|
|
}
|