From d9483ec4d11826d44b674015b73bfa4d22f8d631 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:23:13 +0100 Subject: [PATCH] refactor: AgentHealth slide-in detail panel and richer stat cards - Instance detail now opens in a DetailPanel (slide-in from right) with Overview and Performance tabs instead of navigating away - Stat strip matches mock design: 5 cards with colored StatusDot breakdowns, labeled states (live/stale/dead, healthy/degraded/critical) - Active Routes shows colored ratio (green/yellow/red) based on state - Groups renamed to Applications - StatCard value/detail props now accept ReactNode for rich content Co-Authored-By: Claude Opus 4.6 (1M context) --- .../primitives/StatCard/StatCard.tsx | 5 +- src/pages/AgentHealth/AgentHealth.module.css | 55 ++- src/pages/AgentHealth/AgentHealth.tsx | 321 ++++++++++++------ 3 files changed, 261 insertions(+), 120 deletions(-) diff --git a/src/design-system/primitives/StatCard/StatCard.tsx b/src/design-system/primitives/StatCard/StatCard.tsx index ac13196..8a809f9 100644 --- a/src/design-system/primitives/StatCard/StatCard.tsx +++ b/src/design-system/primitives/StatCard/StatCard.tsx @@ -1,10 +1,11 @@ import styles from './StatCard.module.css' import { Sparkline } from '../Sparkline/Sparkline' +import type { ReactNode } from 'react' interface StatCardProps { label: string - value: string | number - detail?: string + value: ReactNode + detail?: ReactNode trend?: 'up' | 'down' | 'neutral' trendValue?: string accent?: 'amber' | 'success' | 'warning' | 'error' | 'running' diff --git a/src/pages/AgentHealth/AgentHealth.module.css b/src/pages/AgentHealth/AgentHealth.module.css index bb6bc84..322e6e2 100644 --- a/src/pages/AgentHealth/AgentHealth.module.css +++ b/src/pages/AgentHealth/AgentHealth.module.css @@ -10,11 +10,27 @@ /* Stat strip */ .statStrip { display: grid; - grid-template-columns: repeat(6, 1fr); + grid-template-columns: repeat(5, 1fr); gap: 10px; margin-bottom: 16px; } +/* Stat breakdown with colored dots */ +.breakdown { + display: flex; + gap: 8px; + font-size: 11px; + font-family: var(--font-mono); +} + +.bpLive { color: var(--success); display: inline-flex; align-items: center; gap: 3px; } +.bpStale { color: var(--warning); display: inline-flex; align-items: center; gap: 3px; } +.bpDead { color: var(--error); display: inline-flex; align-items: center; gap: 3px; } + +.routesSuccess { color: var(--success); } +.routesWarning { color: var(--warning); } +.routesError { color: var(--error); } + /* Scope breadcrumb trail */ .scopeTrail { display: flex; @@ -178,11 +194,6 @@ box-shadow: inset 3px 0 0 var(--amber); } -/* Chart expansion row */ -.chartRow td { - padding: 0; -} - /* Instance fields */ .instanceName { font-weight: 600; @@ -211,17 +222,35 @@ white-space: nowrap; } -/* Instance expanded charts */ -.instanceCharts { - display: grid; - grid-template-columns: 1fr 1fr; +/* Detail panel content */ +.detailContent { + display: flex; + flex-direction: column; gap: 12px; - padding: 12px 16px; - background: var(--bg-raised); - border-top: 1px solid var(--border-subtle); +} + +.detailRow { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 12px; + font-family: var(--font-body); + padding: 4px 0; border-bottom: 1px solid var(--border-subtle); } +.detailLabel { + color: var(--text-muted); + font-weight: 500; +} + +.detailProgress { + display: flex; + align-items: center; + gap: 8px; + width: 140px; +} + .chartPanel { display: flex; flex-direction: column; diff --git a/src/pages/AgentHealth/AgentHealth.tsx b/src/pages/AgentHealth/AgentHealth.tsx index fb52db9..79dac51 100644 --- a/src/pages/AgentHealth/AgentHealth.tsx +++ b/src/pages/AgentHealth/AgentHealth.tsx @@ -1,5 +1,5 @@ -import { useMemo } from 'react' -import { useParams, useNavigate, Link } from 'react-router-dom' +import { useState, useMemo } from 'react' +import { useParams, Link } from 'react-router-dom' import styles from './AgentHealth.module.css' // Layout @@ -11,12 +11,14 @@ import { TopBar } from '../../design-system/layout/TopBar/TopBar' import { GroupCard } from '../../design-system/composites/GroupCard/GroupCard' import { LineChart } from '../../design-system/composites/LineChart/LineChart' import { EventFeed } from '../../design-system/composites/EventFeed/EventFeed' +import { DetailPanel } from '../../design-system/composites/DetailPanel/DetailPanel' // 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' // Global filters import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider' @@ -31,13 +33,11 @@ import { agentEvents } from '../../mocks/agentEvents' type Scope = | { level: 'all' } | { level: 'app'; appId: string } - | { level: 'instance'; appId: string; instanceId: string } function useScope(): Scope { const { '*': rest } = useParams() const segments = rest?.split('/').filter(Boolean) ?? [] - if (segments.length >= 2) return { level: 'instance', appId: segments[0], instanceId: segments[1] } - if (segments.length === 1) return { level: 'app', appId: segments[0] } + if (segments.length >= 1) return { level: 'app', appId: segments[0] } return { level: 'all' } } @@ -106,11 +106,8 @@ function buildBreadcrumb(scope: Scope) { { label: 'Applications', href: '/apps' }, { label: 'Agents', href: '/agents' }, ] - if (scope.level === 'app' || scope.level === 'instance') { - crumbs.push({ label: scope.appId, href: `/agents/${scope.appId}` }) - } - if (scope.level === 'instance') { - crumbs.push({ label: scope.instanceId }) + if (scope.level === 'app') { + crumbs.push({ label: scope.appId }) } return crumbs } @@ -119,14 +116,14 @@ function buildBreadcrumb(scope: Scope) { export function AgentHealth() { const scope = useScope() - const navigate = useNavigate() const { isInTimeRange } = useGlobalFilters() + const [selectedInstance, setSelectedInstance] = useState(null) + const [panelOpen, setPanelOpen] = useState(false) // Filter agents by scope const filteredAgents = useMemo(() => { if (scope.level === 'all') return agents - if (scope.level === 'app') return agents.filter((a) => a.appId === scope.appId) - return agents.filter((a) => a.appId === scope.appId && a.id === scope.instanceId) + return agents.filter((a) => a.appId === scope.appId) }, [scope]) const groups = useMemo(() => groupByApp(filteredAgents), [filteredAgents]) @@ -138,18 +135,132 @@ export function AgentHealth() { const deadCount = filteredAgents.filter((a) => a.status === 'dead').length const totalTps = filteredAgents.reduce((s, a) => s + a.tps, 0) const totalActiveRoutes = filteredAgents.reduce((s, a) => s + a.activeRoutes, 0) + const totalRoutes = filteredAgents.reduce((s, a) => s + a.totalRoutes, 0) // Filter events by global time range const filteredEvents = agentEvents.filter((e) => isInTimeRange(e.timestamp)) - // Single instance for expanded charts - const singleInstance = scope.level === 'instance' ? filteredAgents[0] : null - const trendData = singleInstance ? buildTrendData(singleInstance) : null + // Build trend data for selected instance + const trendData = selectedInstance ? buildTrendData(selectedInstance) : null + + function handleInstanceClick(inst: AgentHealthData) { + setSelectedInstance(inst) + setPanelOpen(true) + } + + // Detail panel tabs + const detailTabs = selectedInstance + ? [ + { + label: 'Overview', + value: 'overview', + content: ( +
+
+ Status + +
+
+ Application + {selectedInstance.appId} +
+
+ Version + {selectedInstance.version} +
+
+ Uptime + {selectedInstance.uptime} +
+
+ Last Seen + {selectedInstance.lastSeen} +
+
+ Throughput + {selectedInstance.tps.toFixed(1)}/s +
+
+ Errors + + {selectedInstance.errorRate ?? '0 err/h'} + +
+
+ Routes + {selectedInstance.activeRoutes}/{selectedInstance.totalRoutes} active +
+
+ Memory +
+ 85 ? 'error' : selectedInstance.memoryUsagePct > 70 ? 'warning' : 'success'} + /> + {selectedInstance.memoryUsagePct}% +
+
+
+ CPU +
+ 85 ? 'error' : selectedInstance.cpuUsagePct > 70 ? 'warning' : 'success'} + /> + {selectedInstance.cpuUsagePct}% +
+
+
+ ), + }, + { + label: 'Performance', + value: 'performance', + content: trendData ? ( +
+
+
Throughput (msg/s)
+ +
+
+
Error Rate (err/h)
+ +
+
+ ) : null, + }, + ] + : [] const isFullWidth = scope.level !== 'all' return ( - }> + } + detail={ + selectedInstance ? ( + setPanelOpen(false)} + title={selectedInstance.name} + tabs={detailTabs} + /> + ) : undefined + } + > {/* Stat strip */}
- - - 0 ? 'warning' : undefined} /> - 0 ? 'error' : undefined} /> - - + 0 ? 'warning' : 'amber'} + detail={ + + {liveCount} live + {staleCount} stale + {deadCount} dead + + } + /> + + {groups.filter((g) => g.deadCount === 0 && g.staleCount === 0).length} healthy + {groups.filter((g) => g.staleCount > 0 && g.deadCount === 0).length} degraded + {groups.filter((g) => g.deadCount > 0).length} critical + + } + /> + {totalActiveRoutes}/{totalRoutes}} + accent={totalActiveRoutes === 0 ? 'error' : totalActiveRoutes < totalRoutes ? 'warning' : 'success'} + detail={totalActiveRoutes < totalRoutes ? `${totalRoutes - totalActiveRoutes} suspended` : 'all routes active'} + /> + + 0 ? 'error' : 'success'} + detail={deadCount > 0 ? 'requires attention' : 'all healthy'} + />
{/* Scope breadcrumb trail */} {scope.level !== 'all' && (
All Agents - {scope.level === 'instance' && ( - <> - - {scope.appId} - - )} - - {scope.level === 'app' ? scope.appId : scope.instanceId} - + {scope.appId}
)} {/* Section header */}
- {scope.level === 'all' ? 'Agents' : scope.level === 'app' ? scope.appId : scope.instanceId} + {scope.level === 'all' ? 'Agents' : scope.appId} {group.instances.map((inst) => ( - <> - navigate(`/agents/${inst.appId}/${inst.id}`)} - > - - - - - {inst.name} - - - - - - {inst.uptime} - - - {inst.tps.toFixed(1)}/s - - - - {inst.errorRate ?? '0 err/h'} - - - - - {inst.lastSeen} - - - - - {/* Expanded charts for single instance */} - {singleInstance?.id === inst.id && trendData && ( - - -
-
-
Throughput (msg/s)
- -
-
-
Error Rate (err/h)
- -
-
- - - )} - + handleInstanceClick(inst)} + > + + + + + {inst.name} + + + + + + {inst.uptime} + + + {inst.tps.toFixed(1)}/s + + + + {inst.errorRate ?? '0 err/h'} + + + + + {inst.lastSeen} + + + ))}