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

355 lines
11 KiB
TypeScript
Raw Normal View History

import { useMemo } from 'react';
import { useParams, useNavigate } from 'react-router';
import {
KpiStrip,
DataTable,
AreaChart,
LineChart,
BarChart,
Card,
Sparkline,
MonoText,
Badge,
} from '@cameleer/design-system';
import type { KpiItem, Column } from '@cameleer/design-system';
import { useGlobalFilters } from '@cameleer/design-system';
import { useRouteMetrics } from '../../api/queries/catalog';
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
import type { RouteMetrics } from '../../api/types';
import styles from './RoutesMetrics.module.css';
interface RouteRow {
id: string;
routeId: string;
appId: string;
exchangeCount: number;
successRate: number;
avgDurationMs: number;
p99DurationMs: number;
errorRate: number;
throughputPerSec: number;
sparkline: number[];
}
// ── Route table columns ──────────────────────────────────────────────────────
const ROUTE_COLUMNS: Column<RouteRow>[] = [
{
key: 'routeId',
header: 'Route',
sortable: true,
render: (_, row) => (
<span className={styles.routeNameCell}>{row.routeId}</span>
),
},
{
key: 'appId',
header: 'Application',
sortable: true,
render: (_, row) => (
<span className={styles.appCell}>{row.appId}</span>
),
},
{
key: 'exchangeCount',
header: 'Exchanges',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.exchangeCount.toLocaleString()}</MonoText>
),
},
{
key: 'successRate',
header: 'Success %',
sortable: true,
render: (_, row) => {
const pct = row.successRate * 100;
const cls = pct >= 99 ? styles.rateGood : pct >= 97 ? styles.rateWarn : styles.rateBad;
return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>;
},
},
{
key: 'avgDurationMs',
header: 'Avg Duration',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{Math.round(row.avgDurationMs)}ms</MonoText>
),
},
{
key: 'p99DurationMs',
header: 'p99 Duration',
sortable: true,
render: (_, row) => {
const cls = row.p99DurationMs > 300 ? styles.rateBad : row.p99DurationMs > 200 ? styles.rateWarn : styles.rateGood;
return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}ms</MonoText>;
},
},
{
key: 'errorRate',
header: 'Error Rate',
sortable: true,
render: (_, row) => {
const pct = row.errorRate * 100;
const cls = pct > 5 ? styles.rateBad : pct > 1 ? styles.rateWarn : styles.rateGood;
return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>;
},
},
{
key: 'sparkline',
header: 'Trend',
render: (_, row) => (
<Sparkline data={row.sparkline} width={80} height={24} />
),
},
];
// ── Build KPI items from backend stats ───────────────────────────────────────
function buildKpiItems(
stats: {
totalCount: number;
failedCount: number;
avgDurationMs: number;
p99LatencyMs: number;
activeCount: number;
prevTotalCount: number;
prevFailedCount: number;
prevP99LatencyMs: number;
} | undefined,
routeCount: number,
throughputSparkline: number[],
errorSparkline: 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 throughputPctChange = prevTotalCount > 0
? Math.round(((totalCount - prevTotalCount) / prevTotalCount) * 100)
: 0;
const throughputTrendLabel = throughputPctChange >= 0
? `\u25B2 +${throughputPctChange}%`
: `\u25BC ${throughputPctChange}%`;
const p50 = Math.round(avgMs * 0.5);
const p95 = Math.round(avgMs * 1.4);
const slaStatus = p99Ms > 300 ? 'BREACH' : 'OK';
const prevErrorRate = prevTotalCount > 0
? ((stats?.prevFailedCount ?? 0) / prevTotalCount) * 100
: 0;
const errorDelta = (errorRate - prevErrorRate).toFixed(1);
return [
{
label: 'Total Throughput',
value: totalCount.toLocaleString(),
trend: {
label: throughputTrendLabel,
variant: throughputPctChange >= 0 ? 'success' as const : 'error' as const,
},
subtitle: `${activeCount} active exchanges`,
sparkline: throughputSparkline,
borderColor: 'var(--amber)',
},
{
label: 'System Error Rate',
value: `${errorRate.toFixed(2)}%`,
trend: {
label: errorRate <= prevErrorRate ? `\u25BC ${errorDelta}%` : `\u25B2 +${errorDelta}%`,
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 Percentiles',
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: `P50 ${p50}ms \u00B7 P95 ${p95}ms \u00B7 SLA <300ms P99: ${slaStatus}`,
borderColor: p99Ms > 300 ? 'var(--warning)' : 'var(--success)',
},
{
label: 'Active Routes',
value: `${routeCount}`,
trend: { label: '\u2194 stable', variant: 'muted' as const },
subtitle: `${routeCount} routes reporting`,
borderColor: 'var(--running)',
},
{
label: 'In-Flight Exchanges',
value: String(activeCount),
trend: { label: '\u2194', variant: 'muted' as const },
subtitle: `${activeCount} active`,
sparkline: throughputSparkline,
borderColor: 'var(--amber)',
},
];
}
// ── Component ────────────────────────────────────────────────────────────────
export default function RoutesMetrics() {
const { appId } = useParams();
const navigate = useNavigate();
const { timeRange } = useGlobalFilters();
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
const { data: metrics } = useRouteMetrics(timeFrom, timeTo, appId);
const { data: stats } = useExecutionStats(timeFrom, timeTo, undefined, appId);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId);
// Map backend RouteMetrics[] to table rows
const rows: RouteRow[] = useMemo(() =>
(metrics || []).map((m: RouteMetrics) => ({
id: `${m.appId}/${m.routeId}`,
routeId: m.routeId,
appId: m.appId,
exchangeCount: m.exchangeCount,
successRate: m.successRate,
avgDurationMs: m.avgDurationMs,
p99DurationMs: m.p99DurationMs,
errorRate: m.errorRate,
throughputPerSec: m.throughputPerSec,
sparkline: m.sparkline ?? [],
})),
[metrics],
);
// Sparkline data from timeseries buckets
const throughputSparkline = useMemo(() =>
(timeseries?.buckets || []).map((b) => b.totalCount),
[timeseries],
);
const errorSparkline = useMemo(() =>
(timeseries?.buckets || []).map((b) => b.failedCount),
[timeseries],
);
// Chart series from timeseries buckets
const throughputChartSeries = useMemo(() => [{
label: 'Throughput',
data: (timeseries?.buckets || []).map((b, i) => ({
x: i as number,
y: b.totalCount,
})),
}], [timeseries]);
const latencyChartSeries = useMemo(() => [{
label: 'Latency',
data: (timeseries?.buckets || []).map((b, i) => ({
x: i as number,
y: b.avgDurationMs,
})),
}], [timeseries]);
const errorBarSeries = useMemo(() => [{
label: 'Errors',
data: (timeseries?.buckets || []).map((b) => {
const ts = new Date(b.time);
const label = !isNaN(ts.getTime())
? ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: '—';
return { x: label, y: b.failedCount };
}),
}], [timeseries]);
const volumeChartSeries = useMemo(() => [{
label: 'Volume',
data: (timeseries?.buckets || []).map((b, i) => ({
x: i as number,
y: b.totalCount,
})),
}], [timeseries]);
const kpiItems = useMemo(() =>
buildKpiItems(stats, rows.length, throughputSparkline, errorSparkline),
[stats, rows.length, throughputSparkline, errorSparkline],
);
return (
<div className={styles.content}>
<div className={styles.refreshIndicator}>
<span className={styles.refreshDot} />
<span className={styles.refreshText}>Auto-refresh: 30s</span>
</div>
{/* KPI header cards */}
<KpiStrip items={kpiItems} />
{/* Per-route performance table */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Per-Route Performance</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>{rows.length} routes</span>
<Badge label="LIVE" color="success" />
</div>
</div>
<DataTable
columns={ROUTE_COLUMNS}
data={rows}
sortable
onRowClick={(row) => {
const targetAppId = appId ?? row.appId;
navigate(`/routes/${targetAppId}/${row.routeId}`);
}}
/>
</div>
{/* 2x2 chart grid */}
{(timeseries?.buckets?.length ?? 0) > 0 && (
<div className={styles.chartGrid}>
<Card title="Throughput (msg/s)">
<AreaChart
series={throughputChartSeries}
yLabel="msg/s"
height={200}
className={styles.chart}
/>
</Card>
<Card title="Latency (ms)">
<LineChart
series={latencyChartSeries}
yLabel="ms"
threshold={{ value: 300, label: 'SLA 300ms' }}
height={200}
className={styles.chart}
/>
</Card>
<Card title="Errors by Route">
<BarChart
series={errorBarSeries}
height={200}
className={styles.chart}
/>
</Card>
<Card title="Message Volume (msg/min)">
<AreaChart
series={volumeChartSeries}
yLabel="msg/min"
height={200}
className={styles.chart}
/>
</Card>
</div>
)}
</div>
);
}