diff --git a/ui/src/api/queries/processor-metrics.ts b/ui/src/api/queries/processor-metrics.ts new file mode 100644 index 00000000..e9cc39f4 --- /dev/null +++ b/ui/src/api/queries/processor-metrics.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; +import { config } from '../../config'; +import { useAuthStore } from '../../auth/auth-store'; + +export function useProcessorMetrics(routeId: string | null, appId?: string) { + return useQuery({ + queryKey: ['processor-metrics', routeId, appId], + queryFn: async () => { + const token = useAuthStore.getState().accessToken; + const params = new URLSearchParams(); + if (routeId) params.set('routeId', routeId); + if (appId) params.set('appId', appId); + const res = await fetch(`${config.apiBaseUrl}/routes/metrics/processors?${params}`, { + headers: { + Authorization: `Bearer ${token}`, + 'X-Cameleer-Protocol-Version': '1', + }, + }); + if (!res.ok) throw new Error(`${res.status}`); + return res.json(); + }, + enabled: !!routeId, + refetchInterval: 30_000, + }); +} diff --git a/ui/src/pages/Routes/RouteDetail.module.css b/ui/src/pages/Routes/RouteDetail.module.css new file mode 100644 index 00000000..69f75003 --- /dev/null +++ b/ui/src/pages/Routes/RouteDetail.module.css @@ -0,0 +1,39 @@ +.headerCard { + background: var(--bg-surface); border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); box-shadow: var(--shadow-card); + padding: 16px; margin-bottom: 16px; +} +.headerRow { display: flex; justify-content: space-between; align-items: center; gap: 16px; } +.headerLeft { display: flex; align-items: center; gap: 12px; } +.headerRight { display: flex; gap: 20px; } +.headerStat { text-align: center; } +.headerStatLabel { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 2px; } +.headerStatValue { font-size: 14px; font-weight: 700; font-family: var(--font-mono); color: var(--text-primary); } +.diagramStatsGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; } +.diagramPane, .statsPane { + background: var(--bg-surface); border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); box-shadow: var(--shadow-card); padding: 16px; overflow: hidden; +} +.paneTitle { font-size: 13px; font-weight: 700; color: var(--text-primary); margin-bottom: 12px; } +.tabSection { margin-top: 20px; } +.chartGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.chartCard { + background: var(--bg-surface); border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); box-shadow: var(--shadow-card); padding: 16px; overflow: hidden; +} +.chartTitle { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); margin-bottom: 12px; } +.executionsTable { + background: var(--bg-surface); border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); box-shadow: var(--shadow-card); overflow: hidden; +} +.errorPatterns { display: flex; flex-direction: column; gap: 8px; } +.errorRow { + display: flex; justify-content: space-between; align-items: center; + padding: 10px 12px; background: var(--bg-surface); border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); font-size: 12px; +} +.errorMessage { flex: 1; font-family: var(--font-mono); color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 400px; } +.errorCount { font-weight: 700; color: var(--error); margin: 0 12px; } +.errorTime { color: var(--text-muted); font-size: 11px; } +.backLink { font-size: 13px; color: var(--text-muted); text-decoration: none; margin-bottom: 12px; display: inline-block; } +.backLink:hover { color: var(--text-primary); } diff --git a/ui/src/pages/Routes/RouteDetail.tsx b/ui/src/pages/Routes/RouteDetail.tsx new file mode 100644 index 00000000..e4cf1407 --- /dev/null +++ b/ui/src/pages/Routes/RouteDetail.tsx @@ -0,0 +1,301 @@ +import { useState, useMemo } from 'react'; +import { useParams, useNavigate, Link } from 'react-router'; +import { + Badge, StatusDot, DataTable, Tabs, + AreaChart, LineChart, BarChart, RouteFlow, Spinner, + MonoText, +} from '@cameleer/design-system'; +import type { Column } from '@cameleer/design-system'; +import { useGlobalFilters } from '@cameleer/design-system'; +import { useRouteCatalog } from '../../api/queries/catalog'; +import { useDiagramByRoute } from '../../api/queries/diagrams'; +import { useProcessorMetrics } from '../../api/queries/processor-metrics'; +import { useStatsTimeseries, useSearchExecutions } from '../../api/queries/executions'; +import type { ExecutionSummary, AppCatalogEntry, RouteSummary } from '../../api/types'; +import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping'; +import styles from './RouteDetail.module.css'; + +interface ExchangeRow extends ExecutionSummary { + id: string; +} + +interface ProcessorRow { + id: string; + processorId: string; + callCount: number; + avgDurationMs: number; + p99DurationMs: number; + errorCount: number; +} + +interface ErrorPattern { + message: string; + count: number; + lastSeen: string; +} + +export default function RouteDetail() { + const { appId, routeId } = useParams(); + const navigate = useNavigate(); + const { timeRange } = useGlobalFilters(); + const timeFrom = timeRange.start.toISOString(); + const timeTo = timeRange.end.toISOString(); + + const [activeTab, setActiveTab] = useState('performance'); + + const { data: catalog } = useRouteCatalog(); + const { data: diagram } = useDiagramByRoute(appId, routeId); + const { data: processorMetrics, isLoading: processorLoading } = useProcessorMetrics(routeId ?? null, appId); + const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId); + const { data: recentResult, isLoading: recentLoading } = useSearchExecutions({ + timeFrom, + timeTo, + routeId: routeId || undefined, + group: appId || undefined, + offset: 0, + limit: 50, + }); + const { data: errorResult } = useSearchExecutions({ + timeFrom, + timeTo, + routeId: routeId || undefined, + group: appId || undefined, + status: 'FAILED', + offset: 0, + limit: 200, + }); + + const appEntry: AppCatalogEntry | undefined = useMemo(() => + (catalog || []).find((e: AppCatalogEntry) => e.appId === appId), + [catalog, appId], + ); + + const routeSummary: RouteSummary | undefined = useMemo(() => + appEntry?.routes?.find((r: RouteSummary) => r.routeId === routeId), + [appEntry, routeId], + ); + + const health = appEntry?.health ?? 'unknown'; + const exchangeCount = routeSummary?.exchangeCount ?? 0; + const lastSeen = routeSummary?.lastSeen + ? new Date(routeSummary.lastSeen).toLocaleString() + : '—'; + + const healthVariant = useMemo((): 'success' | 'warning' | 'error' | 'dead' => { + const h = health.toLowerCase(); + if (h === 'healthy') return 'success'; + if (h === 'degraded') return 'warning'; + if (h === 'unhealthy') return 'error'; + return 'dead'; + }, [health]); + + const diagramNodes = useMemo(() => { + if (!diagram?.nodes) return []; + return mapDiagramToRouteNodes(diagram.nodes, []); + }, [diagram]); + + const processorRows: ProcessorRow[] = useMemo(() => + (processorMetrics || []).map((p: any) => ({ + id: p.processorId, + processorId: p.processorId, + callCount: p.callCount ?? 0, + avgDurationMs: p.avgDurationMs ?? 0, + p99DurationMs: p.p99DurationMs ?? 0, + errorCount: p.errorCount ?? 0, + })), + [processorMetrics], + ); + + const chartData = useMemo(() => + (timeseries?.buckets || []).map((b: any) => ({ + time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + throughput: b.totalCount, + latency: b.avgDurationMs, + errors: b.failedCount, + successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100, + })), + [timeseries], + ); + + const exchangeRows: ExchangeRow[] = useMemo(() => + (recentResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })), + [recentResult], + ); + + const errorPatterns: ErrorPattern[] = useMemo(() => { + const failed = (errorResult?.data || []) as ExecutionSummary[]; + const grouped = new Map(); + for (const ex of failed) { + const msg = ex.errorMessage || 'Unknown error'; + const existing = grouped.get(msg); + if (!existing) { + grouped.set(msg, { count: 1, lastSeen: ex.startTime ?? '' }); + } else { + existing.count += 1; + if ((ex.startTime ?? '') > existing.lastSeen) { + existing.lastSeen = ex.startTime ?? ''; + } + } + } + return Array.from(grouped.entries()) + .map(([message, { count, lastSeen: ls }]) => ({ + message, + count, + lastSeen: ls ? new Date(ls).toLocaleString() : '—', + })) + .sort((a, b) => b.count - a.count); + }, [errorResult]); + + const processorColumns: Column[] = [ + { key: 'processorId', header: 'Processor', render: (v) => {String(v)} }, + { key: 'callCount', header: 'Calls', sortable: true }, + { key: 'avgDurationMs', header: 'Avg', sortable: true, render: (v) => `${(v as number).toFixed(1)}ms` }, + { key: 'p99DurationMs', header: 'P99', sortable: true, render: (v) => `${(v as number).toFixed(1)}ms` }, + { key: 'errorCount', header: 'Errors', sortable: true, render: (v) => { + const n = v as number; + return n > 0 ? {n} : 0; + }}, + ]; + + const exchangeColumns: Column[] = [ + { + key: 'status', header: 'Status', width: '80px', + render: (v) => , + }, + { key: 'executionId', header: 'Exchange ID', render: (v) => {String(v).slice(0, 12)} }, + { key: 'startTime', header: 'Started', sortable: true, render: (v) => new Date(v as string).toLocaleTimeString() }, + { key: 'durationMs', header: 'Duration', sortable: true, render: (v) => `${v}ms` }, + ]; + + const tabs = [ + { label: 'Performance', value: 'performance' }, + { label: 'Recent Executions', value: 'executions', count: exchangeRows.length }, + { label: 'Error Patterns', value: 'errors', count: errorPatterns.length }, + ]; + + return ( +
+ + ← {appId} routes + + +
+
+
+ +

