Files
cameleer-server/ui/src/pages/Routes/RouteDetail.tsx
hsiegeln 457650012b
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m22s
CI / docker (push) Successful in 1m36s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s
fix: resolve UI glitches and improve consistency
- Sidebar: make +App button more subtle (lower opacity, brightens on hover)
- Sidebar: add filter chips to hide empty routes and offline/stale apps
- Sidebar: hide filter chips and +App button when sidebar is collapsed
- Exchange table: reorder columns to Status, Attributes, App, Route, Started, Duration; remove ExchangeId and Agent columns
- Exchange detail log tab: query by exchangeId only (no applicationId required), filter by processorId when processor selected
- KPI tooltips: styled tooltips with current/previous values, time period labels, percentage change, themed with DS variables
- KPI tooltips: fix overflow by left-aligning first two and right-aligning last two
- Exchange detail: show full datetime (YYYY-MM-DD HH:mm:ss.SSS) for start/end times
- Status labels: unify to title-case (Completed, Failed, Running) across all views
- Status filter buttons: match title-case labels (Completed, Warning, Failed, Running)
- Create app: show full external URL using routingDomain from env config or window.location.origin fallback
- Create app: add Runtime Type selector and Custom Arguments to Resources tab
- Create app: add Sensitive Keys tab with agent defaults, global keys, and app-specific keys (matching admin page design)
- Create app: add placeholder text to all Input fields for consistency
- Update design-system to 0.1.52 (sidebar collapse toggle fix)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:41:36 +02:00

990 lines
37 KiB
TypeScript

