feat: replace UI with design system example pages wired to real API
Migrate all page components from the @cameleer/design-system v0.0.3 example UI, replacing mock data with real backend API hooks. This brings richer visuals (KpiStrip, GroupCard, RouteFlow, ProcessorTimeline, DateRangePicker, expandable rows) while preserving all existing API integration, auth, and routing infrastructure. Pages migrated: Dashboard, RoutesMetrics, RouteDetail, ExchangeDetail, AgentHealth, AgentInstance, OidcConfig, AuditLog, RBAC (Users/Groups/Roles). Also enhanced LayoutShell CommandPalette with real search data from catalog. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,21 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import {
|
||||
StatCard, Sparkline, MonoText, Badge,
|
||||
DataTable, AreaChart, LineChart, BarChart,
|
||||
KpiStrip,
|
||||
DataTable,
|
||||
AreaChart,
|
||||
LineChart,
|
||||
BarChart,
|
||||
Card,
|
||||
Sparkline,
|
||||
MonoText,
|
||||
Badge,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } 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 { useGlobalFilters } from '@cameleer/design-system';
|
||||
import type { RouteMetrics } from '../../api/types';
|
||||
import styles from './RoutesMetrics.module.css';
|
||||
|
||||
interface RouteRow {
|
||||
@@ -23,186 +31,322 @@ interface RouteRow {
|
||||
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, routeId } = useParams();
|
||||
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, routeId, appId);
|
||||
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, 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: any) => ({
|
||||
(metrics || []).map((m: RouteMetrics) => ({
|
||||
id: `${m.appId}/${m.routeId}`,
|
||||
...m,
|
||||
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],
|
||||
);
|
||||
|
||||
const sparklineData = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b: any) => b.totalCount as number),
|
||||
// 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],
|
||||
);
|
||||
|
||||
const chartData = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b: any, i: number) => {
|
||||
const ts = b.timestamp ? new Date(b.timestamp) : null;
|
||||
const time = ts && !isNaN(ts.getTime())
|
||||
// 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' })
|
||||
: String(i);
|
||||
return {
|
||||
time,
|
||||
throughput: b.totalCount ?? 0,
|
||||
latency: b.avgDurationMs ?? 0,
|
||||
errors: b.failedCount ?? 0,
|
||||
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
|
||||
};
|
||||
: '—';
|
||||
return { x: label, y: b.failedCount };
|
||||
}),
|
||||
[timeseries],
|
||||
}], [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],
|
||||
);
|
||||
|
||||
const columns: Column<RouteRow>[] = [
|
||||
{ key: 'routeId', header: 'Route', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
|
||||
{ key: 'appId', header: 'App', render: (v) => <Badge label={String(v)} color="auto" /> },
|
||||
{ key: 'exchangeCount', header: 'Exchanges', sortable: true },
|
||||
{
|
||||
key: 'successRate', header: 'Success', sortable: true,
|
||||
render: (v) => `${((v as number) * 100).toFixed(1)}%`,
|
||||
},
|
||||
{ key: 'avgDurationMs', header: 'Avg Duration', sortable: true, render: (v) => `${(v as number).toFixed(0)}ms` },
|
||||
{ key: 'p99DurationMs', header: 'P99', sortable: true, render: (v) => `${(v as number).toFixed(0)}ms` },
|
||||
{
|
||||
key: 'errorRate', header: 'Error Rate', sortable: true,
|
||||
render: (v) => {
|
||||
const rate = v as number;
|
||||
const cls = rate > 0.05 ? styles.rateBad : rate > 0.01 ? styles.rateWarn : styles.rateGood;
|
||||
return <span className={cls}>{(rate * 100).toFixed(1)}%</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sparkline', header: 'Trend', width: '80px',
|
||||
render: (v) => <Sparkline data={v as number[]} />,
|
||||
},
|
||||
];
|
||||
|
||||
const errorRate = stats?.totalCount
|
||||
? (((stats.failedCount ?? 0) / stats.totalCount) * 100)
|
||||
: 0;
|
||||
const prevErrorRate = stats?.prevTotalCount
|
||||
? (((stats.prevFailedCount ?? 0) / stats.prevTotalCount) * 100)
|
||||
: 0;
|
||||
const errorTrend: 'up' | 'down' | 'neutral' = errorRate > prevErrorRate ? 'up' : errorRate < prevErrorRate ? 'down' : 'neutral';
|
||||
const errorTrendValue = stats?.prevTotalCount
|
||||
? `${Math.abs(errorRate - prevErrorRate).toFixed(2)}%`
|
||||
: undefined;
|
||||
|
||||
const p99Ms = stats?.p99LatencyMs ?? 0;
|
||||
const prevP99Ms = stats?.prevP99LatencyMs ?? 0;
|
||||
const latencyTrend: 'up' | 'down' | 'neutral' = p99Ms > prevP99Ms ? 'up' : p99Ms < prevP99Ms ? 'down' : 'neutral';
|
||||
const latencyTrendValue = prevP99Ms ? `${Math.abs(p99Ms - prevP99Ms)}ms` : undefined;
|
||||
|
||||
const totalCount = stats?.totalCount ?? 0;
|
||||
const prevTotalCount = stats?.prevTotalCount ?? 0;
|
||||
const throughputTrend: 'up' | 'down' | 'neutral' = totalCount > prevTotalCount ? 'up' : totalCount < prevTotalCount ? 'down' : 'neutral';
|
||||
const throughputTrendValue = prevTotalCount
|
||||
? `${Math.abs(((totalCount - prevTotalCount) / prevTotalCount) * 100).toFixed(0)}%`
|
||||
: undefined;
|
||||
|
||||
const successRate = stats?.totalCount
|
||||
? (((stats.totalCount - (stats.failedCount ?? 0)) / stats.totalCount) * 100)
|
||||
: 100;
|
||||
|
||||
const activeCount = stats?.activeCount ?? 0;
|
||||
|
||||
const errorSparkline = (timeseries?.buckets || []).map((b: any) => b.failedCount as number);
|
||||
const latencySparkline = (timeseries?.buckets || []).map((b: any) => b.p99DurationMs as number);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.statStrip}>
|
||||
<StatCard
|
||||
label="Total Throughput"
|
||||
value={totalCount.toLocaleString()}
|
||||
detail="exchanges"
|
||||
trend={throughputTrend}
|
||||
trendValue={throughputTrendValue}
|
||||
accent="amber"
|
||||
sparkline={sparklineData}
|
||||
/>
|
||||
<StatCard
|
||||
label="System Error Rate"
|
||||
value={`${errorRate.toFixed(2)}%`}
|
||||
detail={`${stats?.failedCount ?? 0} errors / ${totalCount.toLocaleString()} total`}
|
||||
trend={errorTrend}
|
||||
trendValue={errorTrendValue}
|
||||
accent={errorRate < 1 ? 'success' : 'error'}
|
||||
sparkline={errorSparkline}
|
||||
/>
|
||||
<StatCard
|
||||
label="P99 Latency"
|
||||
value={`${p99Ms}ms`}
|
||||
detail={`Avg: ${stats?.avgDurationMs ?? 0}ms`}
|
||||
trend={latencyTrend}
|
||||
trendValue={latencyTrendValue}
|
||||
accent={p99Ms > 300 ? 'error' : p99Ms > 200 ? 'warning' : 'success'}
|
||||
sparkline={latencySparkline}
|
||||
/>
|
||||
<StatCard
|
||||
label="Success Rate"
|
||||
value={`${successRate.toFixed(1)}%`}
|
||||
detail={`${activeCount} active routes`}
|
||||
accent="success"
|
||||
sparkline={sparklineData.map((v, i) => {
|
||||
const failed = errorSparkline[i] ?? 0;
|
||||
return v > 0 ? ((v - failed) / v) * 100 : 100;
|
||||
})}
|
||||
/>
|
||||
<StatCard
|
||||
label="In-Flight"
|
||||
value={activeCount}
|
||||
detail="active exchanges"
|
||||
accent="amber"
|
||||
/>
|
||||
<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>
|
||||
<span className={styles.tableMeta}>{rows.length} routes</span>
|
||||
<div className={styles.tableRight}>
|
||||
<span className={styles.tableMeta}>{rows.length} routes</span>
|
||||
<Badge label="LIVE" color="success" />
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
columns={ROUTE_COLUMNS}
|
||||
data={rows}
|
||||
sortable
|
||||
pageSize={20}
|
||||
onRowClick={(row) => {
|
||||
const targetAppId = appId ?? row.appId;
|
||||
navigate(`/routes/${targetAppId}/${row.routeId}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{chartData.length > 0 && (
|
||||
{/* 2x2 chart grid */}
|
||||
{(timeseries?.buckets?.length ?? 0) > 0 && (
|
||||
<div className={styles.chartGrid}>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartTitle}>Throughput (msg/s)</div>
|
||||
<AreaChart series={[{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} yLabel="msg/s" height={200} />
|
||||
</div>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartTitle}>Latency (ms)</div>
|
||||
<LineChart
|
||||
series={[{ label: 'Latency', data: chartData.map((d: any, i: number) => ({ x: i, y: d.latency })) }]}
|
||||
yLabel="ms"
|
||||
<Card title="Throughput (msg/s)">
|
||||
<AreaChart
|
||||
series={throughputChartSeries}
|
||||
yLabel="msg/s"
|
||||
height={200}
|
||||
threshold={{ value: 300, label: 'SLA 300ms' }}
|
||||
className={styles.chart}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartTitle}>Errors by Route</div>
|
||||
<BarChart series={[{ label: 'Errors', data: chartData.map((d: any) => ({ x: d.time as string, y: d.errors })) }]} height={200} />
|
||||
</div>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartTitle}>Message Volume (msg/min)</div>
|
||||
<AreaChart series={[{ label: 'Volume', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} yLabel="msg/min" height={200} />
|
||||
</div>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user