import { useState, useMemo, useCallback } from 'react'; import { useParams, useNavigate, Link } from 'react-router'; import { KpiStrip, Badge, StatusDot, DataTable, Tabs, AreaChart, LineChart, BarChart, RouteFlow, Spinner, MonoText, Sparkline, Toggle, Button, Modal, FormField, Input, Select, Textarea, Collapsible, ConfirmDialog, } from '@cameleer/design-system'; import type { KpiItem, 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, useExecutionStats } from '../../api/queries/executions'; import { useApplicationConfig, useUpdateApplicationConfig, useTestExpression } from '../../api/queries/commands'; import type { TapDefinition } from '../../api/queries/commands'; import type { ExecutionSummary, AppCatalogEntry, RouteSummary } from '../../api/types'; import { mapDiagramToRouteNodes, toFlowSegments } from '../../utils/diagram-mapping'; import styles from './RouteDetail.module.css'; // ── Row types ──────────────────────────────────────────────────────────────── interface ExchangeRow extends ExecutionSummary { id: string; } interface ProcessorRow { id: string; processorId: string; callCount: number; avgDurationMs: number; p99DurationMs: number; errorCount: number; errorRate: number; sparkline: number[]; } interface ErrorPattern { message: string; count: number; lastSeen: string; } // ── Processor type badge classes ───────────────────────────────────────────── const TYPE_STYLE_MAP: Record = { consumer: styles.typeConsumer, producer: styles.typeProducer, enricher: styles.typeEnricher, validator: styles.typeValidator, transformer: styles.typeTransformer, router: styles.typeRouter, processor: styles.typeProcessor, }; function classifyProcessorType(processorId: string): string { const lower = processorId.toLowerCase(); if (lower.startsWith('from(') || lower.includes('consumer')) return 'consumer'; if (lower.startsWith('to(')) return 'producer'; if (lower.includes('enrich')) return 'enricher'; if (lower.includes('validate') || lower.includes('check')) return 'validator'; if (lower.includes('unmarshal') || lower.includes('marshal')) return 'transformer'; if (lower.includes('route') || lower.includes('choice')) return 'router'; return 'processor'; } // ── Processor table columns ────────────────────────────────────────────────── function makeProcessorColumns(css: typeof styles): Column[] { return [ { key: 'processorId', header: 'Processor', sortable: true, render: (_, row) => ( {row.processorId} ), }, { key: 'callCount', header: 'Invocations', sortable: true, render: (_, row) => ( {row.callCount.toLocaleString()} ), }, { key: 'avgDurationMs', header: 'Avg Duration', sortable: true, render: (_, row) => { const cls = row.avgDurationMs > 200 ? css.rateBad : row.avgDurationMs > 100 ? css.rateWarn : css.rateGood; return {Math.round(row.avgDurationMs)}ms; }, }, { key: 'p99DurationMs', header: 'p99 Duration', sortable: true, render: (_, row) => { const cls = row.p99DurationMs > 300 ? css.rateBad : row.p99DurationMs > 200 ? css.rateWarn : css.rateGood; return {Math.round(row.p99DurationMs)}ms; }, }, { key: 'errorCount', header: 'Errors', sortable: true, render: (_, row) => ( 10 ? css.rateBad : css.rateNeutral}> {row.errorCount} ), }, { key: 'errorRate', header: 'Error Rate', sortable: true, render: (_, row) => { const cls = row.errorRate > 1 ? css.rateBad : row.errorRate > 0.5 ? css.rateWarn : css.rateGood; return {row.errorRate.toFixed(2)}%; }, }, { key: 'sparkline', header: 'Trend', render: (_, row) => ( ), }, ]; } // ── Exchange table columns ─────────────────────────────────────────────────── const EXCHANGE_COLUMNS: Column[] = [ { key: 'status', header: 'Status', width: '80px', render: (_, row) => ( ), }, { key: 'executionId', header: 'Exchange ID', render: (_, row) => {row.executionId.slice(0, 12)}, }, { key: 'startTime', header: 'Started', sortable: true, render: (_, row) => new Date(row.startTime).toLocaleTimeString(), }, { key: 'durationMs', header: 'Duration', sortable: true, render: (_, row) => `${row.durationMs}ms`, }, ]; // ── Build KPI items ────────────────────────────────────────────────────────── function buildDetailKpiItems( stats: { totalCount: number; failedCount: number; avgDurationMs: number; p99LatencyMs: number; activeCount: number; prevTotalCount: number; prevFailedCount: number; prevP99LatencyMs: number; } | undefined, throughputSparkline: number[], errorSparkline: number[], latencySparkline: number[], ): KpiItem[] { const totalCount = stats?.totalCount ?? 0; const failedCount = stats?.failedCount ?? 0; const prevTotalCount = stats?.prevTotalCount ?? 0; const p99Ms = stats?.p99LatencyMs ?? 0; const prevP99Ms = stats?.prevP99LatencyMs ?? 0; const avgMs = stats?.avgDurationMs ?? 0; const activeCount = stats?.activeCount ?? 0; const errorRate = totalCount > 0 ? (failedCount / totalCount) * 100 : 0; const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount) * 100 : 100; const throughputPctChange = prevTotalCount > 0 ? Math.round(((totalCount - prevTotalCount) / prevTotalCount) * 100) : 0; return [ { label: 'Total Throughput', value: totalCount.toLocaleString(), trend: { label: throughputPctChange >= 0 ? `\u25B2 +${throughputPctChange}%` : `\u25BC ${throughputPctChange}%`, variant: throughputPctChange >= 0 ? 'success' as const : 'error' as const, }, subtitle: `${activeCount} in-flight`, sparkline: throughputSparkline, borderColor: 'var(--amber)', }, { label: 'System Error Rate', value: `${errorRate.toFixed(2)}%`, trend: { label: errorRate < 1 ? '\u25BC low' : `\u25B2 ${errorRate.toFixed(1)}%`, variant: errorRate < 1 ? 'success' as const : 'error' as const, }, subtitle: `${failedCount} errors / ${totalCount.toLocaleString()} total`, sparkline: errorSparkline, borderColor: errorRate < 1 ? 'var(--success)' : 'var(--error)', }, { label: 'Latency P99', value: `${p99Ms}ms`, trend: { label: p99Ms > prevP99Ms ? `\u25B2 +${p99Ms - prevP99Ms}ms` : `\u25BC ${prevP99Ms - p99Ms}ms`, variant: p99Ms > 300 ? 'error' as const : 'warning' as const, }, subtitle: `Avg ${avgMs}ms \u00B7 SLA <300ms`, sparkline: latencySparkline, borderColor: p99Ms > 300 ? 'var(--warning)' : 'var(--success)', }, { label: 'Success Rate', value: `${successRate.toFixed(1)}%`, trend: { label: '\u2194', variant: 'muted' as const }, subtitle: `${totalCount - failedCount} ok / ${failedCount} failed`, borderColor: 'var(--success)', }, { label: 'In-Flight', value: String(activeCount), trend: { label: '\u2194', variant: 'muted' as const }, subtitle: `${activeCount} active exchanges`, borderColor: 'var(--amber)', }, ]; } // ── Component ──────────────────────────────────────────────────────────────── 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 [recentSortField, setRecentSortField] = useState('startTime'); const [recentSortDir, setRecentSortDir] = useState<'asc' | 'desc'>('desc'); // ── Tap modal state ──────────────────────────────────────────────────────── const [tapModalOpen, setTapModalOpen] = useState(false); const [editingTap, setEditingTap] = useState(null); const [tapName, setTapName] = useState(''); const [tapProcessor, setTapProcessor] = useState(''); const [tapLanguage, setTapLanguage] = useState('simple'); const [tapTarget, setTapTarget] = useState<'INPUT' | 'OUTPUT' | 'BOTH'>('OUTPUT'); const [tapExpression, setTapExpression] = useState(''); const [tapType, setTapType] = useState<'BUSINESS_OBJECT' | 'CORRELATION' | 'EVENT' | 'CUSTOM'>('BUSINESS_OBJECT'); const [tapEnabled, setTapEnabled] = useState(true); const [deletingTap, setDeletingTap] = useState(null); // ── Test expression state ────────────────────────────────────────────────── const [testTab, setTestTab] = useState('recent'); const [testPayload, setTestPayload] = useState(''); const [testResult, setTestResult] = useState<{ result?: string; error?: string } | null>(null); const [testExchangeId, setTestExchangeId] = useState(''); const handleRecentSortChange = useCallback((key: string, dir: 'asc' | 'desc') => { setRecentSortField(key); setRecentSortDir(dir); }, []); // ── API queries ──────────────────────────────────────────────────────────── const { data: catalog } = useRouteCatalog(); const { data: diagram } = useDiagramByRoute(appId, routeId); const { data: processorMetrics, isLoading: processorLoading } = useProcessorMetrics(routeId ?? null, appId); const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId); const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId); const { data: recentResult, isLoading: recentLoading } = useSearchExecutions({ timeFrom, timeTo, routeId: routeId || undefined, application: appId || undefined, sortField: recentSortField, sortDir: recentSortDir, offset: 0, limit: 50, }); const { data: errorResult } = useSearchExecutions({ timeFrom, timeTo, routeId: routeId || undefined, application: appId || undefined, status: 'FAILED', offset: 0, limit: 200, }); // ── Application config ────────────────────────────────────────────────────── const config = useApplicationConfig(appId); const updateConfig = useUpdateApplicationConfig(); const testExpressionMutation = useTestExpression(); const isRecording = config.data?.routeRecording?.[routeId!] !== false; function toggleRecording() { if (!config.data) return; const routeRecording = { ...config.data.routeRecording, [routeId!]: !isRecording }; updateConfig.mutate({ ...config.data, routeRecording }); } // ── Derived data ─────────────────────────────────────────────────────────── 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() : '\u2014'; 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]); // Route flow from diagram const diagramFlows = useMemo(() => { if (!diagram?.nodes) return []; return toFlowSegments(mapDiagramToRouteNodes(diagram.nodes, [])).flows; }, [diagram]); // Processor table rows const processorRows: ProcessorRow[] = useMemo(() => (processorMetrics || []).map((p: any) => { const callCount = p.callCount ?? 0; const errorCount = p.errorCount ?? 0; const errRate = callCount > 0 ? (errorCount / callCount) * 100 : 0; return { id: p.processorId, processorId: p.processorId, type: classifyProcessorType(p.processorId ?? ''), callCount, avgDurationMs: p.avgDurationMs ?? 0, p99DurationMs: p.p99DurationMs ?? 0, errorCount, errorRate: Number(errRate.toFixed(2)), sparkline: p.sparkline ?? [], }; }), [processorMetrics], ); // Timeseries-derived data const throughputSparkline = useMemo(() => (timeseries?.buckets || []).map((b) => b.totalCount), [timeseries], ); const errorSparkline = useMemo(() => (timeseries?.buckets || []).map((b) => b.failedCount), [timeseries], ); const latencySparkline = useMemo(() => (timeseries?.buckets || []).map((b) => b.p99DurationMs), [timeseries], ); const chartData = useMemo(() => (timeseries?.buckets || []).map((b) => { const ts = new Date(b.time); return { time: !isNaN(ts.getTime()) ? ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '\u2014', throughput: b.totalCount, latency: b.avgDurationMs, errors: b.failedCount, successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100, }; }), [timeseries], ); // Exchange rows const exchangeRows: ExchangeRow[] = useMemo(() => (recentResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })), [recentResult], ); // Error patterns 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() : '\u2014', })) .sort((a, b) => b.count - a.count); }, [errorResult]); // Route taps — cross-reference config taps with diagram processor IDs const routeTaps = useMemo(() => { if (!config.data?.taps || !diagram) return []; const routeProcessorIds = new Set( (diagram.nodes || []).map((n: any) => n.id).filter(Boolean), ); return config.data.taps.filter(t => routeProcessorIds.has(t.processorId)); }, [config.data?.taps, diagram]); const activeTapCount = routeTaps.filter(t => t.enabled).length; // KPI items const kpiItems = useMemo(() => { const base = buildDetailKpiItems(stats, throughputSparkline, errorSparkline, latencySparkline); base.push({ label: 'Active Taps', value: String(activeTapCount), trend: { label: `${routeTaps.length} total`, variant: 'muted' as const }, subtitle: `${activeTapCount} enabled / ${routeTaps.length} configured`, borderColor: 'var(--running)', }); return base; }, [stats, throughputSparkline, errorSparkline, latencySparkline, activeTapCount, routeTaps.length]); const processorColumns = useMemo(() => makeProcessorColumns(styles), []); const tabs = [ { label: 'Performance', value: 'performance' }, { label: 'Recent Executions', value: 'executions', count: exchangeRows.length }, { label: 'Error Patterns', value: 'errors', count: errorPatterns.length }, { label: 'Taps', value: 'taps', count: routeTaps.length }, ]; // ── Tap helpers ────────────────────────────────────────────────────────── const processorOptions = useMemo(() => { if (!diagram?.nodes) return []; return (diagram.nodes as Array<{ id?: string; label?: string }>) .filter((n) => n.id) .map((n) => ({ value: n.id!, label: n.label || n.id! })); }, [diagram]); function openTapModal(tap: TapDefinition | null) { if (tap) { setEditingTap(tap); setTapName(tap.attributeName); setTapProcessor(tap.processorId); setTapLanguage(tap.language); setTapTarget(tap.target); setTapExpression(tap.expression); setTapType(tap.attributeType); setTapEnabled(tap.enabled); } else { setEditingTap(null); setTapName(''); setTapProcessor(processorOptions[0]?.value ?? ''); setTapLanguage('simple'); setTapTarget('OUTPUT'); setTapExpression(''); setTapType('BUSINESS_OBJECT'); setTapEnabled(true); } setTestResult(null); setTestPayload(''); setTestExchangeId(''); setTapModalOpen(true); } function saveTap() { if (!config.data) return; const tap: TapDefinition = { tapId: editingTap?.tapId || `tap-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, processorId: tapProcessor, target: tapTarget, expression: tapExpression, language: tapLanguage, attributeName: tapName, attributeType: tapType, enabled: tapEnabled, version: editingTap ? editingTap.version + 1 : 1, }; const taps = editingTap ? config.data.taps.map(t => t.tapId === editingTap.tapId ? tap : t) : [...(config.data.taps || []), tap]; updateConfig.mutate({ ...config.data, taps }); setTapModalOpen(false); } function deleteTap(tap: TapDefinition) { if (!config.data) return; const taps = config.data.taps.filter(t => t.tapId !== tap.tapId); updateConfig.mutate({ ...config.data, taps }); setDeletingTap(null); } function toggleTapEnabled(tap: TapDefinition) { if (!config.data) return; const taps = config.data.taps.map(t => t.tapId === tap.tapId ? { ...t, enabled: !t.enabled } : t, ); updateConfig.mutate({ ...config.data, taps }); } function runTestExpression() { if (!appId) return; const body = testTab === 'recent' ? testExchangeId : testPayload; testExpressionMutation.mutate( { application: appId, expression: tapExpression, language: tapLanguage, body, target: tapTarget }, { onSuccess: (data) => setTestResult(data), onError: (err) => setTestResult({ error: (err as Error).message }) }, ); } const tapColumns: Column[] = useMemo(() => [ { key: 'attributeName', header: 'Attribute', sortable: true, render: (_, row) => {row.attributeName}, }, { key: 'processorId', header: 'Processor', sortable: true, render: (_, row) => {row.processorId}, }, { key: 'expression', header: 'Expression', render: (_, row) => {row.expression}, }, { key: 'language', header: 'Language', render: (_, row) => , }, { key: 'target', header: 'Target', render: (_, row) => , }, { key: 'attributeType', header: 'Type', render: (_, row) => , }, { key: 'enabled', header: 'Enabled', width: '80px', render: (_, row) => ( toggleTapEnabled(row)} /> ), }, { key: 'actions' as any, header: '', width: '80px', render: (_, row) => (
), }, ], [config.data, processorOptions]); const languageOptions = [ { value: 'simple', label: 'Simple' }, { value: 'jsonpath', label: 'JSONPath' }, { value: 'xpath', label: 'XPath' }, { value: 'jq', label: 'jq' }, { value: 'groovy', label: 'Groovy' }, ]; const targetOptions = [ { value: 'INPUT', label: 'Input' }, { value: 'OUTPUT', label: 'Output' }, { value: 'BOTH', label: 'Both' }, ]; const typeChoices: Array<{ value: TapDefinition['attributeType']; label: string; tooltip: string }> = [ { value: 'BUSINESS_OBJECT', label: 'Business Object', tooltip: 'A key business identifier like orderId, customerId, or invoiceNumber' }, { value: 'CORRELATION', label: 'Correlation', tooltip: 'Used to correlate related exchanges across routes or services' }, { value: 'EVENT', label: 'Event', tooltip: 'Marks a business event occurrence like orderPlaced or paymentReceived' }, { value: 'CUSTOM', label: 'Custom', tooltip: 'General-purpose attribute for any other extraction need' }, ]; const recentExchangeOptions = useMemo(() => exchangeRows.slice(0, 20).map(e => ({ value: e.executionId, label: `${e.executionId.slice(0, 12)} — ${e.status}`, })), [exchangeRows], ); // ── Render ───────────────────────────────────────────────────────────────── return (
← {appId} routes {/* Route header card */}

