Files
cameleer-server/ui/src/pages/Routes/RoutesMetrics.tsx
hsiegeln 0dae1f1cc7
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled
CI / build (push) Has been cancelled
feat: migrate agent charts to ThemedChart + Recharts
Replace custom LineChart/AreaChart/BarChart usage with ThemedChart
wrapper. Data format changed from ChartSeries[] to Recharts-native
flat objects. Uses DS v0.1.47.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 19:44:55 +02:00

331 lines
11 KiB
TypeScript

import { useMemo } from 'react';
import { useParams, useNavigate } from 'react-router';
import {
KpiStrip,
DataTable,
ThemedChart,
Area,
Line,
Bar,
ReferenceLine,
CHART_COLORS,
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 { useEnvironmentStore } from '../../api/environment-store';
import type { RouteMetrics } from '../../api/types';
import styles from './RoutesMetrics.module.css';
import tableStyles from '../../styles/table-section.module.css';
import refreshStyles from '../../styles/refresh-indicator.module.css';
import rateStyles from '../../styles/rate-colors.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 ? rateStyles.rateGood : pct >= 97 ? rateStyles.rateWarn : rateStyles.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 ? rateStyles.rateBad : row.p99DurationMs > 200 ? rateStyles.rateWarn : rateStyles.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 ? rateStyles.rateBad : pct > 1 ? rateStyles.rateWarn : rateStyles.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 selectedEnv = useEnvironmentStore((s) => s.environment);
const { timeRange } = useGlobalFilters();
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
const { data: metrics } = useRouteMetrics(timeFrom, timeTo, appId, selectedEnv);
const { data: stats } = useExecutionStats(timeFrom, timeTo, undefined, appId, selectedEnv);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId, selectedEnv);
// 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],
);
// Flat chart data from timeseries buckets
const chartData = useMemo(() =>
(timeseries?.buckets || []).map((b, i) => ({
idx: i,
throughput: b.totalCount,
latency: b.avgDurationMs,
errors: b.failedCount,
volume: b.totalCount,
})),
[timeseries],
);
const kpiItems = useMemo(() =>
buildKpiItems(stats, rows.length, throughputSparkline, errorSparkline),
[stats, rows.length, throughputSparkline, errorSparkline],
);
return (
<div className={styles.content}>
<div className={refreshStyles.refreshIndicator}>
<span className={refreshStyles.refreshDot} />
<span className={refreshStyles.refreshText}>Auto-refresh: 30s</span>
</div>
{/* KPI header cards */}
<KpiStrip items={kpiItems} />
{/* Per-route performance table */}
<div className={tableStyles.tableSection}>
<div className={tableStyles.tableHeader}>
<span className={tableStyles.tableTitle}>Per-Route Performance</span>
<div className={tableStyles.tableRight}>
<span className={tableStyles.tableMeta}>{rows.length} routes</span>
<Badge label="AUTO" 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 */}
{chartData.length > 0 && (
<div className={styles.chartGrid}>
<Card title="Throughput (msg/s)">
<ThemedChart data={chartData} height={200} xDataKey="idx" yLabel="msg/s">
<Area dataKey="throughput" name="Throughput" stroke={CHART_COLORS[0]}
fill={CHART_COLORS[0]} fillOpacity={0.1} strokeWidth={2} dot={false} />
</ThemedChart>
</Card>
<Card title="Latency (ms)">
<ThemedChart data={chartData} height={200} xDataKey="idx" 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>
</Card>
<Card title="Errors by Bucket">
<ThemedChart data={chartData} height={200} xDataKey="idx" yLabel="errors">
<Bar dataKey="errors" name="Errors" fill={CHART_COLORS[1]} />
</ThemedChart>
</Card>
<Card title="Message Volume (msg/min)">
<ThemedChart data={chartData} height={200} xDataKey="idx" yLabel="msg/min">
<Area dataKey="volume" name="Volume" stroke={CHART_COLORS[0]}
fill={CHART_COLORS[0]} fillOpacity={0.1} strokeWidth={2} dot={false} />
</ThemedChart>
</Card>
</div>
)}
</div>
);
}