import { useState, useMemo, useCallback } from 'react';
import { useParams, useNavigate, useSearchParams, Link } from 'react-router';
import { Pencil, Trash2 } from 'lucide-react';
import {
KpiStrip,
Badge,
StatusDot,
DataTable,
EmptyState,
Tabs,
ThemedChart,
Area,
Line,
Bar,
ReferenceLine,
CHART_COLORS,
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 { useCatalog } 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 { useEnvironmentStore } from '../../api/environment-store';
import type { TapDefinition } from '../../api/queries/commands';
import type { ExecutionSummary } from '../../api/types';
import type { CatalogApp, CatalogRoute } from '../../api/queries/catalog';
import { buildFlowSegments } from '../../utils/diagram-mapping';
import { statusLabel } from '../../utils/format-utils';
import styles from './RouteDetail.module.css';
import tableStyles from '../../styles/table-section.module.css';
import rateStyles from '../../styles/rate-colors.module.css';
import chartCardStyles from '../../styles/chart-card.module.css';
import tapModalStyles from '../../components/TapConfigModal.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<string, string> = {
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<ProcessorRow>[] {
return [
{
key: 'processorId',
header: 'Processor',
sortable: true,
render: (_, row) => (
<span className={css.routeNameCell}>{row.processorId}</span>
),
},
{
key: 'callCount',
header: 'Invocations',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.callCount.toLocaleString()}</MonoText>
),
},
{
key: 'avgDurationMs',
header: 'Avg Duration',
sortable: true,
render: (_, row) => {
const cls = row.avgDurationMs > 200 ? css.rateBad : row.avgDurationMs > 100 ? css.rateWarn : css.rateGood;
return <MonoText size="sm" className={cls}>{Math.round(row.avgDurationMs)}ms</MonoText>;
},
},
{
key: 'p99DurationMs',
header: 'p99 Duration',
sortable: true,
render: (_, row) => {
const cls = row.p99DurationMs > 300 ? css.rateBad : row.p99DurationMs > 200 ? css.rateWarn : css.rateGood;
return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}ms</MonoText>;
},
},
{
key: 'errorCount',
header: 'Errors',
sortable: true,
render: (_, row) => (
<MonoText size="sm" className={row.errorCount > 10 ? css.rateBad : css.rateNeutral}>
{row.errorCount}
</MonoText>
),
},
{
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 <MonoText size="sm" className={cls}>{row.errorRate.toFixed(2)}%</MonoText>;
},
},
{
key: 'sparkline',
header: 'Trend',
render: (_, row) => (
<Sparkline data={row.sparkline} width={80} height={24} />
),
},
];
}
// ── Exchange table columns ───────────────────────────────────────────────────
const EXCHANGE_COLUMNS: Column<ExchangeRow>[] = [
{
key: 'status',
header: 'Status',
width: '80px',
render: (_, row) => (
<StatusDot variant={row.status === 'COMPLETED' ? 'success' : row.status === 'FAILED' ? 'error' : 'running'} />
),
},
{
key: 'executionId',
header: 'Exchange ID',
render: (_, row) => <MonoText size="xs">{row.executionId.slice(0, 12)}</MonoText>,
},
{
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 selectedEnv = useEnvironmentStore((s) => s.environment);
const { timeRange } = useGlobalFilters();
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
const [searchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState(searchParams.get('tab') || 'performance');
const [recentSortField, setRecentSortField] = useState<string>('startTime');
const [recentSortDir, setRecentSortDir] = useState<'asc' | 'desc'>('desc');
// ── Tap modal state ────────────────────────────────────────────────────────
const [tapModalOpen, setTapModalOpen] = useState(false);
const [editingTap, setEditingTap] = useState<TapDefinition | null>(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<TapDefinition | null>(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 } = useCatalog(selectedEnv);
const { data: diagram } = useDiagramByRoute(appId, routeId);
const { data: processorMetrics, isLoading: processorLoading } = useProcessorMetrics(routeId ?? null, appId, selectedEnv);
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId, selectedEnv);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId, selectedEnv);
const { data: recentResult, isLoading: recentLoading } = useSearchExecutions({
timeFrom,
timeTo,
routeId: routeId || undefined,
applicationId: appId || undefined,
environment: selectedEnv,
sortField: recentSortField,
sortDir: recentSortDir,
offset: 0,
limit: 50,
});
const { data: errorResult } = useSearchExecutions({
timeFrom,
timeTo,
routeId: routeId || undefined,
applicationId: appId || undefined,
environment: selectedEnv,
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: { ...config.data, routeRecording }, environment: selectedEnv });
}
// ── Derived data ───────────────────────────────────────────────────────────
const appEntry: CatalogApp | undefined = useMemo(() =>
(catalog || []).find((e: CatalogApp) => e.slug === appId),
[catalog, appId],
);
const routeSummary: CatalogRoute | undefined = useMemo(() =>
appEntry?.routes?.find((r: CatalogRoute) => 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 buildFlowSegments(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<string, { count: number; lastSeen: string }>();
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: { ...config.data, taps }, environment: selectedEnv });
setTapModalOpen(false);
}
function deleteTap(tap: TapDefinition) {
if (!config.data) return;
const taps = config.data.taps.filter(t => t.tapId !== tap.tapId);
updateConfig.mutate({ config: { ...config.data, taps }, environment: selectedEnv });
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: { ...config.data, taps }, environment: selectedEnv });
}
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<TapDefinition & { id: string }>[] = useMemo(() => [
{
key: 'attributeName',
header: 'Attribute',
sortable: true,
render: (_, row) => <span>{row.attributeName}</span>,
},
{
key: 'processorId',
header: 'Processor',
sortable: true,
render: (_, row) => <MonoText size="xs">{row.processorId}</MonoText>,
},
{
key: 'expression',
header: 'Expression',
render: (_, row) => <MonoText size="xs">{row.expression}</MonoText>,
},
{
key: 'language',
header: 'Language',
render: (_, row) => <Badge label={row.language} color="auto" />,
},
{
key: 'target',
header: 'Target',
render: (_, row) => <Badge label={row.target} color="auto" />,
},
{
key: 'attributeType',
header: 'Type',
render: (_, row) => <Badge label={row.attributeType} color="auto" />,
},
{
key: 'enabled',
header: 'Enabled',
width: '80px',
render: (_, row) => (
<Toggle
checked={row.enabled}
onChange={() => toggleTapEnabled(row)}
/>
),
},
{
key: 'actions' as any,
header: '',
width: '80px',
render: (_, row) => (
<div className={styles.tapActions}>
<button className={styles.tapActionBtn} title="Edit" onClick={() => openTapModal(row)}><Pencil size={14} /></button>
<button className={styles.tapActionBtn} title="Delete" onClick={() => setDeletingTap(row)}><Trash2 size={14} /></button>
</div>
),
},
], [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)}${statusLabel(e.status)}`,
})),
[exchangeRows],
);
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div>
<Link to={`/routes/${appId}`} className={styles.backLink}>
&larr; {appId} routes
</Link>
{/* Route header card */}
<div className={styles.headerCard}>
<div className={styles.headerRow}>
<div className={styles.headerLeft}>
<StatusDot variant={healthVariant} />
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700 }}>{routeId}</h2>
<Badge label={appId ?? ''} color="auto" />
</div>
<div className={styles.headerRight}>
<div className={styles.recordingPill}>
<span className={styles.recordingLabel}>Recording</span>
<Toggle checked={isRecording} onChange={toggleRecording} />
</div>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Exchanges</div>
<div className={styles.headerStatValue}>{exchangeCount.toLocaleString()}</div>
</div>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Last Seen</div>
<div className={styles.headerStatValue}>{lastSeen}</div>
</div>
</div>
</div>
</div>
{/* KPI strip */}
<KpiStrip items={kpiItems} />
{/* Diagram + Processor Stats grid */}
<div className={styles.diagramStatsGrid}>
<div className={styles.diagramPane}>
<div className={styles.paneTitle}>Route Diagram</div>
{diagramFlows.length > 0 ? (
<RouteFlow flows={diagramFlows} />
) : (
<EmptyState title="No diagram" description="No diagram available for this route." />
)}
</div>
<div className={styles.statsPane}>
<div className={styles.paneTitle}>Processor Stats</div>
{processorLoading ? (
<Spinner size="sm" />
) : processorRows.length > 0 ? (
<DataTable columns={processorColumns} data={processorRows} sortable pageSize={10} />
) : (
<EmptyState title="No processor data" description="No processor data available." />
)}
</div>
</div>
{/* Processor Performance table (full width) */}
<div className={`${tableStyles.tableSection} ${styles.tableSection}`}>
<div className={tableStyles.tableHeader}>
<span className={tableStyles.tableTitle}>Processor Performance</span>
<div className={tableStyles.tableRight}>
<span className={tableStyles.tableMeta}>{processorRows.length} processors</span>
<Badge label="AUTO" color="success" />
</div>
</div>
<DataTable
columns={processorColumns}
data={processorRows}
sortable
/>
</div>
{/* Route Flow section */}
{diagramFlows.length > 0 && (
<div className={styles.routeFlowSection}>
<div className={tableStyles.tableHeader}>
<span className={tableStyles.tableTitle}>Route Flow</span>
</div>
<RouteFlow flows={diagramFlows} />
</div>
)}
{/* Tabbed section: Performance charts, Recent Executions, Error Patterns */}
<div className={styles.tabSection}>
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
{activeTab === 'performance' && (
<div className={styles.chartGrid} style={{ marginTop: 16 }}>
<div className={chartCardStyles.chartCard}>
<div className={styles.chartTitle}>Throughput</div>
<ThemedChart data={chartData} height={200} xDataKey="time" yLabel="msg/s">
<Area dataKey="throughput" name="Throughput" stroke={CHART_COLORS[0]}
fill={CHART_COLORS[0]} fillOpacity={0.1} strokeWidth={2} dot={false} />
</ThemedChart>
</div>
<div className={chartCardStyles.chartCard}>
<div className={styles.chartTitle}>Latency</div>
<ThemedChart data={chartData} height={200} xDataKey="time" yLabel="ms">
<Line dataKey="latency" name="Latency" stroke={CHART_COLORS[0]} strokeWidth={2} dot={false} />
<ReferenceLine y={300} stroke="var(--error)" strokeDasharray="5 3"
label={{ value: 'SLA 300ms', position: 'right', fill: 'var(--error)', fontSize: 9 }} />
</ThemedChart>
</div>
<div className={chartCardStyles.chartCard}>
<div className={styles.chartTitle}>Errors</div>
<ThemedChart data={chartData} height={200} xDataKey="time" yLabel="errors">
<Bar dataKey="errors" name="Errors" fill={CHART_COLORS[1]} />
</ThemedChart>
</div>
<div className={chartCardStyles.chartCard}>
<div className={styles.chartTitle}>Success Rate</div>
<ThemedChart data={chartData} height={200} xDataKey="time" yLabel="%">
<Area dataKey="successRate" name="Success Rate" stroke={CHART_COLORS[0]}
fill={CHART_COLORS[0]} fillOpacity={0.1} strokeWidth={2} dot={false} />
</ThemedChart>
</div>
</div>
)}
{activeTab === 'executions' && (
<div className={styles.executionsTable} style={{ marginTop: 16 }}>
{recentLoading ? (
<div style={{ padding: 24, display: 'flex', justifyContent: 'center' }}>
<Spinner size="sm" />
</div>
) : (
<DataTable
columns={EXCHANGE_COLUMNS}
data={exchangeRows}
onRowClick={(row) => navigate(`/exchanges/${row.executionId}`)}
sortable
pageSize={20}
onSortChange={handleRecentSortChange}
/>
)}
</div>
)}
{activeTab === 'errors' && (
<div className={styles.errorPatterns} style={{ marginTop: 16 }}>
{errorPatterns.length === 0 ? (
<EmptyState title="No error patterns" description="No error patterns found in the selected time range." />
) : (
errorPatterns.map((ep, i) => (
<div key={i} className={styles.errorRow}>
<span className={styles.errorMessage} title={ep.message}>{ep.message}</span>
<span className={styles.errorCount}>{ep.count}x</span>
<span className={styles.errorTime}>{ep.lastSeen}</span>
</div>
))
)}
</div>
)}
{activeTab === 'taps' && (
<div className={styles.tapsSection}>
<div className={styles.tapsHeader}>
<span className={styles.tapsTitle}>Data Extraction Taps</span>
<Button variant="primary" size="sm" onClick={() => openTapModal(null)}>+ Add Tap</Button>
</div>
{routeTaps.length === 0 ? (
<EmptyState title="No taps" description="No taps configured for this route. Add a tap to extract business attributes from exchange data." />
) : (
<DataTable
columns={tapColumns}
data={routeTaps.map(t => ({ ...t, id: t.tapId }))}
flush
/>
)}
</div>
)}
</div>
{/* Tap Modal */}
<Modal open={tapModalOpen} onClose={() => setTapModalOpen(false)} title={editingTap ? 'Edit Tap' : 'Add Tap'} size="lg">
<div className={styles.tapModalBody}>
<FormField label="Attribute Name">
<Input value={tapName} onChange={(e) => setTapName(e.target.value)} placeholder="e.g. orderId" />
</FormField>
<FormField label="Processor">
<Select
options={processorOptions}
value={tapProcessor}
onChange={(e) => setTapProcessor(e.target.value)}
/>
</FormField>
<div className={styles.tapFormRow}>
<FormField label="Language">
<Select
options={languageOptions}
value={tapLanguage}
onChange={(e) => setTapLanguage(e.target.value)}
/>
</FormField>
<FormField label="Target">
<Select
options={targetOptions}
value={tapTarget}
onChange={(e) => setTapTarget(e.target.value as 'INPUT' | 'OUTPUT' | 'BOTH')}
/>
</FormField>
</div>
<FormField label="Expression">
<Textarea
className={styles.monoTextarea}
value={tapExpression}
onChange={(e) => setTapExpression(e.target.value)}
placeholder="e.g. ${body.orderId}"
rows={3}
/>
</FormField>
<FormField label="Type">
<div className={tapModalStyles.typeSelector}>
{typeChoices.map(tc => (
<button
key={tc.value}
type="button"
title={tc.tooltip}
className={`${tapModalStyles.typeOption} ${tapType === tc.value ? tapModalStyles.typeOptionActive : ''}`}
onClick={() => setTapType(tc.value)}
>
{tc.label}
</button>
))}
</div>
</FormField>
<div className={styles.tapFormRow}>
<FormField label="Enabled">
<Toggle checked={tapEnabled} onChange={() => setTapEnabled(!tapEnabled)} />
</FormField>
</div>
{/* Test Expression */}
<Collapsible title="Test Expression" defaultOpen>
<div className={tapModalStyles.testSection}>
<div className={tapModalStyles.testTabs}>
<button
type="button"
className={`${tapModalStyles.testTabBtn} ${testTab === 'recent' ? tapModalStyles.testTabBtnActive : ''}`}
onClick={() => setTestTab('recent')}
>
Recent Exchange
</button>
<button
type="button"
className={`${tapModalStyles.testTabBtn} ${testTab === 'custom' ? tapModalStyles.testTabBtnActive : ''}`}
onClick={() => setTestTab('custom')}
>
Custom Payload
</button>
</div>
{testTab === 'recent' && (
<div className={tapModalStyles.testBody}>
<Select
options={recentExchangeOptions.length > 0 ? recentExchangeOptions : [{ value: '', label: 'No recent exchanges' }]}
value={testExchangeId}
onChange={(e) => setTestExchangeId(e.target.value)}
/>
</div>
)}
{testTab === 'custom' && (
<div className={tapModalStyles.testBody}>
<Textarea
className={styles.monoTextarea}
value={testPayload}
onChange={(e) => setTestPayload(e.target.value)}
placeholder='{"orderId": "12345", ...}'
rows={4}
/>
</div>
)}
<div className={tapModalStyles.testBody}>
<Button
variant="secondary"
size="sm"
onClick={runTestExpression}
loading={testExpressionMutation.isPending}
disabled={!tapExpression}
>
Test
</Button>
</div>
{testResult && (
<div className={`${tapModalStyles.testResult} ${testResult.error ? tapModalStyles.testError : tapModalStyles.testSuccess}`}>
{testResult.error ?? testResult.result ?? 'No result'}
</div>
)}
</div>
</Collapsible>
<div className={styles.tapModalFooter}>
<Button variant="ghost" onClick={() => setTapModalOpen(false)}>Cancel</Button>
<Button variant="primary" onClick={saveTap} disabled={!tapName || !tapProcessor || !tapExpression}>Save</Button>
</div>
</div>
</Modal>
{/* Delete Tap Confirm */}
<ConfirmDialog
open={!!deletingTap}
onClose={() => setDeletingTap(null)}
onConfirm={() => deletingTap && deleteTap(deletingTap)}
title="Delete Tap"
message={`This will remove the tap "${deletingTap?.attributeName ?? ''}" from the configuration.`}
confirmText={deletingTap?.attributeName ?? ''}
confirmLabel="Delete"
variant="danger"
loading={updateConfig.isPending}
/>
</div>
);
}