feat(ui): integrate ContentTabs, ScopeTrail, and sidebar scope interception

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-28 14:01:52 +01:00
parent 66abb1fe3a
commit 4fe418cc89

View File

@@ -6,6 +6,9 @@ import { useAgents } from '../api/queries/agents';
import { useSearchExecutions } from '../api/queries/executions'; import { useSearchExecutions } from '../api/queries/executions';
import { useAuthStore } from '../auth/auth-store'; import { useAuthStore } from '../auth/auth-store';
import { useState, useMemo, useCallback, useEffect } from 'react'; import { useState, useMemo, useCallback, useEffect } from 'react';
import { ContentTabs } from './ContentTabs';
import { ScopeTrail } from './ScopeTrail';
import { useScope } from '../hooks/useScope';
function healthToColor(health: string): string { function healthToColor(health: string): string {
switch (health) { switch (health) {
@@ -31,7 +34,7 @@ function buildSearchData(
title: app.appId, title: app.appId,
badges: [{ label: (app.health || 'unknown').toUpperCase(), color: healthToColor(app.health) }], 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`, meta: `${(app.routes || []).length} routes · ${(app.agents || []).length} agents (${liveAgents} live) · ${(app.exchangeCount ?? 0).toLocaleString()} exchanges`,
path: `/apps/${app.appId}`, path: `/exchanges/${app.appId}`,
}); });
for (const route of (app.routes || [])) { for (const route of (app.routes || [])) {
@@ -41,7 +44,7 @@ function buildSearchData(
title: route.routeId, title: route.routeId,
badges: [{ label: app.appId }], badges: [{ label: app.appId }],
meta: `${(route.exchangeCount ?? 0).toLocaleString()} exchanges`, meta: `${(route.exchangeCount ?? 0).toLocaleString()} exchanges`,
path: `/apps/${app.appId}/${route.routeId}`, path: `/exchanges/${app.appId}/${route.routeId}`,
}); });
} }
} }
@@ -54,7 +57,7 @@ function buildSearchData(
title: agent.name, title: agent.name,
badges: [{ label: (agent.state || 'unknown').toUpperCase(), color: healthToColor((agent.state || '').toLowerCase()) }], 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` : ''}`, meta: `${agent.application} · ${agent.version || ''}${agent.agentTps != null ? ` · ${agent.agentTps.toFixed(1)} msg/s` : ''}`,
path: `/agents/${agent.application}/${agent.id}`, path: `/runtime/${agent.application}/${agent.id}`,
}); });
} }
} }
@@ -94,6 +97,7 @@ function LayoutContent() {
const { data: agents } = useAgents(); const { data: agents } = useAgents();
const { username, logout } = useAuthStore(); const { username, logout } = useAuthStore();
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette(); const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
const { scope, setTab } = useScope();
// Exchange full-text search via command palette // Exchange full-text search via command palette
const [paletteQuery, setPaletteQuery] = useState(''); const [paletteQuery, setPaletteQuery] = useState('');
@@ -115,12 +119,7 @@ function LayoutContent() {
name: r.routeId, name: r.routeId,
exchangeCount: r.exchangeCount, exchangeCount: r.exchangeCount,
})), })),
agents: (app.agents || []).map((a: any) => ({ agents: [],
id: a.id,
name: a.name,
status: a.status as 'live' | 'stale' | 'dead',
tps: a.tps,
})),
})); }));
}, [catalog]); }, [catalog]);
@@ -136,7 +135,7 @@ function LayoutContent() {
title: e.executionId, title: e.executionId,
badges: [{ label: e.status, color: statusToColor(e.status) }], badges: [{ label: e.status, color: statusToColor(e.status) }],
meta: `${e.routeId} · ${e.applicationName ?? ''} · ${formatDuration(e.durationMs)}`, meta: `${e.routeId} · ${e.applicationName ?? ''} · ${formatDuration(e.durationMs)}`,
path: `/exchanges/${e.executionId}`, path: `/exchanges/${e.applicationName ?? ''}/${e.routeId}/${e.executionId}`,
serverFiltered: true, serverFiltered: true,
matchContext: e.highlight ?? undefined, matchContext: e.highlight ?? undefined,
})); }));
@@ -154,7 +153,7 @@ function LayoutContent() {
title: `${key} = "${value}"`, title: `${key} = "${value}"`,
badges: [{ label: e.status, color: statusToColor(e.status) }], badges: [{ label: e.status, color: statusToColor(e.status) }],
meta: `${e.executionId} · ${e.routeId} · ${e.applicationName ?? ''}`, meta: `${e.executionId} · ${e.routeId} · ${e.applicationName ?? ''}`,
path: `/exchanges/${e.executionId}`, path: `/exchanges/${e.applicationName ?? ''}/${e.routeId}/${e.executionId}`,
serverFiltered: true, serverFiltered: true,
}); });
} }
@@ -165,14 +164,11 @@ function LayoutContent() {
return [...catalogData, ...exchangeItems, ...attributeItems]; return [...catalogData, ...exchangeItems, ...attributeItems];
}, [catalogData, exchangeResults, debouncedQuery]); }, [catalogData, exchangeResults, debouncedQuery]);
const isAdminPage = location.pathname.startsWith('/admin');
const breadcrumb = useMemo(() => { const breadcrumb = useMemo(() => {
if (!isAdminPage) return [];
const LABELS: Record<string, string> = { const LABELS: Record<string, string> = {
apps: 'Applications',
agents: 'Agents',
exchanges: 'Exchanges',
routes: 'Routes',
admin: 'Admin', admin: 'Admin',
'api-docs': 'API Docs',
rbac: 'Users & Roles', rbac: 'Users & Roles',
audit: 'Audit Log', audit: 'Audit Log',
oidc: 'OIDC', oidc: 'OIDC',
@@ -185,7 +181,7 @@ function LayoutContent() {
label: LABELS[part] ?? part, label: LABELS[part] ?? part,
...(i < parts.length - 1 ? { href: '/' + parts.slice(0, i + 1).join('/') } : {}), ...(i < parts.length - 1 ? { href: '/' + parts.slice(0, i + 1).join('/') } : {}),
})); }));
}, [location.pathname]); }, [location.pathname, isAdminPage]);
const handleLogout = useCallback(() => { const handleLogout = useCallback(() => {
logout(); logout();
@@ -200,19 +196,42 @@ function LayoutContent() {
}, [navigate, setPaletteOpen]); }, [navigate, setPaletteOpen]);
const handlePaletteSubmit = useCallback((query: string) => { const handlePaletteSubmit = useCallback((query: string) => {
// Navigate to dashboard with full-text search applied const baseParts = ['/exchanges'];
const currentPath = location.pathname; if (scope.appId) baseParts.push(scope.appId);
// Stay on the current app/route context if we're already there if (scope.routeId) baseParts.push(scope.routeId);
const basePath = currentPath.startsWith('/apps/') ? currentPath.split('/').slice(0, 4).join('/') : '/apps'; navigate(`${baseParts.join('/')}?text=${encodeURIComponent(query)}`);
navigate(`${basePath}?text=${encodeURIComponent(query)}`); }, [navigate, scope.appId, scope.routeId]);
}, [navigate, location.pathname]);
// Intercept Sidebar's internal <Link> navigation to re-route through current tab
const handleSidebarClick = useCallback((e: React.MouseEvent) => {
const anchor = (e.target as HTMLElement).closest('a[href]');
if (!anchor) return;
const href = anchor.getAttribute('href') || '';
// Intercept /apps/:appId and /apps/:appId/:routeId links
const appMatch = href.match(/^\/apps\/([^/]+)(?:\/(.+))?$/);
if (appMatch) {
e.preventDefault();
const [, sAppId, sRouteId] = appMatch;
navigate(sRouteId ? `/${scope.tab}/${sAppId}/${sRouteId}` : `/${scope.tab}/${sAppId}`);
return;
}
// Intercept /agents/* links — redirect to runtime tab
const agentMatch = href.match(/^\/agents\/([^/]+)(?:\/(.+))?$/);
if (agentMatch) {
e.preventDefault();
const [, sAppId, sInstanceId] = agentMatch;
navigate(sInstanceId ? `/runtime/${sAppId}/${sInstanceId}` : `/runtime/${sAppId}`);
}
}, [navigate, scope.tab]);
return ( return (
<AppShell <AppShell
sidebar={ sidebar={
<Sidebar <div onClick={handleSidebarClick}>
apps={sidebarApps} <Sidebar apps={sidebarApps} />
/> </div>
} }
> >
<TopBar <TopBar
@@ -229,7 +248,17 @@ function LayoutContent() {
onQueryChange={setPaletteQuery} onQueryChange={setPaletteQuery}
data={searchData} data={searchData}
/> />
<main style={{ flex: 1, overflow: 'auto', padding: '1.5rem' }}>
{!isAdminPage && (
<>
<ContentTabs active={scope.tab} onChange={setTab} />
<div style={{ padding: '0 1.5rem', paddingTop: '0.5rem' }}>
<ScopeTrail scope={scope} onNavigate={(path) => navigate(path)} />
</div>
</>
)}
<main style={{ flex: 1, overflow: 'auto', padding: isAdminPage ? '1.5rem' : '0.75rem 1.5rem' }}>
<Outlet /> <Outlet />
</main> </main>
</AppShell> </AppShell>