- Route / redirects to /apps, Dashboard serves both /apps and /apps/:id - When appId is present, exchanges/routes/agents/search are scoped to that app - Remove Dashboards sidebar link, add Metrics link - Sidebar section labels (Applications, Agents) are now clickable nav links with separate chevron for collapse toggle - Update all breadcrumbs from Dashboard/href:'/' to Applications/href:'/apps' - Remove AppDetail page (replaced by scoped Dashboard) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
332 lines
14 KiB
TypeScript
332 lines
14 KiB
TypeScript
import { useMemo } from 'react'
|
|
import { useParams, useNavigate, Link } 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 { GroupCard } from '../../design-system/composites/GroupCard/GroupCard'
|
|
import { LineChart } from '../../design-system/composites/LineChart/LineChart'
|
|
import { EventFeed } from '../../design-system/composites/EventFeed/EventFeed'
|
|
|
|
// 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'
|
|
|
|
// Mock data
|
|
import { agents, type AgentHealth as AgentHealthData } from '../../mocks/agents'
|
|
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
|
import { agentEvents } from '../../mocks/agentEvents'
|
|
|
|
// ── URL scope parsing ────────────────────────────────────────────────────────
|
|
|
|
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] }
|
|
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' || scope.level === 'instance') {
|
|
crumbs.push({ label: scope.appId, href: `/agents/${scope.appId}` })
|
|
}
|
|
if (scope.level === 'instance') {
|
|
crumbs.push({ label: scope.instanceId })
|
|
}
|
|
return crumbs
|
|
}
|
|
|
|
// ── AgentHealth page ─────────────────────────────────────────────────────────
|
|
|
|
export function AgentHealth() {
|
|
const scope = useScope()
|
|
const navigate = useNavigate()
|
|
|
|
// 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)
|
|
}, [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)
|
|
|
|
// Events are a global timeline feed — show all regardless of scope
|
|
const filteredEvents = agentEvents
|
|
|
|
// Single instance for expanded charts
|
|
const singleInstance = scope.level === 'instance' ? filteredAgents[0] : null
|
|
const trendData = singleInstance ? buildTrendData(singleInstance) : null
|
|
|
|
const isFullWidth = scope.level !== 'all'
|
|
|
|
return (
|
|
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
|
<TopBar
|
|
breadcrumb={buildBreadcrumb(scope)}
|
|
environment="PRODUCTION"
|
|
user={{ name: 'hendrik' }}
|
|
/>
|
|
|
|
<div className={styles.content}>
|
|
{/* Stat strip */}
|
|
<div className={styles.statStrip}>
|
|
<StatCard label="Total Instances" value={String(totalInstances)} />
|
|
<StatCard label="Live" value={String(liveCount)} accent="success" />
|
|
<StatCard label="Stale" value={String(staleCount)} accent={staleCount > 0 ? 'warning' : undefined} />
|
|
<StatCard label="Dead" value={String(deadCount)} accent={deadCount > 0 ? 'error' : undefined} />
|
|
<StatCard label="Total TPS" value={`${totalTps.toFixed(1)}/s`} />
|
|
<StatCard label="Active Routes" value={String(totalActiveRoutes)} />
|
|
</div>
|
|
|
|
{/* Scope breadcrumb trail */}
|
|
{scope.level !== 'all' && (
|
|
<div className={styles.scopeTrail}>
|
|
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
|
|
{scope.level === 'instance' && (
|
|
<>
|
|
<span className={styles.scopeSep}>▸</span>
|
|
<Link to={`/agents/${scope.appId}`} className={styles.scopeLink}>{scope.appId}</Link>
|
|
</>
|
|
)}
|
|
<span className={styles.scopeSep}>▸</span>
|
|
<span className={styles.scopeCurrent}>
|
|
{scope.level === 'app' ? scope.appId : scope.instanceId}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Section header */}
|
|
<div className={styles.sectionHeaderRow}>
|
|
<span className={styles.sectionTitle}>
|
|
{scope.level === 'all' ? 'Agents' : scope.level === 'app' ? scope.appId : scope.instanceId}
|
|
</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}
|
|
>
|
|
<table className={styles.instanceTable}>
|
|
<thead>
|
|
<tr>
|
|
<th className={styles.thStatus} />
|
|
<th>Instance</th>
|
|
<th>State</th>
|
|
<th>Uptime</th>
|
|
<th>TPS</th>
|
|
<th>Errors</th>
|
|
<th>Heartbeat</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{group.instances.map((inst) => (
|
|
<>
|
|
<tr
|
|
key={inst.id}
|
|
className={[
|
|
styles.instanceRow,
|
|
scope.level === 'instance' && scope.instanceId === inst.id ? styles.instanceRowActive : '',
|
|
].filter(Boolean).join(' ')}
|
|
onClick={() => navigate(`/agents/${inst.appId}/${inst.id}`)}
|
|
>
|
|
<td className={styles.tdStatus}>
|
|
<StatusDot variant={inst.status === 'live' ? 'live' : inst.status === 'stale' ? 'stale' : 'dead'} />
|
|
</td>
|
|
<td>
|
|
<MonoText size="sm" className={styles.instanceName}>{inst.name}</MonoText>
|
|
</td>
|
|
<td>
|
|
<Badge
|
|
label={inst.status.toUpperCase()}
|
|
color={inst.status === 'live' ? 'success' : inst.status === 'stale' ? 'warning' : 'error'}
|
|
variant="filled"
|
|
/>
|
|
</td>
|
|
<td>
|
|
<MonoText size="xs" className={styles.instanceMeta}>{inst.uptime}</MonoText>
|
|
</td>
|
|
<td>
|
|
<MonoText size="xs" className={styles.instanceMeta}>{inst.tps.toFixed(1)}/s</MonoText>
|
|
</td>
|
|
<td>
|
|
<MonoText size="xs" className={inst.errorRate ? styles.instanceError : styles.instanceMeta}>
|
|
{inst.errorRate ?? '0 err/h'}
|
|
</MonoText>
|
|
</td>
|
|
<td>
|
|
<MonoText size="xs" className={
|
|
inst.status === 'dead' ? styles.instanceHeartbeatDead :
|
|
inst.status === 'stale' ? styles.instanceHeartbeatStale :
|
|
styles.instanceMeta
|
|
}>
|
|
{inst.lastSeen}
|
|
</MonoText>
|
|
</td>
|
|
</tr>
|
|
|
|
{/* Expanded charts for single instance */}
|
|
{singleInstance?.id === inst.id && trendData && (
|
|
<tr key={`${inst.id}-charts`} className={styles.chartRow}>
|
|
<td colSpan={7}>
|
|
<div className={styles.instanceCharts}>
|
|
<div className={styles.chartPanel}>
|
|
<div className={styles.chartTitle}>Throughput (msg/s)</div>
|
|
<LineChart
|
|
series={[{ label: 'tps', data: trendData.throughput }]}
|
|
height={160}
|
|
width={480}
|
|
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={480}
|
|
yLabel="err/h"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</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>
|
|
</AppShell>
|
|
)
|
|
}
|