diff --git a/COMPONENT_GUIDE.md b/COMPONENT_GUIDE.md index c380d29..4d91a92 100644 --- a/COMPONENT_GUIDE.md +++ b/COMPONENT_GUIDE.md @@ -149,6 +149,7 @@ TreeView for hierarchical data (Application → Routes → Processors) | EmptyState | primitive | Placeholder for empty content areas | | EventFeed | composite | Chronological event log with severity | | FilterBar | composite | Search + filter controls for data views | +| GroupCard | composite | Card with header, meta row, children, and optional footer/alert. Used for grouping instances by application. | | FilterPill | primitive | Individual filter chip (active/inactive) | | FormField | primitive | Wrapper adding label, hint, error to any input | | InfoCallout | primitive | Inline contextual note with variant colors | diff --git a/docs/superpowers/specs/2026-03-18-agent-health-page.md b/docs/superpowers/specs/2026-03-18-agent-health-page.md new file mode 100644 index 0000000..a35c555 --- /dev/null +++ b/docs/superpowers/specs/2026-03-18-agent-health-page.md @@ -0,0 +1,237 @@ +# Agent Health Page — Progressive Filtering + +## Context + +The Cameleer3 sidebar now has an "Agents" section with a tree of applications and their running instances. Clicking the "Agents" section header should navigate to a full Agent Health page showing all applications and their instances. Clicking an app in the tree narrows to that app's instances. Clicking an instance narrows to that single instance with charts. + +The page follows the mockup at `ui-mocks/mock-v3-agent-health.html`: stat strip at top, application group cards in a 2-column grid, instance rows within each card, and an EventFeed at the bottom. + +### Domain Model + +- **Application** (e.g., "order-service") = logical grouping for exchange executions +- **Agent** = the application's running binary code (synonymous with "application") +- **Instance** (e.g., "prod-1") = a running copy of the agent on a specific server + +## Routing + +One page component handles three URL levels: + +| URL | Scope | What shows | +|-----|-------|-----------| +| `/agents` | All | All applications, all instances | +| `/agents/:appId` | App | One application's instances (full-width card) | +| `/agents/:appId/:instanceId` | Instance | Single instance with expanded charts | + +React Router config: replace both `/agents` and `/agents/:id` routes with a single `} />`. Remove the `AgentDetail` import and route (the AgentHealth page now handles all `/agents/*` paths). The `AgentDetail.tsx` stub page becomes dead code and should be deleted. + +**URL parsing in the component:** +```ts +const { '*': rest } = useParams() +const segments = rest?.split('/').filter(Boolean) ?? [] +// segments.length === 0 → all +// segments.length === 1 → appId = segments[0] +// segments.length === 2 → appId = segments[0], instanceId = segments[1] +``` + +## Sidebar Changes + +### Section header split + +The "Agents" section header becomes both a navigation link and a collapse toggle: +- The text "Agents" is a `` (proper anchor semantics for right-click, screen readers) +- The chevron (right side) is a ` +
+ Agents + +
{!agentsCollapsed && ( () - - return ( - }> - - - - ) -} diff --git a/src/pages/AgentHealth/AgentHealth.module.css b/src/pages/AgentHealth/AgentHealth.module.css index 070b929..611bef8 100644 --- a/src/pages/AgentHealth/AgentHealth.module.css +++ b/src/pages/AgentHealth/AgentHealth.module.css @@ -7,44 +7,44 @@ background: var(--bg-body); } -/* System overview strip */ -.overviewStrip { +/* Stat strip */ +.statStrip { display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; margin-bottom: 16px; } -.overviewCard { - background: var(--bg-surface); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-md); - box-shadow: var(--shadow-card); - padding: 12px 16px; - text-align: center; +/* Scope breadcrumb trail */ +.scopeTrail { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 12px; + font-size: 12px; } -.overviewLabel { - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.6px; +.scopeLink { + color: var(--amber); + text-decoration: none; + font-weight: 500; +} + +.scopeLink:hover { + text-decoration: underline; +} + +.scopeSep { color: var(--text-muted); - margin-bottom: 4px; + font-size: 10px; } -.overviewValue { - font-size: 22px; - font-weight: 700; - font-family: var(--font-mono); +.scopeCurrent { color: var(--text-primary); - line-height: 1.2; + font-weight: 600; + font-family: var(--font-mono); } -.valueLive { color: var(--success); } -.valueStale { color: var(--warning); } -.valueDead { color: var(--error); } - /* Section header */ .sectionHeaderRow { display: flex; @@ -65,119 +65,145 @@ font-family: var(--font-mono); } -/* Agent cards grid */ -.agentGrid { +/* Group cards grid */ +.groupGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; + margin-bottom: 20px; } -/* Agent card */ -.agentCard { - display: flex; - flex-direction: column; - gap: 0; - overflow: hidden; - padding: 0 !important; +.groupGridSingle { + display: grid; + grid-template-columns: 1fr; + gap: 14px; + margin-bottom: 20px; } -/* Agent card header */ -.agentCardHeader { - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 16px 10px; - cursor: pointer; - transition: background 0.15s; -} - -.agentCardHeader:hover { - background: var(--bg-hover); -} - -.agentCardLeft { - display: flex; - align-items: center; - gap: 10px; -} - -.agentCardName { - font-size: 14px; - font-weight: 700; - font-family: var(--font-mono); - color: var(--text-primary); -} - -.agentCardService { +/* Instance count badge in group header */ +.instanceCountBadge { font-size: 11px; - color: var(--text-secondary); font-family: var(--font-mono); + color: var(--text-muted); + background: var(--bg-inset); + padding: 2px 8px; + border-radius: 10px; } -.agentCardRight { +/* Group meta row */ +.groupMeta { display: flex; align-items: center; - gap: 10px; -} - -.expandIcon { - font-size: 10px; + gap: 16px; + font-size: 11px; color: var(--text-muted); } -/* Agent metrics row */ -.agentMetrics { - display: flex; - flex-wrap: wrap; - gap: 0; - padding: 6px 12px 12px; - border-top: 1px solid var(--border-subtle); +.groupMeta strong { + font-family: var(--font-mono); + color: var(--text-secondary); + font-weight: 600; } -.agentMetric { +/* Alert banner in group footer */ +.alertBanner { display: flex; - flex-direction: column; - align-items: flex-start; - padding: 6px 12px; - min-width: 80px; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: var(--error-bg); + font-size: 11px; + color: var(--error); + font-weight: 500; } -.metricLabel { +.alertIcon { + font-size: 14px; + flex-shrink: 0; +} + +/* Instance header row */ +.instanceHeader { + display: grid; + grid-template-columns: 8px minmax(80px, 1.2fr) auto auto auto auto auto; + gap: 12px; + padding: 4px 16px; font-size: 9px; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.6px; - color: var(--text-muted); - margin-bottom: 2px; + letter-spacing: 0.5px; + color: var(--text-faint); + border-bottom: 1px solid var(--border-subtle); } -.metricValue { +/* Instance row */ +.instanceRow { + display: grid; + grid-template-columns: 8px minmax(80px, 1.2fr) auto auto auto auto auto; + gap: 12px; + align-items: center; + padding: 8px 16px; + border-bottom: 1px solid var(--border-subtle); + font-size: 12px; + text-decoration: none; + color: inherit; + transition: background 0.1s; + cursor: pointer; +} + +.instanceRow:last-child { + border-bottom: none; +} + +.instanceRow:hover { + background: var(--bg-hover); +} + +.instanceRowActive { + background: var(--amber-bg); + border-left: 3px solid var(--amber); +} + +/* Instance fields */ +.instanceName { + font-weight: 600; color: var(--text-primary); } -.metricValueWarn { - color: var(--warning); - font-family: var(--font-mono); - font-size: 12px; +.instanceMeta { + color: var(--text-muted); + white-space: nowrap; } -.metricValueError { +.instanceError { color: var(--error); - font-family: var(--font-mono); - font-size: 12px; + white-space: nowrap; } -/* Expanded charts area */ -.agentCharts { +.instanceHeartbeatStale { + color: var(--warning); + font-weight: 600; + white-space: nowrap; +} + +.instanceHeartbeatDead { + color: var(--error); + font-weight: 600; + white-space: nowrap; +} + +/* Instance expanded charts */ +.instanceCharts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 12px 16px; background: var(--bg-raised); border-top: 1px solid var(--border-subtle); + border-bottom: 1px solid var(--border-subtle); } -.agentChart { +.chartPanel { display: flex; flex-direction: column; gap: 6px; @@ -190,3 +216,8 @@ text-transform: uppercase; letter-spacing: 0.5px; } + +/* Event section */ +.eventSection { + margin-top: 20px; +} diff --git a/src/pages/AgentHealth/AgentHealth.tsx b/src/pages/AgentHealth/AgentHealth.tsx index b315fa2..6da6334 100644 --- a/src/pages/AgentHealth/AgentHealth.tsx +++ b/src/pages/AgentHealth/AgentHealth.tsx @@ -1,4 +1,5 @@ -import { useState } from 'react' +import { useMemo } from 'react' +import { useParams, Link } from 'react-router-dom' import styles from './AgentHealth.module.css' // Layout @@ -7,226 +8,304 @@ 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 { Card } from '../../design-system/primitives/Card/Card' +import { StatCard } from '../../design-system/primitives/StatCard/StatCard' // Mock data -import { agents } from '../../mocks/agents' +import { agents, type AgentHealth as AgentHealthData } from '../../mocks/agents' import { SIDEBAR_APPS } from '../../mocks/sidebar' +import { agentEvents } from '../../mocks/agentEvents' -// ─── Build trend data for each agent ───────────────────────────────────────── -function buildAgentTrendSeries(agentId: string) { - const baseValues: Record = { - 'prod-1': { throughput: 14.2, errorRate: 0.2 }, - 'prod-2': { throughput: 11.8, errorRate: 3.1 }, - 'prod-3': { throughput: 12.1, errorRate: 0.5 }, - 'prod-4': { throughput: 9.1, errorRate: 0.3 }, - } - const base = baseValues[agentId] ?? { throughput: 10, errorRate: 1 } +// ── URL scope parsing ──────────────────────────────────────────────────────── - const now = new Date('2026-03-18T09:15:00') - const points = 20 - const intervalMs = (3 * 60 * 60 * 1000) / points // 3 hours +type Scope = + | { level: 'all' } + | { level: 'app'; appId: string } + | { level: 'instance'; appId: string; instanceId: string } - const throughputData = Array.from({ length: points }, (_, i) => ({ - x: new Date(now.getTime() - (points - i) * intervalMs), - y: Math.max(0, base.throughput + (Math.random() - 0.5) * 4), - })) - - const errorRateData = Array.from({ length: points }, (_, i) => ({ - x: new Date(now.getTime() - (points - i) * intervalMs), - y: Math.max(0, base.errorRate + (Math.random() - 0.5) * 2), - })) - - return { throughputData, errorRateData } +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' } } -// ─── Summary stats ──────────────────────────────────────────────────────────── -const liveCount = agents.filter((a) => a.status === 'live').length -const totalTps = agents.reduce((sum, a) => sum + parseFloat(a.tps), 0) -const totalActiveRoutes = agents.reduce((sum, a) => sum + a.activeRoutes, 0) +// ── Data grouping ──────────────────────────────────────────────────────────── -// ─── AgentHealth page ───────────────────────────────────────────────────────── -export function AgentHealth() { - const [expandedAgent, setExpandedAgent] = useState(null) +interface AppGroup { + appId: string + instances: AgentHealthData[] + liveCount: number + staleCount: number + deadCount: number + totalTps: number + totalActiveRoutes: number + totalRoutes: number +} - function toggleAgent(id: string) { - setExpandedAgent((prev) => (prev === id ? null : id)) +function groupByApp(agentList: AgentHealthData[]): AppGroup[] { + const map = new Map() + 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: 'System', href: '/' }, + { 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() + + // 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) + + // Filter events by scope + const filteredEvents = useMemo(() => { + if (scope.level === 'all') return agentEvents + if (scope.level === 'app') { + return agentEvents.filter((e) => e.message.includes(`[${scope.appId}]`)) + } + return agentEvents.filter( + (e) => e.message.includes(`[${scope.appId}]`) && e.message.includes(scope.instanceId), + ) + }, [scope]) + + // 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 ( - - } - > - {/* Top bar */} + }> - {/* Scrollable content */}
- - {/* System overview strip */} -
-
-
Total Agents
-
{agents.length}
-
-
-
Live
-
{liveCount}
-
-
-
Stale
-
a.status === 'stale') ? styles.valueStale : ''}`}> - {agents.filter((a) => a.status === 'stale').length} -
-
-
-
Dead
-
a.status === 'dead') ? styles.valueDead : ''}`}> - {agents.filter((a) => a.status === 'dead').length} -
-
-
-
Total TPS
-
{totalTps.toFixed(1)}/s
-
-
-
Active Routes
-
{totalActiveRoutes}
-
+ {/* Stat strip */} +
+ + + 0 ? 'warning' : undefined} /> + 0 ? 'error' : undefined} /> + +
+ {/* Scope breadcrumb trail */} + {scope.level !== 'all' && ( +
+ All Agents + {scope.level === 'instance' && ( + <> + + {scope.appId} + + )} + + + {scope.level === 'app' ? scope.appId : scope.instanceId} + +
+ )} + {/* Section header */}
- Agent Details - {liveCount}/{agents.length} live · Click to expand charts + + {scope.level === 'all' ? 'Agent Groups' : scope.level === 'app' ? scope.appId : scope.instanceId} + + + {liveCount}/{totalInstances} live +
- {/* Agent cards grid */} -
- {agents.map((agent) => { - const isExpanded = expandedAgent === agent.id - const trendData = isExpanded ? buildAgentTrendSeries(agent.id) : null - const statusVariant = agent.status === 'live' ? 'live' : agent.status === 'stale' ? 'stale' : 'dead' - const cardAccent = agent.status === 'live' ? 'success' : agent.status === 'stale' ? 'warning' : 'error' + {/* Group cards grid */} +
+ {groups.map((group) => ( + + {group.instances.length} instance{group.instances.length !== 1 ? 's' : ''} + + } + meta={ +
+ {group.totalTps.toFixed(1)} msg/s + {group.totalActiveRoutes}/{group.totalRoutes} routes + + + +
+ } + footer={group.deadCount > 0 ? ( +
+ + Single point of failure — {group.deadCount === group.instances.length ? 'no redundancy' : `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`} +
+ ) : undefined} + > + {/* Instance header row */} +
+ + Instance + State + Uptime + TPS + Errors + Heartbeat +
- return ( - - {/* Agent card header */} -
toggleAgent(agent.id)} - role="button" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') toggleAgent(agent.id) - }} - aria-expanded={isExpanded} - > -
- -
-
{agent.name}
-
{agent.service} {agent.version}
-
-
-
+ {/* Instance rows */} + {group.instances.map((inst) => ( +
+ + + {inst.name} - {isExpanded ? '▲' : '▼'} -
-
+ {inst.uptime} + {inst.tps.toFixed(1)}/s + + {inst.errorRate ?? '0 err/h'} + + + {inst.lastSeen} + + - {/* Agent metrics row */} -
-
- TPS - {agent.tps} -
-
- Uptime - {agent.uptime} -
-
- Last Seen - {agent.lastSeen} -
- {agent.errorRate && ( -
- Error Rate - {agent.errorRate} + {/* Expanded charts for single instance */} + {singleInstance?.id === inst.id && trendData && ( +
+
+
Throughput (msg/s)
+ +
+
+
Error Rate (err/h)
+ +
)} -
- CPU - 70 ? styles.metricValueWarn : styles.metricValue}> - {agent.cpuUsagePct}% - -
-
- Memory - 80 ? styles.metricValueError : agent.memoryUsagePct > 70 ? styles.metricValueWarn : styles.metricValue}> - {agent.memoryUsagePct}% - -
-
- Routes - - {agent.activeRoutes}/{agent.totalRoutes} - -
- - {/* Expanded detail: trend charts */} - {isExpanded && trendData && ( -
-
-
Throughput (msg/s)
- -
-
-
Error Rate (err/h)
- -
-
- )} - - ) - })} + ))} + + ))}
+ {/* EventFeed */} + {filteredEvents.length > 0 && ( +
+
+ Timeline +
+ +
+ )}
) diff --git a/src/pages/Inventory/sections/CompositesSection.tsx b/src/pages/Inventory/sections/CompositesSection.tsx index 33f192a..a3f8cfb 100644 --- a/src/pages/Inventory/sections/CompositesSection.tsx +++ b/src/pages/Inventory/sections/CompositesSection.tsx @@ -13,6 +13,7 @@ import { Dropdown, EventFeed, FilterBar, + GroupCard, LineChart, MenuItem, Modal, @@ -430,6 +431,25 @@ export function CompositesSection() {
+ {/* 11b. GroupCard */} + +
+ 3 instances} + meta={
34.4 msg/s9/9 routes
} + footer={undefined} + > +
Instance rows go here
+
+
+
+ {/* 12. FilterBar */}