diff --git a/src/pages/AgentHealth/AgentHealth.module.css b/src/pages/AgentHealth/AgentHealth.module.css new file mode 100644 index 0000000..070b929 --- /dev/null +++ b/src/pages/AgentHealth/AgentHealth.module.css @@ -0,0 +1,192 @@ +/* Scrollable content area */ +.content { + flex: 1; + overflow-y: auto; + padding: 20px 24px 40px; + min-width: 0; + background: var(--bg-body); +} + +/* System overview strip */ +.overviewStrip { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 10px; + margin-bottom: 16px; +} + +.overviewCard { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + box-shadow: var(--shadow-card); + padding: 12px 16px; + text-align: center; +} + +.overviewLabel { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--text-muted); + margin-bottom: 4px; +} + +.overviewValue { + font-size: 22px; + font-weight: 700; + font-family: var(--font-mono); + color: var(--text-primary); + line-height: 1.2; +} + +.valueLive { color: var(--success); } +.valueStale { color: var(--warning); } +.valueDead { color: var(--error); } + +/* Section header */ +.sectionHeaderRow { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.sectionTitle { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.sectionMeta { + font-size: 11px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +/* Agent cards grid */ +.agentGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +/* Agent card */ +.agentCard { + display: flex; + flex-direction: column; + gap: 0; + overflow: hidden; + padding: 0 !important; +} + +/* Agent card header */ +.agentCardHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px 10px; + cursor: pointer; + transition: background 0.15s; +} + +.agentCardHeader:hover { + background: var(--bg-hover); +} + +.agentCardLeft { + display: flex; + align-items: center; + gap: 10px; +} + +.agentCardName { + font-size: 14px; + font-weight: 700; + font-family: var(--font-mono); + color: var(--text-primary); +} + +.agentCardService { + font-size: 11px; + color: var(--text-secondary); + font-family: var(--font-mono); +} + +.agentCardRight { + display: flex; + align-items: center; + gap: 10px; +} + +.expandIcon { + font-size: 10px; + color: var(--text-muted); +} + +/* Agent metrics row */ +.agentMetrics { + display: flex; + flex-wrap: wrap; + gap: 0; + padding: 6px 12px 12px; + border-top: 1px solid var(--border-subtle); +} + +.agentMetric { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 6px 12px; + min-width: 80px; +} + +.metricLabel { + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--text-muted); + margin-bottom: 2px; +} + +.metricValue { + color: var(--text-primary); +} + +.metricValueWarn { + color: var(--warning); + font-family: var(--font-mono); + font-size: 12px; +} + +.metricValueError { + color: var(--error); + font-family: var(--font-mono); + font-size: 12px; +} + +/* Expanded charts area */ +.agentCharts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + padding: 12px 16px; + background: var(--bg-raised); + border-top: 1px solid var(--border-subtle); +} + +.agentChart { + display: flex; + flex-direction: column; + gap: 6px; +} + +.chartTitle { + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} diff --git a/src/pages/AgentHealth/AgentHealth.tsx b/src/pages/AgentHealth/AgentHealth.tsx new file mode 100644 index 0000000..9b3928a --- /dev/null +++ b/src/pages/AgentHealth/AgentHealth.tsx @@ -0,0 +1,262 @@ +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 = [ + { id: 'order-service', name: 'order-service', agentCount: 2, health: 'live' as const, execCount: 1433 }, + { id: 'payment-svc', name: 'payment-svc', agentCount: 1, health: 'live' as const, execCount: 912 }, + { id: 'shipment-tracker', name: 'shipment-tracker', agentCount: 2, health: 'live' as const, execCount: 471 }, + { id: 'notification-hub', name: 'notification-hub', agentCount: 1, health: 'stale' as const, execCount: 128 }, +] + +const SIDEBAR_ROUTES = routes.slice(0, 3).map((r) => ({ + id: r.id, + name: r.name, + execCount: r.execCount, +})) + +// ─── Build trend data for each agent ───────────────────────────────────────── +function buildAgentTrendSeries(agentId: string) { + const baseValues: Record = { + '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(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 ( + + } + > + {/* Top bar */} + + + {/* Scrollable content */} +
+ + {/* System overview strip */} +
+
+
Total Agents
+
{agents.length}
+
+
+
Live
+
{liveCount}
+
+
+
Stale
+
a.status === 'stale') ? styles.valueStale : ''}`}> + {agents.filter((a) => a.status === 'stale').length} +
+
+
+
Dead
+
a.status === 'dead') ? styles.valueDead : ''}`}> + {agents.filter((a) => a.status === 'dead').length} +
+
+
+
Total TPS
+
{totalTps.toFixed(1)}/s
+
+
+
Active Routes
+
{totalActiveRoutes}
+
+
+ + {/* Section header */} +
+ Agent Details + {liveCount}/{agents.length} live · Click to expand charts +
+ + {/* Agent cards grid */} +
+ {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 ( + + {/* Agent card header */} +
toggleAgent(agent.id)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') toggleAgent(agent.id) + }} + aria-expanded={isExpanded} + > +
+ +
+
{agent.name}
+
{agent.service} {agent.version}
+
+
+
+ + {isExpanded ? '▲' : '▼'} +
+
+ + {/* Agent metrics row */} +
+
+ TPS + {agent.tps} +
+
+ Uptime + {agent.uptime} +
+
+ Last Seen + {agent.lastSeen} +
+ {agent.errorRate && ( +
+ Error Rate + {agent.errorRate} +
+ )} +
+ CPU + 70 ? styles.metricValueWarn : styles.metricValue}> + {agent.cpuUsagePct}% + +
+
+ Memory + 80 ? styles.metricValueError : agent.memoryUsagePct > 70 ? styles.metricValueWarn : styles.metricValue}> + {agent.memoryUsagePct}% + +
+
+ Routes + + {agent.activeRoutes}/{agent.totalRoutes} + +
+
+ + {/* Expanded detail: trend charts */} + {isExpanded && trendData && ( +
+
+
Throughput (msg/s)
+ +
+
+
Error Rate (err/h)
+ +
+
+ )} +
+ ) + })} +
+ +
+
+ ) +} diff --git a/src/pages/ExchangeDetail/ExchangeDetail.module.css b/src/pages/ExchangeDetail/ExchangeDetail.module.css new file mode 100644 index 0000000..ea0060f --- /dev/null +++ b/src/pages/ExchangeDetail/ExchangeDetail.module.css @@ -0,0 +1,264 @@ +/* Scrollable content area */ +.content { + flex: 1; + overflow-y: auto; + padding: 20px 24px 40px; + min-width: 0; + background: var(--bg-body); +} + +/* Exchange header card */ +.exchangeHeader { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: 16px 20px; + margin-bottom: 14px; +} + +.headerRow { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.headerLeft { + display: flex; + align-items: flex-start; + gap: 12px; + flex: 1; +} + +.exchangeId { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; +} + +.exchangeRoute { + font-size: 12px; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.routeLink { + color: var(--amber); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.routeLink:hover { + color: var(--amber-deep); +} + +.headerDivider { + color: var(--text-faint); +} + +.headerRight { + display: flex; + gap: 20px; + flex-shrink: 0; +} + +.headerStat { + text-align: center; +} + +.headerStatLabel { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--text-muted); + margin-bottom: 2px; +} + +.headerStatValue { + font-size: 14px; + font-weight: 600; + font-family: var(--font-mono); + color: var(--text-primary); +} + +/* Section layout */ +.section { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + overflow: hidden; + margin-bottom: 16px; +} + +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border-subtle); +} + +.sectionTitle { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.sectionMeta { + font-size: 11px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +/* Timeline wrapper */ +.timelineWrap { + padding: 12px 16px; +} + +/* Inspector steps */ +.inspectorSteps { + display: flex; + flex-direction: column; +} + +.stepCollapsible { + border-bottom: 1px solid var(--border-subtle); +} + +.stepCollapsible:last-child { + border-bottom: none; +} + +.stepTitle { + display: flex; + align-items: center; + gap: 10px; +} + +.stepIndex { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + font-size: 11px; + font-weight: 700; + font-family: var(--font-mono); + flex-shrink: 0; +} + +.stepOk { + background: var(--success-bg); + color: var(--success); + border: 1px solid var(--success-border); +} + +.stepSlow { + background: var(--warning-bg); + color: var(--warning); + border: 1px solid var(--warning-border); +} + +.stepFail { + background: var(--error-bg); + color: var(--error); + border: 1px solid var(--error-border); +} + +.stepName { + font-size: 12px; + font-weight: 500; + font-family: var(--font-mono); + color: var(--text-primary); + flex: 1; +} + +.stepDuration { + font-size: 11px; + font-family: var(--font-mono); + color: var(--text-muted); + margin-left: auto; + flex-shrink: 0; +} + +/* Step body (two-column layout) */ +.stepBody { + display: grid; + grid-template-columns: 1fr 2fr; + gap: 12px; + padding: 12px 16px; + background: var(--bg-raised); +} + +.stepPanel { + display: flex; + flex-direction: column; + gap: 6px; +} + +.stepPanelLabel { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--text-muted); +} + +.codeBlock { + flex: 1; + max-height: 200px; + overflow-y: auto; +} + +/* Error section */ +.errorSection { + background: var(--error-bg); + border: 1px solid var(--error-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + overflow: hidden; + margin-bottom: 16px; +} + +.errorBody { + padding: 16px; +} + +.errorClass { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 700; + color: var(--error); + margin-bottom: 8px; +} + +.errorMessage { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + background: var(--bg-surface); + border: 1px solid var(--error-border); + border-radius: var(--radius-sm); + padding: 10px 12px; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.5; + margin-bottom: 8px; +} + +.errorHint { + font-size: 11px; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 5px; +} diff --git a/src/pages/ExchangeDetail/ExchangeDetail.tsx b/src/pages/ExchangeDetail/ExchangeDetail.tsx new file mode 100644 index 0000000..f367981 --- /dev/null +++ b/src/pages/ExchangeDetail/ExchangeDetail.tsx @@ -0,0 +1,331 @@ +import { useMemo, useState } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import styles from './ExchangeDetail.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 { ProcessorTimeline } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline' +import type { ProcessorStep } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline' + +// Primitives +import { Badge } from '../../design-system/primitives/Badge/Badge' +import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot' +import { MonoText } from '../../design-system/primitives/MonoText/MonoText' +import { Collapsible } from '../../design-system/primitives/Collapsible/Collapsible' +import { CodeBlock } from '../../design-system/primitives/CodeBlock/CodeBlock' +import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout' + +// Mock data +import { executions } from '../../mocks/executions' +import { routes } from '../../mocks/routes' +import { agents } from '../../mocks/agents' + +// ─── Sidebar data (shared) ──────────────────────────────────────────────────── +const APPS = [ + { id: 'order-service', name: 'order-service', agentCount: 2, health: 'live' as const, execCount: 1433 }, + { id: 'payment-svc', name: 'payment-svc', agentCount: 1, health: 'live' as const, execCount: 912 }, + { id: 'shipment-tracker', name: 'shipment-tracker', agentCount: 2, health: 'live' as const, execCount: 471 }, + { id: 'notification-hub', name: 'notification-hub', agentCount: 1, health: 'stale' as const, execCount: 128 }, +] + +const SIDEBAR_ROUTES = routes.slice(0, 3).map((r) => ({ + id: r.id, + name: r.name, + execCount: r.execCount, +})) + +// ─── Helpers ────────────────────────────────────────────────────────────────── +function formatDuration(ms: number): string { + if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s` + if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s` + return `${ms}ms` +} + +function statusToVariant(status: 'completed' | 'failed' | 'running' | 'warning'): 'success' | 'error' | 'running' | 'warning' { + switch (status) { + case 'completed': return 'success' + case 'failed': return 'error' + case 'running': return 'running' + case 'warning': return 'warning' + } +} + +function statusToLabel(status: 'completed' | 'failed' | 'running' | 'warning'): string { + switch (status) { + case 'completed': return 'COMPLETED' + case 'failed': return 'FAILED' + case 'running': return 'RUNNING' + case 'warning': return 'WARNING' + } +} + +// ─── Exchange body mock generator ──────────────────────────────────────────── +// For each processor step, generate a plausible exchange body snapshot +function generateExchangeSnapshot( + step: ProcessorStep, + orderId: string, + customer: string, + stepIndex: number, +) { + const baseBody = { + orderId, + customer, + status: step.status === 'fail' ? 'ERROR' : 'PROCESSING', + processorStep: step.name, + stepIndex, + } + + const headers: Record = { + 'CamelCorrelationId': `cmr-${Math.random().toString(36).slice(2, 10)}`, + 'Content-Type': 'application/json', + 'CamelTimerName': step.name, + 'CamelBreadcrumbId': `${orderId}-${stepIndex}`, + } + + if (stepIndex === 0) { + return { + headers, + body: JSON.stringify({ + ...baseBody, + raw: { orderId, customer, items: ['ITEM-001', 'ITEM-002'], total: 142.50 }, + }, null, 2), + } + } + + if (step.type === 'enrich') { + return { + headers: { + ...headers, + 'enrichedBy': step.name.replace('enrich(', '').replace(')', ''), + }, + body: JSON.stringify({ + ...baseBody, + enrichment: { source: step.name, addedFields: ['customerId', 'address', 'tier'] }, + }, null, 2), + } + } + + return { + headers, + body: JSON.stringify(baseBody, null, 2), + } +} + +// ─── ExchangeDetail component ───────────────────────────────────────────────── +export function ExchangeDetail() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const [activeItem, setActiveItem] = useState('') + + const execution = useMemo(() => executions.find((e) => e.id === id), [id]) + + function handleItemClick(itemId: string) { + setActiveItem(itemId) + const route = routes.find((r) => r.id === itemId) + if (route) navigate(`/routes/${itemId}`) + } + + // Not found state + if (!execution) { + return ( + + } + > + +
+ Exchange "{id}" not found in mock data. +
+
+ ) + } + + const statusVariant = statusToVariant(execution.status) + const statusLabel = statusToLabel(execution.status) + + return ( + + } + > + {/* Top bar */} + + + {/* Scrollable content */} +
+ + {/* Exchange header */} +
+
+
+ +
+
+ {execution.id} + +
+
+ Route: navigate(`/routes/${execution.route}`)}>{execution.route} + · + Order: {execution.orderId} + · + Customer: {execution.customer} +
+
+
+
+
+
Duration
+
{formatDuration(execution.durationMs)}
+
+
+
Agent
+
{execution.agent}
+
+
+
Started
+
+ {execution.timestamp.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })} +
+
+
+
Processors
+
{execution.processors.length}
+
+
+
+
+ + {/* Processor timeline */} +
+
+ Processor Timeline + Total: {formatDuration(execution.durationMs)} +
+
+ +
+
+ + {/* Step-by-step inspector */} +
+
+ Exchange Inspector + {execution.processors.length} processor steps +
+
+ {execution.processors.map((proc, index) => { + const snapshot = generateExchangeSnapshot(proc, execution.orderId, execution.customer, index) + const stepStatusClass = + proc.status === 'fail' + ? styles.stepFail + : proc.status === 'slow' + ? styles.stepSlow + : styles.stepOk + + return ( + + {index + 1} + {proc.name} + + {formatDuration(proc.durationMs)} +
+ } + defaultOpen={proc.status === 'fail'} + className={styles.stepCollapsible} + > +
+
+
Exchange Headers
+ +
+
+
Exchange Body
+ +
+
+ + ) + })} +
+
+ + {/* Error block (if failed) */} + {execution.status === 'failed' && execution.errorMessage && ( +
+
+ Error Details + +
+
+
{execution.errorClass}
+
{execution.errorMessage}
+
+ Failed at processor: + {execution.processors.find((p) => p.status === 'fail')?.name ?? 'unknown'} + +
+
+
+ )} + + +
+ ) +}