feat: rework Metrics into Routes with 3-level hierarchy and mock-matching KPI header
All checks were successful
Build & Publish / publish (push) Successful in 43s

- Rename Metrics to Routes with /routes, /routes/:appId, /routes/:appId/:routeId
- Sidebar: Routes is now a collapsible tree (apps > routes) like Applications/Agents
- KPI header matching mock-v3-metrics-dashboard: throughput with sparkline, error rate,
  latency percentiles (P50/P95/P99), active routes with mini donut, in-flight exchanges
- Same KPI header used consistently across all 3 levels with scoped data
- Route detail level shows per-processor performance table and RouteFlow diagram
- Added appId to RouteMetricRow and filled missing route entries in mock data
- Fix sidebar section toggle indentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-19 15:29:27 +01:00
parent 932dc9dcbd
commit a92ada8117
8 changed files with 1066 additions and 467 deletions

View File

@@ -1,7 +1,7 @@
import { useMemo, useCallback } from 'react' import { useMemo, useCallback } from 'react'
import { Routes, Route, Navigate, useNavigate } from 'react-router-dom' import { Routes, Route, Navigate, useNavigate } from 'react-router-dom'
import { Dashboard } from './pages/Dashboard/Dashboard' 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 { ExchangeDetail } from './pages/ExchangeDetail/ExchangeDetail'
import { AgentHealth } from './pages/AgentHealth/AgentHealth' import { AgentHealth } from './pages/AgentHealth/AgentHealth'
import { AgentInstance } from './pages/AgentInstance/AgentInstance' import { AgentInstance } from './pages/AgentInstance/AgentInstance'
@@ -82,7 +82,9 @@ export default function App() {
<Route path="/apps" element={<Dashboard />} /> <Route path="/apps" element={<Dashboard />} />
<Route path="/apps/:id" element={<Dashboard />} /> <Route path="/apps/:id" element={<Dashboard />} />
<Route path="/apps/:id/:routeId" element={<Dashboard />} /> <Route path="/apps/:id/:routeId" element={<Dashboard />} />
<Route path="/metrics" element={<Metrics />} /> <Route path="/routes" element={<RoutesPage />} />
<Route path="/routes/:appId" element={<RoutesPage />} />
<Route path="/routes/:appId/:routeId" element={<RoutesPage />} />
<Route path="/exchanges/:id" element={<ExchangeDetail />} /> <Route path="/exchanges/:id" element={<ExchangeDetail />} />
<Route path="/agents/:appId/:instanceId" element={<AgentInstance />} /> <Route path="/agents/:appId/:instanceId" element={<AgentInstance />} />
<Route path="/agents/*" element={<AgentHealth />} /> <Route path="/agents/*" element={<AgentHealth />} />

View File

@@ -74,9 +74,9 @@ describe('Sidebar', () => {
expect(screen.getByText('Agents')).toBeInTheDocument() expect(screen.getByText('Agents')).toBeInTheDocument()
}) })
it('renders Metrics nav link', () => { it('renders Routes nav link', () => {
renderSidebar() renderSidebar()
expect(screen.getByText('Metrics')).toBeInTheDocument() expect(screen.getByText('Routes')).toBeInTheDocument()
}) })
it('renders bottom links', () => { it('renders bottom links', () => {
@@ -87,9 +87,9 @@ describe('Sidebar', () => {
it('renders app names in the Applications tree', () => { it('renders app names in the Applications tree', () => {
renderSidebar() 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.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', () => { it('renders exchange count badges', () => {
@@ -130,8 +130,8 @@ describe('Sidebar', () => {
const searchInput = screen.getByPlaceholderText('Filter...') const searchInput = screen.getByPlaceholderText('Filter...')
await user.type(searchInput, 'payment') await user.type(searchInput, 'payment')
// payment-svc should still be visible // payment-svc should still be visible (may appear in multiple trees)
expect(screen.getByText('payment-svc')).toBeInTheDocument() expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1)
}) })
it('expands tree to show children when chevron is clicked', async () => { it('expands tree to show children when chevron is clicked', async () => {

View File

@@ -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: <StatusDot variant={app.health} />,
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: <span className={styles.routeArrow}>&#9656;</span>,
badge: formatCount(route.exchangeCount),
path: `/routes/${app.id}/${route.id}`,
starrable: false,
})),
}))
}
function buildAgentTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] { function buildAgentTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
return apps return apps
.filter((app) => app.agents.length > 0) .filter((app) => app.agents.length > 0)
@@ -196,6 +219,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true') const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-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) => { const setAppsCollapsed = (updater: (v: boolean) => boolean) => {
_setAppsCollapsed((prev) => { _setAppsCollapsed((prev) => {
@@ -212,6 +236,14 @@ export function Sidebar({ apps, className }: SidebarProps) {
return next 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 navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { starredIds, isStarred, toggleStar } = useStarred() const { starredIds, isStarred, toggleStar } = useStarred()
@@ -219,6 +251,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
// Build tree data // Build tree data
const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps]) const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps])
const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [apps]) const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [apps])
const routeNodes = useMemo(() => buildRouteTreeNodes(apps), [apps])
// Sidebar reveal from Cmd-K navigation (passed via location state) // Sidebar reveal from Cmd-K navigation (passed via location state)
const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null
@@ -374,23 +407,38 @@ export function Sidebar({ apps, className }: SidebarProps) {
)} )}
</div> </div>
{/* Flat nav links */} {/* Routes tree (collapsible, label navigates to /routes) */}
<div className={styles.items}> <div className={styles.treeSection}>
<div <div className={styles.treeSectionToggle}>
className={[ <button
styles.item, className={styles.treeSectionChevronBtn}
location.pathname === '/metrics' ? styles.active : '', onClick={() => setRoutesCollapsed((v) => !v)}
].filter(Boolean).join(' ')} aria-expanded={!routesCollapsed}
onClick={() => navigate('/metrics')} aria-label={routesCollapsed ? 'Expand Routes' : 'Collapse Routes'}
>
{routesCollapsed ? '▸' : '▾'}
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
onClick={() => navigate('/routes')}
role="button" role="button"
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/metrics') }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/routes') }}
> >
<span className={styles.navIcon}></span> Routes
<div className={styles.itemInfo}> </span>
<div className={styles.itemName}>Metrics</div>
</div>
</div> </div>
{!routesCollapsed && (
<SidebarTree
nodes={routeNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={search}
persistKey="cameleer:expanded:routes"
autoRevealPath={sidebarRevealPath}
/>
)}
</div> </div>
{/* No results message */} {/* No results message */}

View File

@@ -147,6 +147,7 @@ export const errorCountSeries: MetricSeries[] = [
export interface RouteMetricRow { export interface RouteMetricRow {
routeId: string routeId: string
routeName: string routeName: string
appId: string
exchangeCount: number exchangeCount: number
successRate: number successRate: number
avgDurationMs: number avgDurationMs: number
@@ -159,6 +160,7 @@ export const routeMetrics: RouteMetricRow[] = [
{ {
routeId: 'order-intake', routeId: 'order-intake',
routeName: 'order-intake', routeName: 'order-intake',
appId: 'order-service',
exchangeCount: 892, exchangeCount: 892,
successRate: 99.2, successRate: 99.2,
avgDurationMs: 88, avgDurationMs: 88,
@@ -169,6 +171,7 @@ export const routeMetrics: RouteMetricRow[] = [
{ {
routeId: 'order-enrichment', routeId: 'order-enrichment',
routeName: 'order-enrichment', routeName: 'order-enrichment',
appId: 'order-service',
exchangeCount: 541, exchangeCount: 541,
successRate: 97.6, successRate: 97.6,
avgDurationMs: 156, avgDurationMs: 156,
@@ -179,6 +182,7 @@ export const routeMetrics: RouteMetricRow[] = [
{ {
routeId: 'payment-process', routeId: 'payment-process',
routeName: 'payment-process', routeName: 'payment-process',
appId: 'payment-svc',
exchangeCount: 414, exchangeCount: 414,
successRate: 96.1, successRate: 96.1,
avgDurationMs: 234, avgDurationMs: 234,
@@ -186,9 +190,21 @@ export const routeMetrics: RouteMetricRow[] = [
errorCount: 16, errorCount: 16,
sparkline: [210, 225, 232, 218, 241, 235, 228, 242, 238, 231, 244, 237, 233, 234], 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', routeId: 'shipment-dispatch',
routeName: 'shipment-dispatch', routeName: 'shipment-dispatch',
appId: 'shipment-tracker',
exchangeCount: 387, exchangeCount: 387,
successRate: 98.4, successRate: 98.4,
avgDurationMs: 118, avgDurationMs: 118,
@@ -196,4 +212,26 @@ export const routeMetrics: RouteMetricRow[] = [
errorCount: 6, errorCount: 6,
sparkline: [112, 115, 118, 114, 120, 116, 119, 117, 118, 121, 116, 118, 119, 118], 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],
},
] ]

View File

@@ -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%;
}

View File

@@ -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<RouteMetricRowWithId>[] = [
{
key: 'routeName',
header: 'Route',
sortable: true,
render: (_, row) => (
<span className={styles.routeNameCell}>{row.routeName}</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 cls = row.successRate >= 99 ? styles.rateGood : row.successRate >= 97 ? styles.rateWarn : styles.rateBad
return <MonoText size="sm" className={cls}>{row.successRate.toFixed(1)}%</MonoText>
},
},
{
key: 'avgDurationMs',
header: 'Avg Duration',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{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}>{row.p99DurationMs}ms</MonoText>
},
},
{
key: 'errorCount',
header: 'Errors',
sortable: true,
render: (_, row) => (
<MonoText size="sm" className={row.errorCount > 10 ? styles.rateBad : styles.rateNeutral}>
{row.errorCount}
</MonoText>
),
},
{
key: 'sparkline',
header: 'Trend',
render: (_, row) => (
<Sparkline data={row.sparkline} width={80} height={24} />
),
},
]
// ─── 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 (
<AppShell
sidebar={
<Sidebar apps={SIDEBAR_APPS} />
}
>
{/* Top bar */}
<TopBar
breadcrumb={[
{ label: 'Applications', href: '/apps' },
{ label: 'Metrics' },
]}
environment="PRODUCTION"
user={{ name: 'hendrik' }}
/>
{/* Scrollable content */}
<div className={styles.content}>
{/* Auto-refresh indicator */}
<div className={styles.refreshIndicator}>
<span className={styles.refreshDot} />
<span className={styles.refreshText}>Auto-refresh: 30s</span>
</div>
{/* KPI stat cards (5) */}
<div className={styles.kpiStrip}>
{METRIC_KPIS.map((kpi, i) => (
<StatCard
key={i}
label={kpi.label}
value={kpi.value}
detail={kpi.detail}
trend={kpi.trend}
trendValue={kpi.trendValue}
accent={kpi.accent}
sparkline={kpi.sparkline}
/>
))}
</div>
{/* Per-route performance table */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Per-Route Performance</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>{routeMetrics.length} routes</span>
<Badge label="SHIFT" color="primary" />
</div>
</div>
<DataTable
columns={ROUTE_COLUMNS}
data={routeMetricsWithId}
sortable
onRowClick={(row) => navigate(`/apps/${ROUTE_TO_APP.get(row.routeId) ?? row.routeId}/${row.routeId}`)}
/>
</div>
{/* 2x2 chart grid */}
<div className={styles.chartGrid}>
{/* Throughput area chart */}
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Throughput (msg/s)</div>
<AreaChart
series={convertSeries(throughputSeries)}
yLabel="msg/s"
height={200}
width={500}
className={styles.chart}
/>
</div>
{/* Latency line chart with SLA threshold */}
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Latency (ms)</div>
<LineChart
series={convertSeries(latencySeries)}
yLabel="ms"
threshold={{ value: 300, label: 'SLA 300ms' }}
height={200}
width={500}
className={styles.chart}
/>
</div>
{/* Error bar chart */}
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Errors by Route</div>
<BarChart
series={ERROR_BAR_SERIES}
stacked
height={200}
width={500}
className={styles.chart}
/>
</div>
{/* Volume area chart */}
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Message Volume (msg/min)</div>
<AreaChart
series={VOLUME_SERIES}
yLabel="msg/min"
height={200}
width={500}
className={styles.chart}
/>
</div>
</div>
</div>
</AppShell>
)
}

View File

@@ -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);
}

595
src/pages/Routes/Routes.tsx Normal file
View File

@@ -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 (
<div className={styles.kpiStrip}>
{/* Card 1: Total Throughput */}
<div className={`${styles.kpiCard} ${styles.kpiCardAmber}`}>
<div className={styles.kpiLabel}>Total Throughput</div>
<div className={styles.kpiValueRow}>
<span className={`${styles.kpiValue} ${styles.kpiValueAmber}`}>{totalExchanges.toLocaleString()}</span>
<span className={styles.kpiUnit}>msg/shift</span>
<span className={`${styles.kpiTrend} ${styles.trendUpGood}`}>&#9650; +8%</span>
</div>
<div className={styles.kpiDetail}>
<span className={styles.kpiDetailStrong}>{throughputPerSec}</span> msg/s · Capacity 39%
</div>
<div className={styles.kpiSparkline}>
<Sparkline data={[44, 46, 45, 47, 48, 46, 47, 48, 46, 47, 48, 47, 46, 47]} color="var(--amber)" width={200} height={32} />
</div>
</div>
{/* Card 2: System Error Rate */}
<div className={`${styles.kpiCard} ${errorRate < 1 ? styles.kpiCardGreen : styles.kpiCardError}`}>
<div className={styles.kpiLabel}>System Error Rate</div>
<div className={styles.kpiValueRow}>
<span className={`${styles.kpiValue} ${errorRate < 1 ? styles.kpiValueGreen : styles.kpiValueError}`}>{errorRate.toFixed(2)}%</span>
<span className={`${styles.kpiTrend} ${errorRate < 1 ? styles.trendDownGood : styles.trendUpBad}`}>
{errorRate < 1 ? '\u25BC -0.1%' : '\u25B2 +0.4%'}
</span>
</div>
<div className={styles.kpiDetail}>
<span className={styles.kpiDetailStrong}>{totalErrors}</span> errors / <span className={styles.kpiDetailStrong}>{totalExchanges.toLocaleString()}</span> total (6h)
</div>
<div className={styles.kpiSparkline}>
<Sparkline data={[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, errorRate]} color={errorRate < 1 ? 'var(--success)' : 'var(--error)'} width={200} height={32} />
</div>
</div>
{/* Card 3: Latency Percentiles */}
<div className={`${styles.kpiCard} ${p99Latency > 300 ? styles.kpiCardWarn : styles.kpiCardGreen}`}>
<div className={styles.kpiLabel}>Latency Percentiles</div>
<div className={styles.latencyValues}>
<div className={styles.latencyItem}>
<span className={styles.latencyLabel}>P50</span>
<span className={`${styles.latencyVal} ${styles.latValGreen}`}>{Math.round(avgLatency * 0.5)}ms</span>
<span className={`${styles.latencyTrend} ${styles.trendDownGood}`}>&#9660;3</span>
</div>
<div className={styles.latencyItem}>
<span className={styles.latencyLabel}>P95</span>
<span className={`${styles.latencyVal} ${avgLatency > 150 ? styles.latValAmber : styles.latValGreen}`}>{Math.round(avgLatency * 1.4)}ms</span>
<span className={`${styles.latencyTrend} ${styles.trendUpBad}`}>&#9650;12</span>
</div>
<div className={styles.latencyItem}>
<span className={styles.latencyLabel}>P99</span>
<span className={`${styles.latencyVal} ${p99Latency > 300 ? styles.latValRed : styles.latValAmber}`}>{p99Latency}ms</span>
<span className={`${styles.latencyTrend} ${styles.trendUpBad}`}>&#9650;28</span>
</div>
</div>
<div className={styles.kpiDetail}>
SLA: &lt;300ms P99 · {p99Latency > 300
? <span style={{ color: 'var(--error)', fontWeight: 600 }}>BREACH</span>
: <span style={{ color: 'var(--success)', fontWeight: 600 }}>OK</span>}
</div>
</div>
{/* Card 4: Active Routes */}
<div className={`${styles.kpiCard} ${styles.kpiCardTeal}`}>
<div className={styles.kpiLabel}>Active Routes</div>
<div className={styles.kpiValueRow}>
<span className={`${styles.kpiValue} ${styles.kpiValueTeal}`}>{activeRoutes}</span>
<span className={styles.kpiUnit}>of {totalRoutes}</span>
<span className={`${styles.kpiTrend} ${styles.trendFlat}`}>&#8596; stable</span>
</div>
<div className={styles.donutWrap}>
<svg viewBox="0 0 36 36" width="40" height="40">
<circle cx="18" cy="18" r="15.9" fill="none" stroke="var(--bg-inset)" strokeWidth="3" />
<circle cx="18" cy="18" r="15.9" fill="none" stroke="var(--running)" strokeWidth="3"
strokeDasharray={`${(activeRoutes / totalRoutes) * 100} ${100 - (activeRoutes / totalRoutes) * 100}`}
strokeDashoffset="25" strokeLinecap="round" />
</svg>
<div className={styles.donutLegend}>
<span className={styles.donutLegendActive}>{activeRoutes} active</span>
<span>{totalRoutes - activeRoutes} stopped</span>
</div>
</div>
</div>
{/* Card 5: In-Flight Exchanges */}
<div className={`${styles.kpiCard} ${styles.kpiCardAmber}`}>
<div className={styles.kpiLabel}>In-Flight Exchanges</div>
<div className={styles.kpiValueRow}>
<span className={styles.kpiValue}>23</span>
<span className={`${styles.kpiTrend} ${styles.trendFlat}`}>&#8596;</span>
</div>
<div className={styles.kpiDetail}>
High-water: <span className={styles.kpiDetailStrong}>67</span> (2h ago)
</div>
<div className={styles.kpiSparkline}>
<Sparkline data={[16, 14, 18, 12, 10, 15, 8, 6, 4, 3, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 18, 16, 18, 20, 18, 23]} color="var(--amber)" width={200} height={32} />
</div>
</div>
</div>
)
}
// ─── 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<string, string> = {
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<RouteMetricRowWithId>[] = [
{
key: 'routeName',
header: 'Route',
sortable: true,
render: (_, row) => (
<span className={styles.routeNameCell}>{row.routeName}</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 cls = row.successRate >= 99 ? styles.rateGood : row.successRate >= 97 ? styles.rateWarn : styles.rateBad
return <MonoText size="sm" className={cls}>{row.successRate.toFixed(1)}%</MonoText>
},
},
{
key: 'avgDurationMs',
header: 'Avg Duration',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{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}>{row.p99DurationMs}ms</MonoText>
},
},
{
key: 'errorCount',
header: 'Errors',
sortable: true,
render: (_, row) => (
<MonoText size="sm" className={row.errorCount > 10 ? styles.rateBad : styles.rateNeutral}>
{row.errorCount}
</MonoText>
),
},
{
key: 'sparkline',
header: 'Trend',
render: (_, row) => (
<Sparkline data={row.sparkline} width={80} height={24} />
),
},
]
// ─── Processor performance table columns ─────────────────────────────────────
const PROCESSOR_COLUMNS: Column<ProcessorMetricWithId>[] = [
{
key: 'name',
header: 'Processor',
sortable: true,
render: (_, row) => (
<span className={styles.routeNameCell}>{row.name}</span>
),
},
{
key: 'type',
header: 'Type',
sortable: true,
render: (_, row) => (
<span className={`${styles.processorType} ${TYPE_STYLE_MAP[row.type] ?? styles.typeProcessor}`}>
{row.type}
</span>
),
},
{
key: 'invocations',
header: 'Invocations',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.invocations.toLocaleString()}</MonoText>
),
},
{
key: 'avgDurationMs',
header: 'Avg Duration',
sortable: true,
render: (_, row) => {
const cls = row.avgDurationMs > 200 ? styles.rateBad : row.avgDurationMs > 100 ? styles.rateWarn : styles.rateGood
return <MonoText size="sm" className={cls}>{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}>{row.p99DurationMs}ms</MonoText>
},
},
{
key: 'errorCount',
header: 'Errors',
sortable: true,
render: (_, row) => (
<MonoText size="sm" className={row.errorCount > 10 ? styles.rateBad : styles.rateNeutral}>
{row.errorCount}
</MonoText>
),
},
{
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 <MonoText size="sm" className={cls}>{row.errorRate}%</MonoText>
},
},
{
key: 'sparkline',
header: 'Trend',
render: (_, row) => (
<Sparkline data={row.sparkline} width={80} height={24} />
),
},
]
// ─── 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<ProcessorMetricWithId[]>(() => {
if (!routeDef) return []
return generateProcessorMetrics(routeDef.processors, routeDef.exchangeCount).map((pm, i) => ({
...pm,
id: `proc-${i}`,
}))
}, [routeDef])
const routeFlowNodes = useMemo<RouteNode[]>(() => {
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 (
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
<TopBar
breadcrumb={breadcrumb}
environment="PRODUCTION"
user={{ name: 'hendrik' }}
/>
<div className={styles.content}>
<div className={styles.refreshIndicator}>
<span className={styles.refreshDot} />
<span className={styles.refreshText}>Auto-refresh: 30s</span>
</div>
<KpiHeader scopedMetrics={scopedMetricsForKpi} />
{/* Processor Performance table */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Processor Performance</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>{processorMetrics.length} processors</span>
<Badge label="SHIFT" color="primary" />
</div>
</div>
<DataTable
columns={PROCESSOR_COLUMNS}
data={processorMetrics}
sortable
/>
</div>
{/* Route Flow diagram */}
<div className={styles.routeFlowSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Route Flow</span>
</div>
<RouteFlow nodes={routeFlowNodes} />
</div>
</div>
</AppShell>
)
}
// ── Top level / Application level view ──────────────────────────────────────
return (
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
<TopBar
breadcrumb={breadcrumb}
environment="PRODUCTION"
user={{ name: 'hendrik' }}
/>
<div className={styles.content}>
<div className={styles.refreshIndicator}>
<span className={styles.refreshDot} />
<span className={styles.refreshText}>Auto-refresh: 30s</span>
</div>
{/* KPI header cards */}
<KpiHeader scopedMetrics={scopedMetricsForKpi} />
{/* Per-route performance table */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Per-Route Performance</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>{filteredMetrics.length} routes</span>
<Badge label="SHIFT" color="primary" />
</div>
</div>
<DataTable
columns={ROUTE_COLUMNS}
data={filteredMetrics}
sortable
onRowClick={(row) => {
const rowAppId = appId ?? ROUTE_TO_APP.get(row.routeId) ?? row.routeId
navigate(`/routes/${rowAppId}/${row.routeId}`)
}}
/>
</div>
{/* 2x2 chart grid */}
<div className={styles.chartGrid}>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Throughput (msg/s)</div>
<AreaChart
series={convertSeries(throughputSeries)}
yLabel="msg/s"
height={200}
width={500}
className={styles.chart}
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Latency (ms)</div>
<LineChart
series={convertSeries(latencySeries)}
yLabel="ms"
threshold={{ value: 300, label: 'SLA 300ms' }}
height={200}
width={500}
className={styles.chart}
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Errors by Route</div>
<BarChart
series={ERROR_BAR_SERIES}
stacked
height={200}
width={500}
className={styles.chart}
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Message Volume (msg/min)</div>
<AreaChart
series={VOLUME_SERIES}
yLabel="msg/min"
height={200}
width={500}
className={styles.chart}
/>
</div>
</div>
</div>
</AppShell>
)
}