feat: ExchangeDetail and AgentHealth pages
ExchangeDetail (/exchanges/:id): exchange header card with ID/route/
status/duration, ProcessorTimeline for the specific exchange, step-by-
step exchange inspector using Collapsible+CodeBlock for headers/body at
each processor step, and error details block for failed exchanges.
AgentHealth (/agents): 6-card system overview strip, 2-column grid of
agent cards (StatusDot, name, version, tps, uptime, last-seen, CPU/mem
usage, active routes), expandable per-agent LineCharts for throughput
and error rate trends. Both pages use AppShell + shared Sidebar layout.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:22:11 +01:00
|
|
|
import { useState } from 'react'
|
|
|
|
|
import { useNavigate } from 'react-router-dom'
|
|
|
|
|
import styles from './AgentHealth.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 { LineChart } from '../../design-system/composites/LineChart/LineChart'
|
|
|
|
|
|
|
|
|
|
// Primitives
|
|
|
|
|
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
|
|
|
|
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
|
|
|
|
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
|
|
|
|
import { Card } from '../../design-system/primitives/Card/Card'
|
|
|
|
|
|
|
|
|
|
// Mock data
|
|
|
|
|
import { agents } from '../../mocks/agents'
|
|
|
|
|
import { routes } from '../../mocks/routes'
|
|
|
|
|
|
|
|
|
|
// ─── Sidebar data (shared) ────────────────────────────────────────────────────
|
|
|
|
|
const APPS = [
|
2026-03-18 15:54:27 +01:00
|
|
|
{ id: 'order-service', name: 'order-service', agentCount: 2, health: 'live' as const, exchangeCount: 1433 },
|
|
|
|
|
{ id: 'payment-svc', name: 'payment-svc', agentCount: 1, health: 'live' as const, exchangeCount: 912 },
|
|
|
|
|
{ id: 'shipment-tracker', name: 'shipment-tracker', agentCount: 2, health: 'live' as const, exchangeCount: 471 },
|
|
|
|
|
{ id: 'notification-hub', name: 'notification-hub', agentCount: 1, health: 'stale' as const, exchangeCount: 128 },
|
feat: ExchangeDetail and AgentHealth pages
ExchangeDetail (/exchanges/:id): exchange header card with ID/route/
status/duration, ProcessorTimeline for the specific exchange, step-by-
step exchange inspector using Collapsible+CodeBlock for headers/body at
each processor step, and error details block for failed exchanges.
AgentHealth (/agents): 6-card system overview strip, 2-column grid of
agent cards (StatusDot, name, version, tps, uptime, last-seen, CPU/mem
usage, active routes), expandable per-agent LineCharts for throughput
and error rate trends. Both pages use AppShell + shared Sidebar layout.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:22:11 +01:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
const SIDEBAR_ROUTES = routes.slice(0, 3).map((r) => ({
|
|
|
|
|
id: r.id,
|
|
|
|
|
name: r.name,
|
2026-03-18 15:54:27 +01:00
|
|
|
exchangeCount: r.exchangeCount,
|
feat: ExchangeDetail and AgentHealth pages
ExchangeDetail (/exchanges/:id): exchange header card with ID/route/
status/duration, ProcessorTimeline for the specific exchange, step-by-
step exchange inspector using Collapsible+CodeBlock for headers/body at
each processor step, and error details block for failed exchanges.
AgentHealth (/agents): 6-card system overview strip, 2-column grid of
agent cards (StatusDot, name, version, tps, uptime, last-seen, CPU/mem
usage, active routes), expandable per-agent LineCharts for throughput
and error rate trends. Both pages use AppShell + shared Sidebar layout.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:22:11 +01:00
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// ─── Build trend data for each agent ─────────────────────────────────────────
|
|
|
|
|
function buildAgentTrendSeries(agentId: string) {
|
|
|
|
|
const baseValues: Record<string, { throughput: number; errorRate: number }> = {
|
|
|
|
|
'prod-1': { throughput: 14.2, errorRate: 0.2 },
|
|
|
|
|
'prod-2': { throughput: 11.8, errorRate: 3.1 },
|
|
|
|
|
'prod-3': { throughput: 12.1, errorRate: 0.5 },
|
|
|
|
|
'prod-4': { throughput: 9.1, errorRate: 0.3 },
|
|
|
|
|
}
|
|
|
|
|
const base = baseValues[agentId] ?? { throughput: 10, errorRate: 1 }
|
|
|
|
|
|
|
|
|
|
const now = new Date('2026-03-18T09:15:00')
|
|
|
|
|
const points = 20
|
|
|
|
|
const intervalMs = (3 * 60 * 60 * 1000) / points // 3 hours
|
|
|
|
|
|
|
|
|
|
const throughputData = Array.from({ length: points }, (_, i) => ({
|
|
|
|
|
x: new Date(now.getTime() - (points - i) * intervalMs),
|
|
|
|
|
y: Math.max(0, base.throughput + (Math.random() - 0.5) * 4),
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const errorRateData = Array.from({ length: points }, (_, i) => ({
|
|
|
|
|
x: new Date(now.getTime() - (points - i) * intervalMs),
|
|
|
|
|
y: Math.max(0, base.errorRate + (Math.random() - 0.5) * 2),
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
return { throughputData, errorRateData }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Summary stats ────────────────────────────────────────────────────────────
|
|
|
|
|
const liveCount = agents.filter((a) => a.status === 'live').length
|
|
|
|
|
const totalTps = agents.reduce((sum, a) => sum + parseFloat(a.tps), 0)
|
|
|
|
|
const totalActiveRoutes = agents.reduce((sum, a) => sum + a.activeRoutes, 0)
|
|
|
|
|
|
|
|
|
|
// ─── AgentHealth page ─────────────────────────────────────────────────────────
|
|
|
|
|
export function AgentHealth() {
|
|
|
|
|
const navigate = useNavigate()
|
|
|
|
|
const [activeItem, setActiveItem] = useState('agents')
|
|
|
|
|
const [expandedAgent, setExpandedAgent] = useState<string | null>(null)
|
|
|
|
|
|
|
|
|
|
function handleItemClick(id: string) {
|
|
|
|
|
setActiveItem(id)
|
|
|
|
|
const route = routes.find((r) => r.id === id)
|
|
|
|
|
if (route) navigate(`/routes/${id}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleAgent(id: string) {
|
|
|
|
|
setExpandedAgent((prev) => (prev === id ? null : id))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<AppShell
|
|
|
|
|
sidebar={
|
|
|
|
|
<Sidebar
|
|
|
|
|
apps={APPS}
|
|
|
|
|
routes={SIDEBAR_ROUTES}
|
|
|
|
|
agents={agents}
|
|
|
|
|
activeItem={activeItem}
|
|
|
|
|
onItemClick={handleItemClick}
|
|
|
|
|
/>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{/* Top bar */}
|
|
|
|
|
<TopBar
|
|
|
|
|
breadcrumb={[
|
|
|
|
|
{ label: 'Dashboard', href: '/' },
|
|
|
|
|
{ label: 'Agents' },
|
|
|
|
|
]}
|
|
|
|
|
environment="PRODUCTION"
|
|
|
|
|
shift="Day (06:00-18:00)"
|
|
|
|
|
user={{ name: 'hendrik' }}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Scrollable content */}
|
|
|
|
|
<div className={styles.content}>
|
|
|
|
|
|
|
|
|
|
{/* System overview strip */}
|
|
|
|
|
<div className={styles.overviewStrip}>
|
|
|
|
|
<div className={styles.overviewCard}>
|
|
|
|
|
<div className={styles.overviewLabel}>Total Agents</div>
|
|
|
|
|
<div className={styles.overviewValue}>{agents.length}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.overviewCard}>
|
|
|
|
|
<div className={styles.overviewLabel}>Live</div>
|
|
|
|
|
<div className={`${styles.overviewValue} ${styles.valueLive}`}>{liveCount}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.overviewCard}>
|
|
|
|
|
<div className={styles.overviewLabel}>Stale</div>
|
|
|
|
|
<div className={`${styles.overviewValue} ${agents.some(a => a.status === 'stale') ? styles.valueStale : ''}`}>
|
|
|
|
|
{agents.filter((a) => a.status === 'stale').length}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.overviewCard}>
|
|
|
|
|
<div className={styles.overviewLabel}>Dead</div>
|
|
|
|
|
<div className={`${styles.overviewValue} ${agents.some(a => a.status === 'dead') ? styles.valueDead : ''}`}>
|
|
|
|
|
{agents.filter((a) => a.status === 'dead').length}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.overviewCard}>
|
|
|
|
|
<div className={styles.overviewLabel}>Total TPS</div>
|
|
|
|
|
<div className={styles.overviewValue}>{totalTps.toFixed(1)}/s</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.overviewCard}>
|
|
|
|
|
<div className={styles.overviewLabel}>Active Routes</div>
|
|
|
|
|
<div className={styles.overviewValue}>{totalActiveRoutes}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Section header */}
|
|
|
|
|
<div className={styles.sectionHeaderRow}>
|
|
|
|
|
<span className={styles.sectionTitle}>Agent Details</span>
|
|
|
|
|
<span className={styles.sectionMeta}>{liveCount}/{agents.length} live · Click to expand charts</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Agent cards grid */}
|
|
|
|
|
<div className={styles.agentGrid}>
|
|
|
|
|
{agents.map((agent) => {
|
|
|
|
|
const isExpanded = expandedAgent === agent.id
|
|
|
|
|
const trendData = isExpanded ? buildAgentTrendSeries(agent.id) : null
|
|
|
|
|
const statusVariant = agent.status === 'live' ? 'live' : agent.status === 'stale' ? 'stale' : 'dead'
|
|
|
|
|
const cardAccent = agent.status === 'live' ? 'success' : agent.status === 'stale' ? 'warning' : 'error'
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Card
|
|
|
|
|
key={agent.id}
|
|
|
|
|
accent={cardAccent as 'success' | 'warning' | 'error'}
|
|
|
|
|
className={styles.agentCard}
|
|
|
|
|
>
|
|
|
|
|
{/* Agent card header */}
|
|
|
|
|
<div
|
|
|
|
|
className={styles.agentCardHeader}
|
|
|
|
|
onClick={() => toggleAgent(agent.id)}
|
|
|
|
|
role="button"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === 'Enter' || e.key === ' ') toggleAgent(agent.id)
|
|
|
|
|
}}
|
|
|
|
|
aria-expanded={isExpanded}
|
|
|
|
|
>
|
|
|
|
|
<div className={styles.agentCardLeft}>
|
|
|
|
|
<StatusDot variant={statusVariant} />
|
|
|
|
|
<div>
|
|
|
|
|
<div className={styles.agentCardName}>{agent.name}</div>
|
|
|
|
|
<div className={styles.agentCardService}>{agent.service} {agent.version}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.agentCardRight}>
|
|
|
|
|
<Badge
|
|
|
|
|
label={agent.status.toUpperCase()}
|
|
|
|
|
color={agent.status === 'live' ? 'success' : agent.status === 'stale' ? 'warning' : 'error'}
|
|
|
|
|
variant="filled"
|
|
|
|
|
/>
|
|
|
|
|
<span className={styles.expandIcon}>{isExpanded ? '▲' : '▼'}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Agent metrics row */}
|
|
|
|
|
<div className={styles.agentMetrics}>
|
|
|
|
|
<div className={styles.agentMetric}>
|
|
|
|
|
<span className={styles.metricLabel}>TPS</span>
|
|
|
|
|
<MonoText size="sm" className={styles.metricValue}>{agent.tps}</MonoText>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.agentMetric}>
|
|
|
|
|
<span className={styles.metricLabel}>Uptime</span>
|
|
|
|
|
<MonoText size="sm" className={styles.metricValue}>{agent.uptime}</MonoText>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.agentMetric}>
|
|
|
|
|
<span className={styles.metricLabel}>Last Seen</span>
|
|
|
|
|
<MonoText size="sm" className={styles.metricValue}>{agent.lastSeen}</MonoText>
|
|
|
|
|
</div>
|
|
|
|
|
{agent.errorRate && (
|
|
|
|
|
<div className={styles.agentMetric}>
|
|
|
|
|
<span className={styles.metricLabel}>Error Rate</span>
|
|
|
|
|
<MonoText size="sm" className={styles.metricValueError}>{agent.errorRate}</MonoText>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div className={styles.agentMetric}>
|
|
|
|
|
<span className={styles.metricLabel}>CPU</span>
|
|
|
|
|
<MonoText size="sm" className={agent.cpuUsagePct > 70 ? styles.metricValueWarn : styles.metricValue}>
|
|
|
|
|
{agent.cpuUsagePct}%
|
|
|
|
|
</MonoText>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.agentMetric}>
|
|
|
|
|
<span className={styles.metricLabel}>Memory</span>
|
|
|
|
|
<MonoText size="sm" className={agent.memoryUsagePct > 80 ? styles.metricValueError : agent.memoryUsagePct > 70 ? styles.metricValueWarn : styles.metricValue}>
|
|
|
|
|
{agent.memoryUsagePct}%
|
|
|
|
|
</MonoText>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.agentMetric}>
|
|
|
|
|
<span className={styles.metricLabel}>Routes</span>
|
|
|
|
|
<MonoText size="sm" className={styles.metricValue}>
|
|
|
|
|
{agent.activeRoutes}/{agent.totalRoutes}
|
|
|
|
|
</MonoText>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Expanded detail: trend charts */}
|
|
|
|
|
{isExpanded && trendData && (
|
|
|
|
|
<div className={styles.agentCharts}>
|
|
|
|
|
<div className={styles.agentChart}>
|
|
|
|
|
<div className={styles.chartTitle}>Throughput (msg/s)</div>
|
|
|
|
|
<LineChart
|
|
|
|
|
series={[{ label: 'tps', data: trendData.throughputData }]}
|
|
|
|
|
height={140}
|
|
|
|
|
width={380}
|
|
|
|
|
yLabel="msg/s"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.agentChart}>
|
|
|
|
|
<div className={styles.chartTitle}>Error Rate (err/h)</div>
|
|
|
|
|
<LineChart
|
|
|
|
|
series={[{ label: 'errors', data: trendData.errorRateData, color: 'var(--error)' }]}
|
|
|
|
|
height={140}
|
|
|
|
|
width={380}
|
|
|
|
|
yLabel="err/h"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</Card>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
</AppShell>
|
|
|
|
|
)
|
|
|
|
|
}
|