From 9dd78a7d2e1a2823a8deb7418f3d05022a802ba9 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:21:52 +0100 Subject: [PATCH] feat: Metrics dashboard page Implements the /metrics route with DateRangePicker bar, 5 KPI StatCards with sparklines (throughput, latency p99, error rate, success rate, active routes), per-route DataTable with trend sparklines, and a 2x2 chart grid (AreaChart throughput, LineChart latency with SLA threshold, BarChart errors, AreaChart volume). Uses AppShell + Sidebar + TopBar layout with mock data from src/mocks/metrics.ts. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/pages/Metrics/Metrics.module.css | 146 +++++++++++ src/pages/Metrics/Metrics.tsx | 346 +++++++++++++++++++++++++++ 2 files changed, 492 insertions(+) create mode 100644 src/pages/Metrics/Metrics.module.css create mode 100644 src/pages/Metrics/Metrics.tsx diff --git a/src/pages/Metrics/Metrics.module.css b/src/pages/Metrics/Metrics.module.css new file mode 100644 index 0000000..be6eaf2 --- /dev/null +++ b/src/pages/Metrics/Metrics.module.css @@ -0,0 +1,146 @@ +/* Scrollable content area */ +.content { + flex: 1; + overflow-y: auto; + padding: 20px 24px 40px; + min-width: 0; + background: var(--bg-body); +} + +/* Date range picker bar */ +.dateRangeBar { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 10px 16px; + margin-bottom: 16px; + box-shadow: var(--shadow-card); +} + +.refreshIndicator { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.refreshDot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--success); + box-shadow: 0 0 4px rgba(61, 124, 71, 0.5); + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.refreshText { + font-size: 11px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +/* KPI strip */ +.kpiStrip { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 10px; + margin-bottom: 16px; +} + +/* Route performance table */ +.tableSection { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + overflow: hidden; + margin-bottom: 20px; +} + +.tableHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border-subtle); +} + +.tableTitle { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.tableRight { + display: flex; + align-items: center; + gap: 10px; +} + +.tableMeta { + font-size: 11px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +/* Route name in table */ +.routeNameCell { + font-size: 12px; + font-weight: 500; + color: var(--text-primary); + font-family: var(--font-mono); +} + +/* Rate color classes */ +.rateGood { + color: var(--success); +} + +.rateWarn { + color: var(--warning); +} + +.rateBad { + color: var(--error); +} + +.rateNeutral { + color: var(--text-secondary); +} + +/* 2x2 chart grid */ +.chartGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.chartCard { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: 16px; + overflow: hidden; +} + +.chartTitle { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 12px; +} + +.chart { + width: 100%; +} diff --git a/src/pages/Metrics/Metrics.tsx b/src/pages/Metrics/Metrics.tsx new file mode 100644 index 0000000..ec87c77 --- /dev/null +++ b/src/pages/Metrics/Metrics.tsx @@ -0,0 +1,346 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import styles from './Metrics.module.css' + +// Layout +import { AppShell } from '../../design-system/layout/AppShell/AppShell' +import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar' +import { TopBar } from '../../design-system/layout/TopBar/TopBar' + +// Composites +import { AreaChart } from '../../design-system/composites/AreaChart/AreaChart' +import { LineChart } from '../../design-system/composites/LineChart/LineChart' +import { BarChart } from '../../design-system/composites/BarChart/BarChart' +import { DataTable } from '../../design-system/composites/DataTable/DataTable' +import type { Column } from '../../design-system/composites/DataTable/types' + +// Primitives +import { StatCard } from '../../design-system/primitives/StatCard/StatCard' +import { DateRangePicker } from '../../design-system/primitives/DateRangePicker/DateRangePicker' +import { Sparkline } from '../../design-system/primitives/Sparkline/Sparkline' +import { MonoText } from '../../design-system/primitives/MonoText/MonoText' +import { Badge } from '../../design-system/primitives/Badge/Badge' + +// Mock data +import { + throughputSeries, + latencySeries, + errorCountSeries, + routeMetrics, + type RouteMetricRow, +} from '../../mocks/metrics' +import { routes } from '../../mocks/routes' +import { agents } from '../../mocks/agents' + +// ─── Sidebar data (shared) ──────────────────────────────────────────────────── +const APPS = [ + { id: 'order-service', name: 'order-service', agentCount: 2, health: 'live' as const, execCount: 1433 }, + { id: 'payment-svc', name: 'payment-svc', agentCount: 1, health: 'live' as const, execCount: 912 }, + { id: 'shipment-tracker', name: 'shipment-tracker', agentCount: 2, health: 'live' as const, execCount: 471 }, + { id: 'notification-hub', name: 'notification-hub', agentCount: 1, health: 'stale' as const, execCount: 128 }, +] + +const SIDEBAR_ROUTES = routes.slice(0, 3).map((r) => ({ + id: r.id, + name: r.name, + execCount: r.execCount, +})) + +// ─── Metrics KPI cards (5 cards per spec) ───────────────────────────────────── +const METRIC_KPIS = [ + { + label: 'Throughput', + value: '47.2', + unit: 'msg/s', + trend: 'neutral' as const, + trendValue: '→', + detail: 'Capacity: 120 msg/s · 39%', + accent: 'running' as const, + sparkline: [44, 46, 45, 47, 48, 46, 47, 48, 46, 47, 48, 47, 46, 47], + }, + { + label: 'Latency p99', + value: '287ms', + trend: 'up' as const, + trendValue: '+23ms', + detail: 'SLA: <300ms · CLOSE', + accent: 'warning' as const, + sparkline: [198, 212, 205, 218, 224, 231, 238, 245, 252, 261, 268, 275, 281, 287], + }, + { + label: 'Error Rate', + value: '2.9%', + trend: 'up' as const, + trendValue: '+0.4%', + detail: '38 errors this shift', + accent: 'error' as const, + sparkline: [1.2, 1.8, 1.5, 2.1, 2.4, 2.2, 2.5, 2.6, 2.7, 2.8, 2.7, 2.9, 2.8, 2.9], + }, + { + label: 'Success Rate', + value: '97.1%', + trend: 'down' as const, + trendValue: '-0.4%', + detail: '3,147 ok · 56 warn · 38 err', + accent: 'success' as const, + sparkline: [98.2, 97.9, 98.1, 97.8, 97.5, 97.6, 97.4, 97.2, 97.3, 97.1, 97.0, 97.1, 97.2, 97.1], + }, + { + label: 'Active Routes', + value: 7, + trend: 'neutral' as const, + trendValue: '→', + detail: '4 healthy · 2 degraded · 1 stale', + accent: 'amber' as const, + sparkline: [7, 7, 7, 7, 7, 7, 7, 6, 7, 7, 7, 7, 7, 7], + }, +] + +// ─── Route metric row with id field (required by DataTable) ────────────────── +type RouteMetricRowWithId = RouteMetricRow & { id: string } + +const routeMetricsWithId: RouteMetricRowWithId[] = routeMetrics.map((r) => ({ + ...r, + id: r.routeId, +})) + +// ─── Route performance table columns ────────────────────────────────────────── +const ROUTE_COLUMNS: Column[] = [ + { + key: 'routeName', + header: 'Route', + sortable: true, + render: (_, row) => ( + {row.routeName} + ), + }, + { + key: 'execCount', + header: 'Executions', + sortable: true, + render: (_, row) => ( + {row.execCount.toLocaleString()} + ), + }, + { + key: 'successRate', + header: 'Success %', + sortable: true, + render: (_, row) => { + const cls = row.successRate >= 99 ? styles.rateGood : row.successRate >= 97 ? styles.rateWarn : styles.rateBad + return {row.successRate.toFixed(1)}% + }, + }, + { + key: 'avgDurationMs', + header: 'Avg Duration', + sortable: true, + render: (_, row) => ( + {row.avgDurationMs}ms + ), + }, + { + key: 'p99DurationMs', + header: 'p99 Duration', + sortable: true, + render: (_, row) => { + const cls = row.p99DurationMs > 300 ? styles.rateBad : row.p99DurationMs > 200 ? styles.rateWarn : styles.rateGood + return {row.p99DurationMs}ms + }, + }, + { + key: 'errorCount', + header: 'Errors', + sortable: true, + render: (_, row) => ( + 10 ? styles.rateBad : styles.rateNeutral}> + {row.errorCount} + + ), + }, + { + key: 'sparkline', + header: 'Trend', + render: (_, row) => ( + + ), + }, +] + +// ─── Build bar chart data from error series ──────────────────────────────────── +function buildErrorBarSeries() { + // Take every 5th point and format x as time label + const sampleInterval = 5 + return errorCountSeries.map((s) => ({ + label: s.label, + data: s.data + .filter((_, i) => i % sampleInterval === 0) + .map((pt) => ({ + x: pt.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + y: Math.round(pt.value), + })), + })) +} + +// ─── Build volume area chart (derived from throughput) ───────────────────────── +function buildVolumeSeries() { + return throughputSeries.map((s) => ({ + label: s.label, + data: s.data.map((pt) => ({ + x: pt.timestamp, + y: Math.round(pt.value * 60), // approx msg/min + })), + })) +} + +const ERROR_BAR_SERIES = buildErrorBarSeries() +const VOLUME_SERIES = buildVolumeSeries() + +// Convert MetricSeries (from mocks) to ChartSeries format +function convertSeries(series: typeof throughputSeries) { + return series.map((s) => ({ + label: s.label, + data: s.data.map((pt) => ({ x: pt.timestamp, y: pt.value })), + })) +} + +// ─── Metrics page ───────────────────────────────────────────────────────────── +export function Metrics() { + const navigate = useNavigate() + const [activeItem, setActiveItem] = useState('order-service') + const [dateRange, setDateRange] = useState({ + start: new Date('2026-03-18T06:00:00'), + end: new Date('2026-03-18T09:15:00'), + }) + + function handleItemClick(id: string) { + setActiveItem(id) + // Navigate to route detail if it's a route + const route = routes.find((r) => r.id === id) + if (route) navigate(`/routes/${id}`) + } + + return ( + + } + > + {/* Top bar */} + + + {/* Scrollable content */} +
+ + {/* Date range picker bar */} +
+ +
+ + Auto-refresh: 30s +
+
+ + {/* KPI stat cards (5) */} +
+ {METRIC_KPIS.map((kpi, i) => ( + + ))} +
+ + {/* Per-route performance table */} +
+
+ Per-Route Performance +
+ {routeMetrics.length} routes + +
+
+ navigate(`/routes/${row.routeId}`)} + /> +
+ + {/* 2x2 chart grid */} +
+ {/* Throughput area chart */} +
+
Throughput (msg/s)
+ +
+ + {/* Latency line chart with SLA threshold */} +
+
Latency (ms)
+ +
+ + {/* Error bar chart */} +
+
Errors by Route
+ +
+ + {/* Volume area chart */} +
+
Message Volume (msg/min)
+ +
+
+ +
+
+ ) +}