{routeId}

+ +
+
+
+
Exchanges
+
{exchangeCount.toLocaleString()}
+
+
+
Last Seen
+
{lastSeen}
+
+
+
+
+ +
+
+
Route Diagram
+ {diagramNodes.length > 0 ? ( + + ) : ( +
+ No diagram available for this route. +
+ )} +
+
+
Processor Stats
+ {processorLoading ? ( + + ) : processorRows.length > 0 ? ( + + ) : ( +
+ No processor data available. +
+ )} +
+
+ +
+ + + {activeTab === 'performance' && ( +
+
+
Throughput
+ ({ x: i, y: d.throughput })) }]} + height={200} + /> +
+
+
Latency
+ ({ x: i, y: d.latency })) }]} + height={200} + /> +
+
+
Errors
+ ({ x: d.time, y: d.errors })) }]} + height={200} + /> +
+
+
Success Rate
+ ({ x: i, y: d.successRate })) }]} + height={200} + /> +
+
+ )} + + {activeTab === 'executions' && ( +
+ {recentLoading ? ( +
+ +
+ ) : ( + navigate(`/exchanges/${row.executionId}`)} + sortable + pageSize={20} + /> + )} +
+ )} + + {activeTab === 'errors' && ( +
+ {errorPatterns.length === 0 ? ( +
+ No error patterns found in the selected time range. +
+ ) : ( + errorPatterns.map((ep, i) => ( +
+ {ep.message} + {ep.count}x + {ep.lastSeen} +
+ )) + )} +
+ )} +
+
+ ); +} diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 57a8222a..c63299d3 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -9,6 +9,7 @@ import { Spinner } from '@cameleer/design-system'; const Dashboard = lazy(() => import('./pages/Dashboard/Dashboard')); const ExchangeDetail = lazy(() => import('./pages/ExchangeDetail/ExchangeDetail')); const RoutesMetrics = lazy(() => import('./pages/Routes/RoutesMetrics')); +const RouteDetail = lazy(() => import('./pages/Routes/RouteDetail')); const AgentHealth = lazy(() => import('./pages/AgentHealth/AgentHealth')); const AgentInstance = lazy(() => import('./pages/AgentInstance/AgentInstance')); const RbacPage = lazy(() => import('./pages/Admin/RbacPage')); @@ -42,7 +43,7 @@ export const router = createBrowserRouter([ { path: 'exchanges/:id', element: }, { path: 'routes', element: }, { path: 'routes/:appId', element: }, - { path: 'routes/:appId/:routeId', element: }, + { path: 'routes/:appId/:routeId', element: }, { path: 'agents', element: }, { path: 'agents/:appId', element: }, { path: 'agents/:appId/:instanceId', element: },