diff --git a/src/App.tsx b/src/App.tsx index a8e814b..6a04477 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ import { useMemo, useCallback } from 'react' import { Routes, Route, Navigate, useNavigate } from 'react-router-dom' import { Dashboard } from './pages/Dashboard/Dashboard' -import { Metrics } from './pages/Metrics/Metrics' +import { Routes as RoutesPage } from './pages/Routes/Routes' import { ExchangeDetail } from './pages/ExchangeDetail/ExchangeDetail' import { AgentHealth } from './pages/AgentHealth/AgentHealth' import { AgentInstance } from './pages/AgentInstance/AgentInstance' @@ -82,7 +82,9 @@ export default function App() { } /> } /> } /> - } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/design-system/layout/Sidebar/Sidebar.test.tsx b/src/design-system/layout/Sidebar/Sidebar.test.tsx index 740c373..ecaf6ed 100644 --- a/src/design-system/layout/Sidebar/Sidebar.test.tsx +++ b/src/design-system/layout/Sidebar/Sidebar.test.tsx @@ -74,9 +74,9 @@ describe('Sidebar', () => { expect(screen.getByText('Agents')).toBeInTheDocument() }) - it('renders Metrics nav link', () => { + it('renders Routes nav link', () => { renderSidebar() - expect(screen.getByText('Metrics')).toBeInTheDocument() + expect(screen.getByText('Routes')).toBeInTheDocument() }) it('renders bottom links', () => { @@ -87,9 +87,9 @@ describe('Sidebar', () => { it('renders app names in the Applications tree', () => { renderSidebar() - // order-service appears in both Applications and Agents trees + // order-service appears in Applications, Routes, and Agents trees expect(screen.getAllByText('order-service').length).toBeGreaterThanOrEqual(1) - expect(screen.getByText('payment-svc')).toBeInTheDocument() + expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1) }) it('renders exchange count badges', () => { @@ -130,8 +130,8 @@ describe('Sidebar', () => { const searchInput = screen.getByPlaceholderText('Filter...') await user.type(searchInput, 'payment') - // payment-svc should still be visible - expect(screen.getByText('payment-svc')).toBeInTheDocument() + // payment-svc should still be visible (may appear in multiple trees) + expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1) }) it('expands tree to show children when chevron is clicked', async () => { diff --git a/src/design-system/layout/Sidebar/Sidebar.tsx b/src/design-system/layout/Sidebar/Sidebar.tsx index 1cb1848..ecb9b84 100644 --- a/src/design-system/layout/Sidebar/Sidebar.tsx +++ b/src/design-system/layout/Sidebar/Sidebar.tsx @@ -63,6 +63,29 @@ function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] { })) } +function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] { + return apps + .filter((app) => app.routes.length > 0) + .map((app) => ({ + id: `routes:${app.id}`, + label: app.name, + icon: , + badge: `${app.routes.length} routes`, + path: `/routes/${app.id}`, + starrable: false, + starKey: `routes:${app.id}`, + children: app.routes.map((route) => ({ + id: `routestat:${app.id}:${route.id}`, + starKey: `routes:${app.id}:${route.id}`, + label: route.name, + icon: , + badge: formatCount(route.exchangeCount), + path: `/routes/${app.id}/${route.id}`, + starrable: false, + })), + })) +} + function buildAgentTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] { return apps .filter((app) => app.agents.length > 0) @@ -196,6 +219,7 @@ export function Sidebar({ apps, className }: SidebarProps) { const [search, setSearch] = useState('') const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true') const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true') + const [routesCollapsed, _setRoutesCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:routes-collapsed') === 'true') const setAppsCollapsed = (updater: (v: boolean) => boolean) => { _setAppsCollapsed((prev) => { @@ -212,6 +236,14 @@ export function Sidebar({ apps, className }: SidebarProps) { return next }) } + + const setRoutesCollapsed = (updater: (v: boolean) => boolean) => { + _setRoutesCollapsed((prev) => { + const next = updater(prev) + localStorage.setItem('cameleer:sidebar:routes-collapsed', String(next)) + return next + }) + } const navigate = useNavigate() const location = useLocation() const { starredIds, isStarred, toggleStar } = useStarred() @@ -219,6 +251,7 @@ export function Sidebar({ apps, className }: SidebarProps) { // Build tree data const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps]) const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [apps]) + const routeNodes = useMemo(() => buildRouteTreeNodes(apps), [apps]) // Sidebar reveal from Cmd-K navigation (passed via location state) const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null @@ -374,23 +407,38 @@ export function Sidebar({ apps, className }: SidebarProps) { )} - {/* Flat nav links */} -
-
navigate('/metrics')} - role="button" - tabIndex={0} - onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/metrics') }} - > - -
-
Metrics
-
+ {/* Routes tree (collapsible, label navigates to /routes) */} +
+
+ + navigate('/routes')} + role="button" + tabIndex={0} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/routes') }} + > + Routes +
+ {!routesCollapsed && ( + + )}
{/* No results message */} diff --git a/src/mocks/metrics.ts b/src/mocks/metrics.ts index 5f6abe5..6d2c58e 100644 --- a/src/mocks/metrics.ts +++ b/src/mocks/metrics.ts @@ -147,6 +147,7 @@ export const errorCountSeries: MetricSeries[] = [ export interface RouteMetricRow { routeId: string routeName: string + appId: string exchangeCount: number successRate: number avgDurationMs: number @@ -159,6 +160,7 @@ export const routeMetrics: RouteMetricRow[] = [ { routeId: 'order-intake', routeName: 'order-intake', + appId: 'order-service', exchangeCount: 892, successRate: 99.2, avgDurationMs: 88, @@ -169,6 +171,7 @@ export const routeMetrics: RouteMetricRow[] = [ { routeId: 'order-enrichment', routeName: 'order-enrichment', + appId: 'order-service', exchangeCount: 541, successRate: 97.6, avgDurationMs: 156, @@ -179,6 +182,7 @@ export const routeMetrics: RouteMetricRow[] = [ { routeId: 'payment-process', routeName: 'payment-process', + appId: 'payment-svc', exchangeCount: 414, successRate: 96.1, avgDurationMs: 234, @@ -186,9 +190,21 @@ export const routeMetrics: RouteMetricRow[] = [ errorCount: 16, sparkline: [210, 225, 232, 218, 241, 235, 228, 242, 238, 231, 244, 237, 233, 234], }, + { + routeId: 'payment-validate', + routeName: 'payment-validate', + appId: 'payment-svc', + exchangeCount: 498, + successRate: 99.8, + avgDurationMs: 142, + p99DurationMs: 198, + errorCount: 1, + sparkline: [138, 141, 140, 143, 145, 142, 144, 141, 139, 143, 142, 140, 141, 142], + }, { routeId: 'shipment-dispatch', routeName: 'shipment-dispatch', + appId: 'shipment-tracker', exchangeCount: 387, successRate: 98.4, avgDurationMs: 118, @@ -196,4 +212,26 @@ export const routeMetrics: RouteMetricRow[] = [ errorCount: 6, sparkline: [112, 115, 118, 114, 120, 116, 119, 117, 118, 121, 116, 118, 119, 118], }, + { + routeId: 'shipment-track', + routeName: 'shipment-track', + appId: 'shipment-tracker', + exchangeCount: 923, + successRate: 99.5, + avgDurationMs: 94, + p99DurationMs: 167, + errorCount: 5, + sparkline: [88, 91, 93, 95, 92, 94, 96, 93, 91, 95, 94, 92, 93, 94], + }, + { + routeId: 'notification-dispatch', + routeName: 'notification-dispatch', + appId: 'notification-hub', + exchangeCount: 471, + successRate: 98.9, + avgDurationMs: 62, + p99DurationMs: 124, + errorCount: 5, + sparkline: [58, 60, 63, 61, 64, 62, 60, 63, 65, 62, 61, 63, 62, 62], + }, ] diff --git a/src/pages/Metrics/Metrics.module.css b/src/pages/Metrics/Metrics.module.css deleted file mode 100644 index cb5b3ae..0000000 --- a/src/pages/Metrics/Metrics.module.css +++ /dev/null @@ -1,134 +0,0 @@ -/* Scrollable content area */ -.content { - flex: 1; - overflow-y: auto; - padding: 20px 24px 40px; - min-width: 0; - background: var(--bg-body); -} - -.refreshIndicator { - display: flex; - align-items: center; - gap: 6px; - margin-bottom: 12px; - justify-content: flex-end; -} - -.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 deleted file mode 100644 index f371b8c..0000000 --- a/src/pages/Metrics/Metrics.tsx +++ /dev/null @@ -1,309 +0,0 @@ -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 { 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 { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar' - -const ROUTE_TO_APP = buildRouteToAppMap() - -// ─── 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: 'exchangeCount', - header: 'Exchanges', - sortable: true, - render: (_, row) => ( - {row.exchangeCount.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() - - return ( - - } - > - {/* Top bar */} - - - {/* Scrollable content */} -
- - {/* Auto-refresh indicator */} -
- - Auto-refresh: 30s -
- - {/* KPI stat cards (5) */} -
- {METRIC_KPIS.map((kpi, i) => ( - - ))} -
- - {/* Per-route performance table */} -
-
- Per-Route Performance -
- {routeMetrics.length} routes - -
-
- navigate(`/apps/${ROUTE_TO_APP.get(row.routeId) ?? row.routeId}/${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)
- -
-
- -
-
- ) -} diff --git a/src/pages/Routes/Routes.module.css b/src/pages/Routes/Routes.module.css new file mode 100644 index 0000000..80d4abf --- /dev/null +++ b/src/pages/Routes/Routes.module.css @@ -0,0 +1,359 @@ +/* Scrollable content area */ +.content { + flex: 1; + overflow-y: auto; + padding: 20px 24px 40px; + min-width: 0; + background: var(--bg-body); +} + +.refreshIndicator { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 12px; + justify-content: flex-end; +} + +.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: 12px; + margin-bottom: 20px; +} + +/* KPI card */ +.kpiCard { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 16px 18px 12px; + box-shadow: var(--shadow-card); + position: relative; + overflow: hidden; + transition: box-shadow 0.15s; +} + +.kpiCard:hover { + box-shadow: var(--shadow-md); +} + +.kpiCard::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; +} + +.kpiCardAmber::before { background: linear-gradient(90deg, var(--amber), transparent); } +.kpiCardGreen::before { background: linear-gradient(90deg, var(--success), transparent); } +.kpiCardError::before { background: linear-gradient(90deg, var(--error), transparent); } +.kpiCardTeal::before { background: linear-gradient(90deg, var(--running), transparent); } +.kpiCardWarn::before { background: linear-gradient(90deg, var(--warning), transparent); } + +.kpiLabel { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--text-muted); + margin-bottom: 6px; +} + +.kpiValueRow { + display: flex; + align-items: baseline; + gap: 6px; + margin-bottom: 4px; +} + +.kpiValue { + font-family: var(--font-mono); + font-size: 26px; + font-weight: 600; + line-height: 1.2; +} + +.kpiValueAmber { color: var(--amber); } +.kpiValueGreen { color: var(--success); } +.kpiValueError { color: var(--error); } +.kpiValueTeal { color: var(--running); } +.kpiValueWarn { color: var(--warning); } + +.kpiUnit { + font-size: 12px; + color: var(--text-muted); +} + +.kpiTrend { + font-family: var(--font-mono); + font-size: 11px; + display: inline-flex; + align-items: center; + gap: 2px; + margin-left: auto; +} + +.trendUpGood { color: var(--success); } +.trendUpBad { color: var(--error); } +.trendDownGood { color: var(--success); } +.trendDownBad { color: var(--error); } +.trendFlat { color: var(--text-muted); } + +.kpiDetail { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} + +.kpiDetailStrong { + color: var(--text-secondary); + font-weight: 600; +} + +.kpiSparkline { + margin-top: 8px; + height: 32px; +} + +/* Latency percentiles card */ +.latencyValues { + display: flex; + gap: 12px; + margin-bottom: 4px; +} + +.latencyItem { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.latencyLabel { + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); +} + +.latencyVal { + font-family: var(--font-mono); + font-size: 18px; + font-weight: 600; + line-height: 1.2; +} + +.latValGreen { color: var(--success); } +.latValAmber { color: var(--amber); } +.latValRed { color: var(--error); } + +.latencyTrend { + font-family: var(--font-mono); + font-size: 9px; +} + +/* Active routes donut */ +.donutWrap { + display: flex; + align-items: center; + gap: 10px; + margin-top: 4px; +} + +.donutLabel { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + color: var(--text-secondary); +} + +.donutLegend { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 10px; + color: var(--text-muted); +} + +.donutLegendActive { + color: var(--running); + font-weight: 600; +} + +/* 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%; +} + +/* Processor type badges */ +.processorType { + display: inline-flex; + align-items: center; + padding: 2px 6px; + border-radius: var(--radius-sm); + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.typeConsumer { + background: var(--running-bg); + color: var(--running); +} + +.typeProducer { + background: var(--success-bg); + color: var(--success); +} + +.typeEnricher { + background: var(--amber-bg); + color: var(--amber); +} + +.typeValidator { + background: var(--running-bg); + color: var(--running); +} + +.typeTransformer { + background: var(--bg-hover); + color: var(--text-muted); +} + +.typeRouter { + background: #F3EEFA; + color: #7C3AED; +} + +.typeProcessor { + background: var(--bg-hover); + color: var(--text-secondary); +} + +/* Route flow section */ +.routeFlowSection { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: 16px; + margin-top: 16px; +} + +/* Application column in table */ +.appCell { + font-size: 12px; + color: var(--text-secondary); +} diff --git a/src/pages/Routes/Routes.tsx b/src/pages/Routes/Routes.tsx new file mode 100644 index 0000000..106034d --- /dev/null +++ b/src/pages/Routes/Routes.tsx @@ -0,0 +1,595 @@ +import { useMemo } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import styles from './Routes.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' +import { RouteFlow } from '../../design-system/composites/RouteFlow/RouteFlow' +import type { RouteNode } from '../../design-system/composites/RouteFlow/RouteFlow' + +// Primitives +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 { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar' + +const ROUTE_TO_APP = buildRouteToAppMap() + +// ─── KPI Header Strip (matches mock-v3-metrics-dashboard) ──────────────────── +function KpiHeader({ scopedMetrics }: { scopedMetrics: RouteMetricRow[] }) { + const totalExchanges = scopedMetrics.reduce((sum, r) => sum + r.exchangeCount, 0) + const totalErrors = scopedMetrics.reduce((sum, r) => sum + r.errorCount, 0) + const errorRate = totalExchanges > 0 ? ((totalErrors / totalExchanges) * 100) : 0 + const avgLatency = scopedMetrics.length > 0 + ? Math.round(scopedMetrics.reduce((sum, r) => sum + r.avgDurationMs, 0) / scopedMetrics.length) + : 0 + const p99Latency = scopedMetrics.length > 0 + ? Math.max(...scopedMetrics.map((r) => r.p99DurationMs)) + : 0 + const avgSuccessRate = scopedMetrics.length > 0 + ? Number((scopedMetrics.reduce((sum, r) => sum + r.successRate, 0) / scopedMetrics.length).toFixed(1)) + : 0 + const throughputPerSec = totalExchanges > 0 ? (totalExchanges / 360).toFixed(1) : '0' + const activeRoutes = scopedMetrics.length + const totalRoutes = routeMetrics.length + + return ( +
+ {/* Card 1: Total Throughput */} +
+
Total Throughput
+
+ {totalExchanges.toLocaleString()} + msg/shift + ▲ +8% +
+
+ {throughputPerSec} msg/s · Capacity 39% +
+
+ +
+
+ + {/* Card 2: System Error Rate */} +
+
System Error Rate
+
+ {errorRate.toFixed(2)}% + + {errorRate < 1 ? '\u25BC -0.1%' : '\u25B2 +0.4%'} + +
+
+ {totalErrors} errors / {totalExchanges.toLocaleString()} total (6h) +
+
+ +
+
+ + {/* Card 3: Latency Percentiles */} +
300 ? styles.kpiCardWarn : styles.kpiCardGreen}`}> +
Latency Percentiles
+
+
+ P50 + {Math.round(avgLatency * 0.5)}ms + ▼3 +
+
+ P95 + 150 ? styles.latValAmber : styles.latValGreen}`}>{Math.round(avgLatency * 1.4)}ms + ▲12 +
+
+ P99 + 300 ? styles.latValRed : styles.latValAmber}`}>{p99Latency}ms + ▲28 +
+
+
+ SLA: <300ms P99 · {p99Latency > 300 + ? BREACH + : OK} +
+
+ + {/* Card 4: Active Routes */} +
+
Active Routes
+
+ {activeRoutes} + of {totalRoutes} + ↔ stable +
+
+ + + + +
+ {activeRoutes} active + {totalRoutes - activeRoutes} stopped +
+
+
+ + {/* Card 5: In-Flight Exchanges */} +
+
In-Flight Exchanges
+
+ 23 + +
+
+ High-water: 67 (2h ago) +
+
+ +
+
+
+ ) +} + +// ─── Route metric row with id field (required by DataTable) ────────────────── +type RouteMetricRowWithId = RouteMetricRow & { id: string } + +// ─── Processor metrics types and generator ─────────────────────────────────── + +interface ProcessorMetric { + name: string + type: string + invocations: number + avgDurationMs: number + p99DurationMs: number + errorCount: number + errorRate: number + sparkline: number[] +} + +type ProcessorMetricWithId = ProcessorMetric & { id: string } + +function generateProcessorMetrics(processors: string[], routeExchangeCount: number): ProcessorMetric[] { + return processors.map((proc, i) => { + const name = proc + const type = proc.startsWith('from(') ? 'consumer' + : proc.startsWith('to(') ? 'producer' + : proc.startsWith('enrich(') ? 'enricher' + : proc.startsWith('validate(') || proc.startsWith('check(') ? 'validator' + : proc.startsWith('unmarshal(') || proc.startsWith('marshal(') ? 'transformer' + : proc.startsWith('route(') || proc.startsWith('choice(') ? 'router' + : 'processor' + const invocations = routeExchangeCount + const avgBase = 10 + (i * 15) + (proc.includes('enrich') ? 40 : 0) + (proc.includes('http') ? 80 : 0) + const avgDurationMs = avgBase + Math.round(Math.sin(i * 2.1) * 10) + const p99DurationMs = Math.round(avgDurationMs * 2.5) + const errorCount = proc.includes('enrich') || proc.includes('http') ? Math.round(invocations * 0.01) : Math.round(invocations * 0.001) + const errorRate = Number(((errorCount / invocations) * 100).toFixed(2)) + const sparkline = Array.from({ length: 14 }, (_, j) => avgDurationMs + Math.round(Math.sin(j * 0.8 + i) * avgDurationMs * 0.15)) + return { name, type, invocations, avgDurationMs, p99DurationMs, errorCount, errorRate, sparkline } + }) +} + +// ─── Map processor type to RouteNode type ──────────────────────────────────── +function toRouteNodeType(procType: string): RouteNode['type'] { + switch (procType) { + case 'consumer': return 'from' + case 'producer': return 'to' + case 'enricher': return 'process' + case 'validator': return 'process' + case 'transformer': return 'process' + case 'router': return 'choice' + default: return 'process' + } +} + +// ─── Processor type badge classes ──────────────────────────────────────────── +const TYPE_STYLE_MAP: Record = { + consumer: styles.typeConsumer, + producer: styles.typeProducer, + enricher: styles.typeEnricher, + validator: styles.typeValidator, + transformer: styles.typeTransformer, + router: styles.typeRouter, + processor: styles.typeProcessor, +} + +// ─── Route performance table columns ────────────────────────────────────────── +const ROUTE_COLUMNS: Column[] = [ + { + key: 'routeName', + header: 'Route', + sortable: true, + render: (_, row) => ( + {row.routeName} + ), + }, + { + key: 'appId', + header: 'Application', + sortable: true, + render: (_, row) => ( + {row.appId} + ), + }, + { + key: 'exchangeCount', + header: 'Exchanges', + sortable: true, + render: (_, row) => ( + {row.exchangeCount.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) => ( + + ), + }, +] + +// ─── Processor performance table columns ───────────────────────────────────── +const PROCESSOR_COLUMNS: Column[] = [ + { + key: 'name', + header: 'Processor', + sortable: true, + render: (_, row) => ( + {row.name} + ), + }, + { + key: 'type', + header: 'Type', + sortable: true, + render: (_, row) => ( + + {row.type} + + ), + }, + { + key: 'invocations', + header: 'Invocations', + sortable: true, + render: (_, row) => ( + {row.invocations.toLocaleString()} + ), + }, + { + key: 'avgDurationMs', + header: 'Avg Duration', + sortable: true, + render: (_, row) => { + const cls = row.avgDurationMs > 200 ? styles.rateBad : row.avgDurationMs > 100 ? styles.rateWarn : styles.rateGood + return {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: 'errorRate', + header: 'Error Rate', + sortable: true, + render: (_, row) => { + const cls = row.errorRate > 1 ? styles.rateBad : row.errorRate > 0.5 ? styles.rateWarn : styles.rateGood + return {row.errorRate}% + }, + }, + { + key: 'sparkline', + header: 'Trend', + render: (_, row) => ( + + ), + }, +] + +// ─── Build bar chart data from error series ──────────────────────────────────── +function buildErrorBarSeries() { + 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), + })), + })) +} + +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 })), + })) +} + +// ─── Routes page ────────────────────────────────────────────────────────────── +export function Routes() { + const navigate = useNavigate() + const { appId, routeId } = useParams<{ appId: string; routeId: string }>() + + // ── Breadcrumbs ───────────────────────────────────────────────────────────── + const breadcrumb = useMemo(() => { + if (routeId && appId) { + return [ + { label: 'Routes', href: '/routes' }, + { label: appId, href: `/routes/${appId}` }, + { label: routeId }, + ] + } + if (appId) { + return [ + { label: 'Routes', href: '/routes' }, + { label: appId }, + ] + } + return [{ label: 'Routes' }] + }, [appId, routeId]) + + // ── Data filtering ────────────────────────────────────────────────────────── + const filteredMetrics = useMemo(() => { + const data = appId + ? routeMetrics.filter((r) => r.appId === appId) + : routeMetrics + return data.map((r) => ({ ...r, id: r.routeId })) + }, [appId]) + + // ── Route detail data ─────────────────────────────────────────────────────── + const routeDef = useMemo(() => { + if (!routeId) return null + return routes.find((r) => r.id === routeId) ?? null + }, [routeId]) + + const processorMetrics = useMemo(() => { + if (!routeDef) return [] + return generateProcessorMetrics(routeDef.processors, routeDef.exchangeCount).map((pm, i) => ({ + ...pm, + id: `proc-${i}`, + })) + }, [routeDef]) + + const routeFlowNodes = useMemo(() => { + if (!processorMetrics.length) return [] + return processorMetrics.map((pm) => ({ + name: pm.name, + type: toRouteNodeType(pm.type), + durationMs: pm.avgDurationMs, + status: pm.errorRate > 1 ? 'fail' as const : pm.avgDurationMs > 150 ? 'slow' as const : 'ok' as const, + })) + }, [processorMetrics]) + + // Scoped metrics for KPI header + const scopedMetricsForKpi = useMemo(() => { + if (routeId) return routeMetrics.filter((r) => r.routeId === routeId) + if (appId) return routeMetrics.filter((r) => r.appId === appId) + return routeMetrics + }, [appId, routeId]) + + // ── Route detail view ─────────────────────────────────────────────────────── + if (routeId && appId && routeDef) { + return ( + }> + +
+
+ + Auto-refresh: 30s +
+ + + + {/* Processor Performance table */} +
+
+ Processor Performance +
+ {processorMetrics.length} processors + +
+
+ +
+ + {/* Route Flow diagram */} +
+
+ Route Flow +
+ +
+
+
+ ) + } + + // ── Top level / Application level view ────────────────────────────────────── + return ( + }> + +
+
+ + Auto-refresh: 30s +
+ + {/* KPI header cards */} + + + {/* Per-route performance table */} +
+
+ Per-Route Performance +
+ {filteredMetrics.length} routes + +
+
+ { + const rowAppId = appId ?? ROUTE_TO_APP.get(row.routeId) ?? row.routeId + navigate(`/routes/${rowAppId}/${row.routeId}`) + }} + /> +
+ + {/* 2x2 chart grid */} +
+
+
Throughput (msg/s)
+ +
+ +
+
Latency (ms)
+ +
+ +
+
Errors by Route
+ +
+ +
+
Message Volume (msg/min)
+ +
+
+
+
+ ) +}