455 lines
18 KiB
TypeScript
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}>⚠</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}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|