From fdf45d0d94de2731f3d545f9eb9e10af848da050 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:51:13 +0100 Subject: [PATCH] feat: add AgentInstance detail page and improve AgentHealth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New /agents/:appId/:instanceId page with process info, 3x2 charts grid (CPU, memory, throughput, errors, threads, GC), application log viewer with level filtering, and instance-scoped timeline - AgentHealth now uses slide-in DetailPanel for quick instance preview - Stat strip enhanced: colored StatusDot breakdowns, route ratio with state-colored values, Groups renamed to Applications - Unified page structure: stat strip → scope trail with inline badges (removed duplicate section headers from both pages) - StatCard value/detail props now accept ReactNode - Log and timeline displayed side by side Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.tsx | 2 + src/pages/AgentHealth/AgentHealth.tsx | 23 +- .../AgentInstance/AgentInstance.module.css | 229 ++++++++++++ src/pages/AgentInstance/AgentInstance.tsx | 326 ++++++++++++++++++ 4 files changed, 566 insertions(+), 14 deletions(-) create mode 100644 src/pages/AgentInstance/AgentInstance.module.css create mode 100644 src/pages/AgentInstance/AgentInstance.tsx diff --git a/src/App.tsx b/src/App.tsx index b139f1e..a4c9b32 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { Metrics } from './pages/Metrics/Metrics' import { RouteDetail } from './pages/RouteDetail/RouteDetail' import { ExchangeDetail } from './pages/ExchangeDetail/ExchangeDetail' import { AgentHealth } from './pages/AgentHealth/AgentHealth' +import { AgentInstance } from './pages/AgentInstance/AgentInstance' import { Inventory } from './pages/Inventory/Inventory' import { AuditLog } from './pages/Admin/AuditLog/AuditLog' import { OidcConfig } from './pages/Admin/OidcConfig/OidcConfig' @@ -85,6 +86,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/AgentHealth/AgentHealth.tsx b/src/pages/AgentHealth/AgentHealth.tsx index 79dac51..2b74538 100644 --- a/src/pages/AgentHealth/AgentHealth.tsx +++ b/src/pages/AgentHealth/AgentHealth.tsx @@ -316,20 +316,15 @@ export function AgentHealth() { /> - {/* Scope breadcrumb trail */} - {scope.level !== 'all' && ( -
- All Agents - - {scope.appId} -
- )} - - {/* Section header */} -
- - {scope.level === 'all' ? 'Agents' : scope.appId} - + {/* Scope trail + badges */} +
+ {scope.level !== 'all' && ( + <> + All Agents + + {scope.appId} + + )} 0 ? 'error' : staleCount > 0 ? 'warning' : 'success'} diff --git a/src/pages/AgentInstance/AgentInstance.module.css b/src/pages/AgentInstance/AgentInstance.module.css new file mode 100644 index 0000000..2028ac8 --- /dev/null +++ b/src/pages/AgentInstance/AgentInstance.module.css @@ -0,0 +1,229 @@ +.content { + flex: 1; + overflow-y: auto; + padding: 20px 24px 40px; + min-width: 0; + background: var(--bg-body); +} + +.notFound { + padding: 60px; + text-align: center; + color: var(--text-faint); + font-size: 14px; +} + +/* Stat strip — 5 columns matching /agents */ +.statStrip { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 10px; + margin-bottom: 16px; +} + +/* Scope trail — matches /agents */ +.scopeTrail { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 12px; + font-size: 12px; +} + +.scopeLink { + color: var(--amber); + text-decoration: none; + font-weight: 500; +} + +.scopeLink:hover { + text-decoration: underline; +} + +.scopeSep { + color: var(--text-muted); + font-size: 10px; +} + +.scopeCurrent { + color: var(--text-primary); + font-weight: 600; + font-family: var(--font-mono); +} + +/* Section header — matches /agents */ +.sectionHeaderRow { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.sectionTitle { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +/* Charts 3x2 grid */ +.chartsGrid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 14px; + margin-bottom: 20px; +} + +.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; +} + +.chartHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.chartTitle { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.chartMeta { + font-size: 11px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +/* Process info card */ +.processCard { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: 16px; + margin-bottom: 20px; +} + +.processGrid { + display: grid; + grid-template-columns: auto 1fr auto 1fr; + gap: 6px 16px; + font-size: 12px; + font-family: var(--font-body); + margin-top: 12px; +} + +.processLabel { + color: var(--text-muted); + font-weight: 500; +} + +.fdRow { + display: flex; + align-items: center; + gap: 8px; +} + +/* Log + Timeline side by side */ +.bottomRow { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +/* Log viewer */ +.logCard { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + overflow: hidden; +} + +.logHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border-subtle); +} + +.logEntries { + max-height: 360px; + overflow-y: auto; + font-size: 11px; +} + +.logEntry { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 5px 16px; + border-bottom: 1px solid var(--border-subtle); + font-family: var(--font-mono); + transition: background 0.1s; +} + +.logEntry:hover { + background: var(--bg-hover); +} + +.logEntry:last-child { + border-bottom: none; +} + +.logTime { + flex-shrink: 0; + color: var(--text-muted); + min-width: 60px; +} + +.logLogger { + flex-shrink: 0; + color: var(--text-faint); + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.logMsg { + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 11px; + word-break: break-word; +} + +.logEmpty { + padding: 24px; + text-align: center; + color: var(--text-faint); + font-size: 12px; +} + +/* Timeline card */ +.timelineCard { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + overflow: hidden; + display: flex; + flex-direction: column; + max-height: 420px; +} + +.timelineHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border-subtle); +} diff --git a/src/pages/AgentInstance/AgentInstance.tsx b/src/pages/AgentInstance/AgentInstance.tsx new file mode 100644 index 0000000..2c4f19b --- /dev/null +++ b/src/pages/AgentInstance/AgentInstance.tsx @@ -0,0 +1,326 @@ +import { useMemo } from 'react' +import { useParams, Link } from 'react-router-dom' +import styles from './AgentInstance.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' +import { AreaChart } from '../../design-system/composites/AreaChart/AreaChart' +import { EventFeed } from '../../design-system/composites/EventFeed/EventFeed' +import { Tabs } from '../../design-system/composites/Tabs/Tabs' + +// 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 { StatCard } from '../../design-system/primitives/StatCard/StatCard' +import { ProgressBar } from '../../design-system/primitives/ProgressBar/ProgressBar' +import { SectionHeader } from '../../design-system/primitives/SectionHeader/SectionHeader' +import { Card } from '../../design-system/primitives/Card/Card' +import { CodeBlock } from '../../design-system/primitives/CodeBlock/CodeBlock' + +// Global filters +import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider' + +// Data +import { agents } from '../../mocks/agents' +import { SIDEBAR_APPS } from '../../mocks/sidebar' +import { agentEvents } from '../../mocks/agentEvents' + +import { useState } from 'react' + +// ── Mock trend data ────────────────────────────────────────────────────────── + +function buildTimeSeries(baseValue: number, variance: number, points = 30) { + const now = Date.now() + const interval = (6 * 60 * 60 * 1000) / points + return Array.from({ length: points }, (_, i) => ({ + x: new Date(now - (points - i) * interval), + y: Math.max(0, baseValue + (Math.random() - 0.5) * variance * 2), + })) +} + +function buildMemoryHistory(currentPct: number) { + return [ + { label: 'Heap Used', data: buildTimeSeries(currentPct * 0.7, 10) }, + { label: 'Heap Total', data: buildTimeSeries(currentPct * 0.9, 5) }, + ] +} + +// ── Mock log entries ───────────────────────────────────────────────────────── + +function buildLogEntries(agentName: string) { + const now = Date.now() + const MIN = 60_000 + return [ + { ts: new Date(now - 1 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.impl.DefaultCamelContext', msg: `Route order-validation started and consuming from: direct:validate` }, + { ts: new Date(now - 2 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.impl.DefaultCamelContext', msg: `Total 3 routes, of which 3 are started` }, + { ts: new Date(now - 5 * MIN).toISOString(), level: 'WARN', logger: 'o.a.c.processor.errorhandler', msg: `Failed delivery for exchangeId: ID-${agentName}-1710847200000-0-1. Exhausted after 3 attempts.` }, + { ts: new Date(now - 8 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.health.HealthCheckHelper', msg: `Health check [routes] is UP` }, + { ts: new Date(now - 12 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.health.HealthCheckHelper', msg: `Health check [consumers] is UP` }, + { ts: new Date(now - 15 * MIN).toISOString(), level: 'DEBUG', logger: 'o.a.c.component.kafka', msg: `KafkaConsumer[order-events] poll returned 42 records in 18ms` }, + { ts: new Date(now - 18 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.impl.engine.InternalRouteStartup', msg: `Route order-enrichment started and consuming from: kafka:order-events` }, + { ts: new Date(now - 25 * MIN).toISOString(), level: 'WARN', logger: 'o.a.c.component.http', msg: `HTTP endpoint https://payment-api.internal/verify returned 503 — will retry` }, + { ts: new Date(now - 30 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.impl.DefaultCamelContext', msg: `Apache Camel ${agentName} (CamelContext) is starting` }, + { ts: new Date(now - 32 * MIN).toISOString(), level: 'INFO', logger: 'org.springframework.boot', msg: `Started ${agentName} in 4.231 seconds (process running for 4.892)` }, + ] +} + +function formatLogTime(iso: string): string { + return new Date(iso).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }) +} + +// ── Mock JVM / process info ────────────────────────────────────────────────── + +function buildProcessInfo(agent: typeof agents[0]) { + return { + jvmVersion: 'OpenJDK 21.0.2+13', + camelVersion: '4.4.0', + springBootVersion: '3.2.4', + pid: Math.floor(1000 + Math.random() * 90000), + startTime: new Date(Date.now() - parseDuration(agent.uptime)).toISOString(), + heapMax: '512 MB', + heapUsed: `${Math.round(512 * agent.memoryUsagePct / 100)} MB`, + nonHeapUsed: `${Math.round(80 + Math.random() * 40)} MB`, + threadCount: Math.floor(20 + Math.random() * 30), + peakThreads: Math.floor(45 + Math.random() * 20), + gcCollections: Math.floor(Math.random() * 500), + gcPauseTotal: `${(Math.random() * 2).toFixed(2)}s`, + classesLoaded: Math.floor(8000 + Math.random() * 4000), + openFileDescriptors: Math.floor(50 + Math.random() * 200), + maxFileDescriptors: 65536, + } +} + +function parseDuration(s: string): number { + let ms = 0 + const dMatch = s.match(/(\d+)d/) + const hMatch = s.match(/(\d+)h/) + const mMatch = s.match(/(\d+)m/) + if (dMatch) ms += parseInt(dMatch[1]) * 86400000 + if (hMatch) ms += parseInt(hMatch[1]) * 3600000 + if (mMatch) ms += parseInt(mMatch[1]) * 60000 + return ms || 60000 +} + +// ── Component ──────────────────────────────────────────────────────────────── + +const LOG_TABS = [ + { label: 'All', value: 'all' }, + { label: 'Warnings', value: 'warn' }, + { label: 'Errors', value: 'error' }, +] + +export function AgentInstance() { + const { appId, instanceId } = useParams<{ appId: string; instanceId: string }>() + const { isInTimeRange } = useGlobalFilters() + const [logFilter, setLogFilter] = useState('all') + + const agent = agents.find((a) => a.appId === appId && a.id === instanceId) + + const instanceEvents = useMemo(() => { + if (!agent) return [] + return agentEvents + .filter((e) => e.searchText?.toLowerCase().includes(agent.name.toLowerCase())) + .filter((e) => isInTimeRange(e.timestamp)) + }, [agent, isInTimeRange]) + + if (!agent) { + return ( + }> + +
+
Agent instance not found.
+
+
+ ) + } + + const processInfo = buildProcessInfo(agent) + const logEntries = buildLogEntries(agent.name) + const filteredLogs = logFilter === 'all' + ? logEntries + : logEntries.filter((l) => l.level === logFilter.toUpperCase()) + + const cpuData = buildTimeSeries(agent.cpuUsagePct, 15) + const memSeries = buildMemoryHistory(agent.memoryUsagePct) + const tpsSeries = [{ label: 'Throughput', data: buildTimeSeries(agent.tps, 5) }] + const errorSeries = [{ label: 'Errors', data: buildTimeSeries(agent.errorRate ? parseFloat(agent.errorRate) : 0.2, 2), color: 'var(--error)' }] + const threadSeries = [{ label: 'Threads', data: buildTimeSeries(processInfo.threadCount, 8) }] + const gcSeries = [{ label: 'GC Pause', data: buildTimeSeries(4, 6) }] + + const statusVariant = agent.status === 'live' ? 'live' : agent.status === 'stale' ? 'stale' : 'dead' + const statusColor = agent.status === 'live' ? 'success' : agent.status === 'stale' ? 'warning' : 'error' + + return ( + }> + + +
+ {/* Stat strip — 5 columns matching /agents */} +
+ 85 ? 'error' : agent.cpuUsagePct > 70 ? 'warning' : 'success'} /> + 85 ? 'error' : agent.memoryUsagePct > 70 ? 'warning' : 'success'} detail={`${processInfo.heapUsed} / ${processInfo.heapMax}`} /> + + + +
+ + {/* Scope trail + badges */} +
+ All Agents + + {appId} + + {agent.name} + + + +
+ + {/* Process info card — right below stat strip */} +
+ Process Information +
+ JVM + {processInfo.jvmVersion} + + Camel + {processInfo.camelVersion} + + Spring Boot + {processInfo.springBootVersion} + + Started + {new Date(processInfo.startTime).toLocaleString()} + + File Descriptors + {processInfo.openFileDescriptors} / {processInfo.maxFileDescriptors.toLocaleString()} +
+
+ + {/* Charts grid — 3x2 (CPU, Memory, Throughput, Errors, Threads, GC) */} +
+
+
+ CPU Usage + {agent.cpuUsagePct}% current +
+ +
+ +
+
+ Memory (Heap) + {processInfo.heapUsed} / {processInfo.heapMax} +
+ +
+ +
+
+ Throughput + {agent.tps.toFixed(1)} msg/s +
+ +
+ +
+
+ Error Rate + {agent.errorRate ?? '0 err/h'} +
+ +
+ +
+
+ Thread Count + {processInfo.threadCount} active +
+ +
+ +
+
+ GC Pauses + {processInfo.gcPauseTotal} total +
+ +
+
+ + {/* Log + Timeline side by side */} +
+ {/* Log viewer */} +
+
+ Application Log + +
+
+ {filteredLogs.map((entry, i) => ( +
+ {formatLogTime(entry.ts)} + + {entry.logger} + {entry.msg} +
+ ))} + {filteredLogs.length === 0 && ( +
No log entries match the selected filter.
+ )} +
+
+ + {/* Timeline */} +
+
+ Timeline + {instanceEvents.length} events +
+ {instanceEvents.length > 0 ? ( + + ) : ( +
No events in the selected time range.
+ )} +
+
+
+
+ ) +}