{routeId}

Recording
Exchanges
{exchangeCount.toLocaleString()}
Last Seen
{lastSeen}
{/* KPI strip */} {/* Diagram + Processor Stats grid */}
Route Diagram
{diagramFlows.length > 0 ? ( ) : (
No diagram available for this route.
)}
Processor Stats
{processorLoading ? ( ) : processorRows.length > 0 ? ( ) : (
No processor data available.
)}
{/* Processor Performance table (full width) */}
Processor Performance
{processorRows.length} processors
{/* Route Flow section */} {diagramFlows.length > 0 && (
Route Flow
)} {/* Tabbed section: Performance charts, Recent Executions, Error Patterns */}
{activeTab === 'performance' && (
Throughput
({ x: i, y: d.throughput })), }]} height={200} />
Latency
({ x: i, y: d.latency })), }]} height={200} threshold={{ value: 300, label: 'SLA 300ms' }} />
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} onSortChange={handleRecentSortChange} /> )}
)} {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}
)) )}
)} {activeTab === 'taps' && (
Data Extraction Taps
{routeTaps.length === 0 ? (
No taps configured for this route. Add a tap to extract business attributes from exchange data.
) : ( ({ ...t, id: t.tapId }))} flush /> )}
)}
{/* Tap Modal */} setTapModalOpen(false)} title={editingTap ? 'Edit Tap' : 'Add Tap'} size="lg">
setTapName(e.target.value)} placeholder="e.g. orderId" /> setTapLanguage(e.target.value)} />