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>
331 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|