Files
cameleer-server/ui/src/pages/Routes/RouteDetail.tsx

589 lines
20 KiB
TypeScript
Raw Normal View History

import { useState, useMemo } from 'react';
import { useParams, useNavigate, Link } from 'react-router';
import {
KpiStrip,
Badge,
StatusDot,
DataTable,
Tabs,
AreaChart,
LineChart,
BarChart,
RouteFlow,
Spinner,
MonoText,
Sparkline,
} 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 type { ExecutionSummary, AppCatalogEntry, RouteSummary } from '../../api/types';
import { mapDiagramToRouteNodes } 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<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 { timeRange } = useGlobalFilters();
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
const [activeTab, setActiveTab] = useState('performance');
// ── 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,
offset: 0,
limit: 50,
});
const { data: errorResult } = useSearchExecutions({
timeFrom,
timeTo,
routeId: routeId || undefined,
application: appId || undefined,
status: 'FAILED',
offset: 0,
limit: 200,
});
// ── 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 diagramNodes = useMemo(() => {
if (!diagram?.nodes) return [];
return mapDiagramToRouteNodes(diagram.nodes, []);
}, [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]);
// KPI items
const kpiItems = useMemo(() =>
buildDetailKpiItems(stats, throughputSparkline, errorSparkline, latencySparkline),
[stats, throughputSparkline, errorSparkline, latencySparkline],
);
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 },
];
// ── 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.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>
{diagramNodes.length > 0 ? (
<RouteFlow nodes={diagramNodes} />
) : (
<div className={styles.emptyText}>
No diagram available for this route.
</div>
)}
</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} />
) : (
<div className={styles.emptyText}>
No processor data available.
</div>
)}
</div>
</div>
{/* Processor Performance table (full width) */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Processor Performance</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>{processorRows.length} processors</span>
<Badge label="LIVE" color="success" />
</div>
</div>
<DataTable
columns={processorColumns}
data={processorRows}
sortable
/>
</div>
{/* Route Flow section */}
{diagramNodes.length > 0 && (
<div className={styles.routeFlowSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Route Flow</span>
</div>
<RouteFlow nodes={diagramNodes} />
</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={styles.chartCard}>
<div className={styles.chartTitle}>Throughput</div>
<AreaChart
series={[{
label: 'Throughput',
data: chartData.map((d, i) => ({ x: i, y: d.throughput })),
}]}
height={200}
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Latency</div>
<LineChart
series={[{
label: 'Latency',
data: chartData.map((d, i) => ({ x: i, y: d.latency })),
}]}
height={200}
threshold={{ value: 300, label: 'SLA 300ms' }}
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Errors</div>
<BarChart
series={[{
label: 'Errors',
data: chartData.map((d) => ({ x: d.time, y: d.errors })),
}]}
height={200}
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Success Rate</div>
<AreaChart
series={[{
label: 'Success Rate',
data: chartData.map((d, i) => ({ x: i, y: d.successRate })),
}]}
height={200}
/>
</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}
/>
)}
</div>
)}
{activeTab === 'errors' && (
<div className={styles.errorPatterns} style={{ marginTop: 16 }}>
{errorPatterns.length === 0 ? (
<div className={styles.emptyText}>
No error patterns found in the selected time range.
</div>
) : (
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>
)}
</div>
</div>
);
}