Files
design-system/src/pages/AgentHealth/AgentHealth.tsx
2026-04-02 18:09:16 +02:00

455 lines
18 KiB
TypeScript

import { useState, useMemo } from 'react'
import { useParams, Link } from 'react-router-dom'
import { ChevronRight } from 'lucide-react'
import styles from './AgentHealth.module.css'
// Layout
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
// Composites
import { GroupCard } from '../../design-system/composites/GroupCard/GroupCard'
import { DataTable } from '../../design-system/composites/DataTable/DataTable'
import { LineChart } from '../../design-system/composites/LineChart/LineChart'
import { EventFeed } from '../../design-system/composites/EventFeed/EventFeed'
import { DetailPanel } from '../../design-system/composites/DetailPanel/DetailPanel'
import type { Column } from '../../design-system/composites/DataTable/types'
// 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'
// Mock data
import { agents, type AgentHealth as AgentHealthData } from '../../mocks/agents'
import { agentEvents } from '../../mocks/agentEvents'
// ── URL scope parsing ────────────────────────────────────────────────────────
type Scope =
| { level: 'all' }
| { level: 'app'; appId: string }
function useScope(): Scope {
const { '*': rest } = useParams()
const segments = rest?.split('/').filter(Boolean) ?? []
if (segments.length >= 1) return { level: 'app', appId: segments[0] }
return { level: 'all' }
}
// ── Data grouping ────────────────────────────────────────────────────────────
interface AppGroup {
appId: string
instances: AgentHealthData[]
liveCount: number
staleCount: number
deadCount: number
totalTps: number
totalActiveRoutes: number
totalRoutes: number
}
function groupByApp(agentList: AgentHealthData[]): AppGroup[] {
const map = new Map<string, AgentHealthData[]>()
for (const a of agentList) {
const list = map.get(a.appId) ?? []
list.push(a)
map.set(a.appId, list)
}
return Array.from(map.entries()).map(([appId, instances]) => ({
appId,
instances,
liveCount: instances.filter((i) => i.status === 'live').length,
staleCount: instances.filter((i) => i.status === 'stale').length,
deadCount: instances.filter((i) => i.status === 'dead').length,
totalTps: instances.reduce((s, i) => s + i.tps, 0),
totalActiveRoutes: instances.reduce((s, i) => s + i.activeRoutes, 0),
totalRoutes: instances.reduce((s, i) => s + i.totalRoutes, 0),
}))
}
function appHealth(group: AppGroup): 'success' | 'warning' | 'error' {
if (group.deadCount > 0) return 'error'
if (group.staleCount > 0) return 'warning'
return 'success'
}
// ── Trend data (mock) ────────────────────────────────────────────────────────
function buildTrendData(agent: AgentHealthData) {
const now = Date.now()
const points = 20
const interval = (3 * 60 * 60 * 1000) / points
const throughput = Array.from({ length: points }, (_, i) => ({
x: new Date(now - (points - i) * interval),
y: Math.max(0, agent.tps + (Math.random() - 0.5) * 4),
}))
const errorRate = Array.from({ length: points }, (_, i) => ({
x: new Date(now - (points - i) * interval),
y: Math.max(0, (agent.errorRate ? parseFloat(agent.errorRate) : 0.5) + (Math.random() - 0.5) * 2),
}))
return { throughput, errorRate }
}
// ── Breadcrumb ───────────────────────────────────────────────────────────────
function buildBreadcrumb(scope: Scope) {
const crumbs: { label: string; href?: string }[] = [
{ label: 'Applications', href: '/apps' },
{ label: 'Agents', href: '/agents' },
]
if (scope.level === 'app') {
crumbs.push({ label: scope.appId })
}
return crumbs
}
// ── AgentHealth page ─────────────────────────────────────────────────────────
export function AgentHealth() {
const scope = useScope()
const { isInTimeRange } = useGlobalFilters()
const [selectedInstance, setSelectedInstance] = useState<AgentHealthData | null>(null)
const [panelOpen, setPanelOpen] = useState(false)
// Filter agents by scope
const filteredAgents = useMemo(() => {
if (scope.level === 'all') return agents
return agents.filter((a) => a.appId === scope.appId)
}, [scope])
const groups = useMemo(() => groupByApp(filteredAgents), [filteredAgents])
// Aggregate stats
const totalInstances = filteredAgents.length
const liveCount = filteredAgents.filter((a) => a.status === 'live').length
const staleCount = filteredAgents.filter((a) => a.status === 'stale').length
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))
// Build trend data for selected instance
const trendData = selectedInstance ? buildTrendData(selectedInstance) : null
// Column definitions for the instance DataTable
const instanceColumns: Column<AgentHealthData>[] = useMemo(() => [
{
key: 'status',
header: '',
width: '12px',
render: (_val, row) => (
<StatusDot variant={row.status === 'live' ? 'live' : row.status === 'stale' ? 'stale' : 'dead'} />
),
},
{
key: 'name',
header: 'Instance',
render: (_val, row) => (
<MonoText size="sm" className={styles.instanceName}>{row.name}</MonoText>
),
},
{
key: 'state',
header: 'State',
render: (_val, row) => (
<Badge
label={row.status.toUpperCase()}
color={row.status === 'live' ? 'success' : row.status === 'stale' ? 'warning' : 'error'}
variant="filled"
/>
),
},
{
key: 'uptime',
header: 'Uptime',
render: (_val, row) => (
<MonoText size="xs" className={styles.instanceMeta}>{row.uptime}</MonoText>
),
},
{
key: 'tps',
header: 'TPS',
render: (_val, row) => (
<MonoText size="xs" className={styles.instanceMeta}>{row.tps.toFixed(1)}/s</MonoText>
),
},
{
key: 'errorRate',
header: 'Errors',
render: (_val, row) => (
<MonoText size="xs" className={row.errorRate ? styles.instanceError : styles.instanceMeta}>
{row.errorRate ?? '0 err/h'}
</MonoText>
),
},
{
key: 'lastSeen',
header: 'Heartbeat',
render: (_val, row) => (
<MonoText size="xs" className={
row.status === 'dead' ? styles.instanceHeartbeatDead :
row.status === 'stale' ? styles.instanceHeartbeatStale :
styles.instanceMeta
}>
{row.lastSeen}
</MonoText>
),
},
], [])
function handleInstanceClick(inst: AgentHealthData) {
setSelectedInstance(inst)
setPanelOpen(true)
}
// Detail panel tabs
const detailTabs = selectedInstance
? [
{
label: 'Overview',
value: 'overview',
content: (
<div className={styles.detailContent}>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Status</span>
<Badge
label={selectedInstance.status.toUpperCase()}
color={selectedInstance.status === 'live' ? 'success' : selectedInstance.status === 'stale' ? 'warning' : 'error'}
/>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Application</span>
<MonoText size="xs">{selectedInstance.appId}</MonoText>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Version</span>
<MonoText size="xs">{selectedInstance.version}</MonoText>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Uptime</span>
<MonoText size="xs">{selectedInstance.uptime}</MonoText>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Last Seen</span>
<MonoText size="xs">{selectedInstance.lastSeen}</MonoText>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Throughput</span>
<MonoText size="xs">{selectedInstance.tps.toFixed(1)}/s</MonoText>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Errors</span>
<MonoText size="xs" className={selectedInstance.errorRate ? styles.instanceError : undefined}>
{selectedInstance.errorRate ?? '0 err/h'}
</MonoText>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Routes</span>
<span>{selectedInstance.activeRoutes}/{selectedInstance.totalRoutes} active</span>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Memory</span>
<div className={styles.detailProgress}>
<ProgressBar
value={selectedInstance.memoryUsagePct}
variant={selectedInstance.memoryUsagePct > 85 ? 'error' : selectedInstance.memoryUsagePct > 70 ? 'warning' : 'success'}
/>
<MonoText size="xs">{selectedInstance.memoryUsagePct}%</MonoText>
</div>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>CPU</span>
<div className={styles.detailProgress}>
<ProgressBar
value={selectedInstance.cpuUsagePct}
variant={selectedInstance.cpuUsagePct > 85 ? 'error' : selectedInstance.cpuUsagePct > 70 ? 'warning' : 'success'}
/>
<MonoText size="xs">{selectedInstance.cpuUsagePct}%</MonoText>
</div>
</div>
</div>
),
},
{
label: 'Performance',
value: 'performance',
content: trendData ? (
<div className={styles.detailContent}>
<div className={styles.chartPanel}>
<div className={styles.chartTitle}>Throughput (msg/s)</div>
<LineChart
series={[{ label: 'tps', data: trendData.throughput }]}
height={160}
width={360}
yLabel="msg/s"
/>
</div>
<div className={styles.chartPanel}>
<div className={styles.chartTitle}>Error Rate (err/h)</div>
<LineChart
series={[{ label: 'errors', data: trendData.errorRate, color: 'var(--error)' }]}
height={160}
width={360}
yLabel="err/h"
/>
</div>
</div>
) : null,
},
]
: []
const isFullWidth = scope.level !== 'all'
return (
<>
<TopBar
breadcrumb={buildBreadcrumb(scope)}
environment="PRODUCTION"
user={{ name: 'hendrik' }}
/>
<div className={styles.content}>
{/* Stat strip */}
<div className={styles.statStrip}>
<StatCard
label="Total Agents"
value={String(totalInstances)}
accent={deadCount > 0 ? 'warning' : 'amber'}
detail={
<span className={styles.breakdown}>
<span className={styles.bpLive}><StatusDot variant="live" /> {liveCount} live</span>
<span className={styles.bpStale}><StatusDot variant="stale" /> {staleCount} stale</span>
<span className={styles.bpDead}><StatusDot variant="dead" /> {deadCount} dead</span>
</span>
}
/>
<StatCard
label="Applications"
value={String(groups.length)}
accent="running"
detail={
<span className={styles.breakdown}>
<span className={styles.bpLive}><StatusDot variant="live" /> {groups.filter((g) => g.deadCount === 0 && g.staleCount === 0).length} healthy</span>
<span className={styles.bpStale}><StatusDot variant="stale" /> {groups.filter((g) => g.staleCount > 0 && g.deadCount === 0).length} degraded</span>
<span className={styles.bpDead}><StatusDot variant="dead" /> {groups.filter((g) => g.deadCount > 0).length} critical</span>
</span>
}
/>
<StatCard
label="Active Routes"
value={<span className={styles[totalActiveRoutes === 0 ? 'routesError' : totalActiveRoutes < totalRoutes ? 'routesWarning' : 'routesSuccess']}>{totalActiveRoutes}/{totalRoutes}</span>}
accent={totalActiveRoutes === 0 ? 'error' : totalActiveRoutes < totalRoutes ? 'warning' : 'success'}
detail={totalActiveRoutes < totalRoutes ? `${totalRoutes - totalActiveRoutes} suspended` : 'all routes active'}
/>
<StatCard
label="Total TPS"
value={totalTps.toFixed(1)}
accent="amber"
detail="msg/s"
trend="up"
trendValue="4.2%"
/>
<StatCard
label="Dead"
value={String(deadCount)}
accent={deadCount > 0 ? 'error' : 'success'}
detail={deadCount > 0 ? 'requires attention' : 'all healthy'}
/>
</div>
{/* Scope trail + badges */}
<div className={styles.scopeTrail}>
{scope.level !== 'all' && (
<>
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
<span className={styles.scopeSep}><ChevronRight size={12} /></span>
<span className={styles.scopeCurrent}>{scope.appId}</span>
</>
)}
<Badge
label={`${liveCount}/${totalInstances} live`}
color={deadCount > 0 ? 'error' : staleCount > 0 ? 'warning' : 'success'}
variant="filled"
/>
</div>
{/* Group cards grid */}
<div className={isFullWidth ? styles.groupGridSingle : styles.groupGrid}>
{groups.map((group) => (
<GroupCard
key={group.appId}
title={group.appId}
accent={appHealth(group)}
headerRight={
<Badge
label={`${group.liveCount}/${group.instances.length} LIVE`}
color={appHealth(group)}
variant="filled"
/>
}
meta={
<div className={styles.groupMeta}>
<span><strong>{group.totalTps.toFixed(1)}</strong> msg/s</span>
<span><strong>{group.totalActiveRoutes}</strong>/{group.totalRoutes} routes</span>
<span>
<StatusDot variant={appHealth(group) === 'success' ? 'live' : appHealth(group) === 'warning' ? 'stale' : 'dead'} />
</span>
</div>
}
footer={group.deadCount > 0 ? (
<div className={styles.alertBanner}>
<span className={styles.alertIcon}>&#9888;</span>
<span>Single point of failure {group.deadCount === group.instances.length ? 'no redundancy' : `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`}</span>
</div>
) : undefined}
>
<DataTable<AgentHealthData>
columns={instanceColumns}
data={group.instances}
onRowClick={handleInstanceClick}
selectedId={panelOpen ? selectedInstance?.id : undefined}
pageSize={50}
flush
/>
</GroupCard>
))}
</div>
{/* EventFeed */}
{filteredEvents.length > 0 && (
<div className={styles.eventCard}>
<div className={styles.eventCardHeader}>
<span className={styles.sectionTitle}>Timeline</span>
<span className={styles.sectionMeta}>{filteredEvents.length} events</span>
</div>
<EventFeed events={filteredEvents} />
</div>
)}
</div>
{/* Detail panel (portals itself) */}
{selectedInstance && (
<DetailPanel
open={panelOpen}
onClose={() => setPanelOpen(false)}
title={selectedInstance.name}
tabs={detailTabs}
/>
)}
</>
)
}