feat: Agent Health page with progressive filtering and GroupCard component

Add URL-driven Agent Health page (/agents, /agents/:appId,
/agents/:appId/:instanceId) that progressively narrows from all
applications to a single instance with trend charts. Create
generic GroupCard composite for grouping instances by application.
Expand mock data to 8 instances across 4 apps with varied states.
Split sidebar Agents header into navigable link + collapse chevron.
Update agent tree paths to /agents/:appId/:instanceId. Add EventFeed
with lifecycle events. Change SidebarAgent.tps from string to number.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 18:22:14 +01:00
parent e69e5ab5fe
commit 8f93ea41ed
18 changed files with 990 additions and 380 deletions

View File

@@ -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 |

View File

@@ -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 `<Route path="/agents/*" element={<AgentHealth />} />`. 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 `<Link to="/agents">` (proper anchor semantics for right-click, screen readers)
- The chevron (right side) is a `<button>` that collapses/expands the tree
- Both targets are on the same row, distinct click zones
The "Applications" section header remains collapse-only for now (no link).
### Agent tree paths
`buildAgentTreeNodes` must update instance paths from `/agents/${agent.id}` to `/agents/${app.id}/${agent.id}`. App-level agent tree nodes keep `/agents/${app.id}`.
## New Component: GroupCard
A generic composite component for the design system. Usable for agent groups now, application groups later.
**Location:** `src/design-system/composites/GroupCard/`
### Props
```ts
interface GroupCardProps {
title: string
titleMono?: boolean // monospace title font (default: true)
headerRight?: ReactNode // right side of header (e.g., instance count badge)
meta?: ReactNode // aggregated stats row below header
footer?: ReactNode // bottom section (e.g., alert banner)
accent?: 'success' | 'warning' | 'error'
onClick?: () => void // optional click on header
className?: string
children: ReactNode // instance rows or any content
}
```
### Structure
```
┌─────────────────────────────────────────────┐
│ [accent border-left] │
│ HEADER: title (mono) ····· headerRight │ ← bg-raised, border-bottom
├─────────────────────────────────────────────┤
│ META: aggregated stats row (optional) │ ← border-bottom
├─────────────────────────────────────────────┤
│ CHILDREN: instance rows │
├─────────────────────────────────────────────┤
│ FOOTER: alert banner (optional) │ ← colored bg
└─────────────────────────────────────────────┘
```
### Styling
- Card chrome: `var(--bg-surface)`, `var(--border-subtle)`, `var(--shadow-card)`, `var(--radius-lg)`
- Header: `var(--bg-raised)` background, title in `var(--font-mono)` 14px 600
- Accent: 3px left border in accent color (similar mechanism to Card, but positioned on the left edge instead of Card's top border)
- Hover: `var(--shadow-md)` transition
### Inventory
Add GroupCard to the Inventory composites section with a demo.
## Page Layout
### Breadcrumb
Updates per scope level:
- `/agents``System > Agents`
- `/agents/:appId``System > Agents > order-service`
- `/agents/:appId/:instanceId``System > Agents > order-service > prod-1`
### Stat Strip (top)
6 StatCard components in a row grid. Values recalculate based on scope:
| Card | All scope | App scope | Instance scope |
|------|----------|-----------|---------------|
| Total Instances | count all | count app's | 1 |
| Live | live count | app's live | status badge |
| Stale | stale count | app's stale | — |
| Dead | dead count | app's dead | — |
| Total TPS | sum all | sum app's | instance TPS |
| Active Routes | sum all | sum app's | instance routes |
### Application Group Cards
2-column grid (CSS Grid) of GroupCard components. Each card:
**Header:** Application name (mono) + instance count badge (e.g., "3 instances")
**Meta row:** Aggregated TPS, total routes, overall health status
**Instance rows:** Grid layout per row:
- Status dot (live/stale/dead)
- Instance name (mono, bold)
- Status badge (LIVE/STALE/DEAD)
- Uptime
- Throughput (msg/s)
- Error rate
- Last heartbeat
**Footer (conditional):** Alert banner when an app has dead instances ("Single point of failure — no redundancy")
**Filtering behavior:**
- `/agents` → all cards in 2-col grid
- `/agents/:appId` → single card, full width, instance rows expandable with charts
- `/agents/:appId/:instanceId` → single card, single instance row expanded with throughput + error rate charts (using LineChart)
### Instance Expanded View
When viewing a single instance (`/agents/:appId/:instanceId`), the card shows:
- Instance header with all metrics
- Two LineChart panels side-by-side: Throughput (msg/s) and Error Rate (err/h)
- Built using the existing `LineChart` composite with mock trend data
### EventFeed (bottom)
Uses the existing `EventFeed` composite. Shows agent lifecycle events (started, stopped, stale, dead, config changes). Filtered to scope:
- `/agents` → all events
- `/agents/:appId` → events for that app's instances
- `/agents/:appId/:instanceId` → events for that instance only
Mock data: ~8-10 lifecycle events using `new Date(Date.now() - offset)` for fresh relative timestamps.
## Mock Data Changes
### `src/mocks/agents.ts`
Expand from 4 to ~10 instances across 4 applications. The new data is the source of truth — existing data inconsistencies (e.g., `prod-2` appearing under two apps) are overwritten.
Add `appId: string` field to `AgentHealth` interface for clean grouping without cross-referencing sidebar data.
Change `tps` from `string` to `number` — format as string at display time. This also requires updating the `SidebarAgent` interface in `Sidebar.tsx` from `tps: string` to `tps: number`, and the sidebar mock data.
| Application | Instances | Status |
|------------|-----------|--------|
| order-service | ord-1, ord-2, ord-3 | All LIVE (ord-3 recently restarted) |
| payment-svc | pay-1, pay-2 | pay-1 LIVE, pay-2 STALE |
| shipment-svc | ship-1, ship-2 | Both LIVE |
| notification-hub | notif-1 | DEAD (single point of failure) |
Each instance: id, name, appId, service, version, tps (number), lastSeen, status, errorRate, uptime, memoryUsagePct, cpuUsagePct, activeRoutes, totalRoutes.
### `src/mocks/sidebar.ts`
Update `SIDEBAR_APPS` agents arrays to match the expanded agent data. Also update `SidebarAgent.tps` to `number`.
### `src/mocks/agentEvents.ts` (new)
Export `agentEvents: FeedEvent[]` with ~8-10 lifecycle events. Each event includes an `appId` and optional `instanceId` for filtering.
## Files to Create
| File | Purpose |
|------|---------|
| `src/design-system/composites/GroupCard/GroupCard.tsx` | Generic group card component |
| `src/design-system/composites/GroupCard/GroupCard.module.css` | GroupCard styles |
| `src/design-system/composites/GroupCard/GroupCard.test.tsx` | GroupCard tests |
| `src/mocks/agentEvents.ts` | Agent lifecycle event mock data |
## Files to Modify
| File | Changes |
|------|---------|
| `src/pages/AgentHealth/AgentHealth.tsx` | Full rewrite: URL-driven filtering, GroupCard usage, stat recalculation |
| `src/pages/AgentHealth/AgentHealth.module.css` | Updated styles for group grid, instance rows, expanded charts |
| `src/design-system/layout/Sidebar/Sidebar.tsx` | Split Agents header (Link + chevron), update agent tree instance paths to `/agents/:appId/:instanceId`, change `SidebarAgent.tps` to number |
| `src/design-system/layout/Sidebar/Sidebar.module.css` | Styles for split section header |
| `src/mocks/agents.ts` | Expand to ~10 instances, add appId field, change tps to number |
| `src/mocks/sidebar.ts` | Update SIDEBAR_APPS agents to match, change tps to number |
| `src/App.tsx` | Replace `/agents` + `/agents/:id` with `/agents/*`, remove AgentDetail import |
| `src/design-system/composites/index.ts` | Export GroupCard |
| `src/pages/Inventory/sections/CompositesSection.tsx` | Add GroupCard demo |
| `COMPONENT_GUIDE.md` | Add GroupCard to component index |
## Files to Remove
| File | Reason |
|------|--------|
| `src/pages/AgentDetail/AgentDetail.tsx` | Superseded by AgentHealth handling all `/agents/*` paths |
## Implementation Order
1. GroupCard component + styles + tests
2. Expand mock data (agents.ts, sidebar.ts, agentEvents.ts)
3. Update SidebarAgent.tps to number + sidebar agent tree paths
4. Sidebar section header split (text=Link, chevron=button)
5. App.tsx route change (`/agents/*`), remove AgentDetail
6. AgentHealth page rewrite with progressive filtering
7. Inventory demo for GroupCard
8. Barrel exports + COMPONENT_GUIDE.md update
## Verification
1. `npx tsc --noEmit` — zero errors
2. `npx vitest run` — all tests pass
3. `npm run build` — clean build
4. Manual: `/agents` shows all apps in 2-col grid with stat strip + EventFeed
5. Manual: click app in sidebar Agents tree → page narrows to that app's instances
6. Manual: click instance → page narrows to single instance with charts
7. Manual: sidebar "Agents" text navigates to `/agents`, chevron collapses tree
8. Manual: breadcrumb updates per scope level
9. Manual: `/inventory` shows GroupCard demo

View File

@@ -6,7 +6,6 @@ import { ExchangeDetail } from './pages/ExchangeDetail/ExchangeDetail'
import { AgentHealth } from './pages/AgentHealth/AgentHealth'
import { Inventory } from './pages/Inventory/Inventory'
import { AppDetail } from './pages/AppDetail/AppDetail'
import { AgentDetail } from './pages/AgentDetail/AgentDetail'
import { Admin } from './pages/Admin/Admin'
import { ApiDocs } from './pages/ApiDocs/ApiDocs'
@@ -17,8 +16,7 @@ export default function App() {
<Route path="/metrics" element={<Metrics />} />
<Route path="/routes/:id" element={<RouteDetail />} />
<Route path="/exchanges/:id" element={<ExchangeDetail />} />
<Route path="/agents" element={<AgentHealth />} />
<Route path="/agents/:id" element={<AgentDetail />} />
<Route path="/agents/*" element={<AgentHealth />} />
<Route path="/apps/:id" element={<AppDetail />} />
<Route path="/admin" element={<Admin />} />
<Route path="/api-docs" element={<ApiDocs />} />

View File

@@ -0,0 +1,79 @@
.root {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
transition: box-shadow 0.15s;
}
.root:hover {
box-shadow: var(--shadow-md);
}
/* Accent variants — left border */
.accentSuccess {
border-left: 3px solid var(--success);
}
.accentWarning {
border-left: 3px solid var(--warning);
}
.accentError {
border-left: 3px solid var(--error);
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-raised);
}
.header[role='button'] {
cursor: pointer;
}
.titleMono {
font-size: 14px;
font-weight: 600;
font-family: var(--font-mono);
color: var(--text-primary);
}
.title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.headerRight {
display: flex;
align-items: center;
gap: 8px;
}
/* Meta row */
.meta {
display: flex;
gap: 16px;
padding: 8px 16px;
border-bottom: 1px solid var(--border-subtle);
font-size: 11px;
color: var(--text-muted);
background: var(--bg-surface);
}
/* Body */
.body {
padding: 0;
}
/* Footer */
.footer {
border-top: 1px solid var(--border-subtle);
}

View File

@@ -0,0 +1,58 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { GroupCard } from './GroupCard'
describe('GroupCard', () => {
it('renders title and children', () => {
render(<GroupCard title="Test Group"><p>Content</p></GroupCard>)
expect(screen.getByText('Test Group')).toBeInTheDocument()
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('renders title in mono font by default', () => {
render(<GroupCard title="Mono Title"><p>C</p></GroupCard>)
const title = screen.getByText('Mono Title')
expect(title.className).toContain('titleMono')
})
it('renders non-mono title when titleMono=false', () => {
render(<GroupCard title="Regular" titleMono={false}><p>C</p></GroupCard>)
const title = screen.getByText('Regular')
expect(title.className).toContain('title')
expect(title.className).not.toContain('titleMono')
})
it('renders headerRight content', () => {
render(<GroupCard title="T" headerRight={<span>Badge</span>}><p>C</p></GroupCard>)
expect(screen.getByText('Badge')).toBeInTheDocument()
})
it('renders meta row when provided', () => {
render(<GroupCard title="T" meta={<span>Meta info</span>}><p>C</p></GroupCard>)
expect(screen.getByText('Meta info')).toBeInTheDocument()
})
it('renders footer when provided', () => {
render(<GroupCard title="T" footer={<div>Alert</div>}><p>C</p></GroupCard>)
expect(screen.getByText('Alert')).toBeInTheDocument()
})
it('does not render meta when not provided', () => {
const { container } = render(<GroupCard title="T"><p>C</p></GroupCard>)
expect(container.querySelector('[class*="meta"]')).not.toBeInTheDocument()
})
it('applies accent class', () => {
const { container } = render(<GroupCard title="T" accent="error"><p>C</p></GroupCard>)
expect(container.firstChild).toHaveClass(/accentError/)
})
it('calls onClick when header is clicked', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
render(<GroupCard title="Clickable" onClick={onClick}><p>C</p></GroupCard>)
await user.click(screen.getByText('Clickable'))
expect(onClick).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,50 @@
import type { ReactNode } from 'react'
import styles from './GroupCard.module.css'
interface GroupCardProps {
title: string
titleMono?: boolean
headerRight?: ReactNode
meta?: ReactNode
footer?: ReactNode
accent?: 'success' | 'warning' | 'error'
onClick?: () => void
className?: string
children: ReactNode
}
export function GroupCard({
title,
titleMono = true,
headerRight,
meta,
footer,
accent,
onClick,
className,
children,
}: GroupCardProps) {
const rootClass = [
styles.root,
accent ? styles[`accent${accent.charAt(0).toUpperCase()}${accent.slice(1)}`] : '',
className ?? '',
].filter(Boolean).join(' ')
return (
<div className={rootClass}>
<div
className={styles.header}
onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick() } : undefined}
>
<span className={titleMono ? styles.titleMono : styles.title}>{title}</span>
{headerRight && <div className={styles.headerRight}>{headerRight}</div>}
</div>
{meta && <div className={styles.meta}>{meta}</div>}
<div className={styles.body}>{children}</div>
{footer && <div className={styles.footer}>{footer}</div>}
</div>
)
}

View File

@@ -11,6 +11,7 @@ export type { Column, DataTableProps } from './DataTable/types'
export { DetailPanel } from './DetailPanel/DetailPanel'
export { Dropdown } from './Dropdown/Dropdown'
export { EventFeed } from './EventFeed/EventFeed'
export { GroupCard } from './GroupCard/GroupCard'
export type { FeedEvent } from './EventFeed/EventFeed'
export { FilterBar } from './FilterBar/FilterBar'
export { LineChart } from './LineChart/LineChart'

View File

@@ -215,6 +215,40 @@
justify-content: center;
}
/* Section header as link (for Agents nav) */
.treeSectionLink {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--sidebar-muted);
text-decoration: none;
flex: 1;
transition: color 0.12s;
}
.treeSectionLink:hover {
color: var(--amber-light);
}
.treeSectionChevronBtn {
background: none;
border: none;
padding: 2px 4px;
margin: 0;
color: var(--sidebar-muted);
font-size: 9px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.12s;
}
.treeSectionChevronBtn:hover {
color: var(--sidebar-text);
}
.tree {
list-style: none;
margin: 0;

View File

@@ -16,8 +16,8 @@ const TEST_APPS: SidebarApp[] = [
{ id: 'order-enrichment', name: 'order-enrichment', exchangeCount: 541 },
],
agents: [
{ id: 'prod-1', name: 'prod-1', status: 'live', tps: '14.2/s' },
{ id: 'prod-2', name: 'prod-2', status: 'live', tps: '11.8/s' },
{ id: 'prod-1', name: 'prod-1', status: 'live', tps: 14.2 },
{ id: 'prod-2', name: 'prod-2', status: 'live', tps: 11.8 },
],
},
{

View File

@@ -1,5 +1,5 @@
import { useState, useMemo } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useNavigate, useLocation, Link } from 'react-router-dom'
import styles from './Sidebar.module.css'
import camelLogoUrl from '../../../assets/camel-logo.svg'
import { SidebarTree, type SidebarTreeNode } from './SidebarTree'
@@ -27,7 +27,7 @@ export interface SidebarAgent {
id: string
name: string
status: 'live' | 'stale' | 'dead'
tps: string
tps: number
}
interface SidebarProps {
@@ -80,8 +80,8 @@ function buildAgentTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
id: `agent:${app.id}:${agent.id}`,
starKey: `${app.id}:${agent.id}`,
label: agent.name,
badge: agent.tps,
path: `/agents/${agent.id}`,
badge: `${agent.tps.toFixed(1)}/s`,
path: `/agents/${app.id}/${agent.id}`,
starrable: true,
})),
}
@@ -271,16 +271,19 @@ export function Sidebar({ apps, className }: SidebarProps) {
)}
</div>
{/* Agents tree (collapsible) */}
{/* Agents tree (collapsible + navigable) */}
<div className={styles.treeSection}>
<button
className={styles.treeSectionToggle}
onClick={() => setAgentsCollapsed((v) => !v)}
aria-expanded={!agentsCollapsed}
>
<span className={styles.treeSectionChevron}>{agentsCollapsed ? '▸' : '▾'}</span>
<span>Agents</span>
</button>
<div className={styles.treeSectionToggle}>
<Link to="/agents" className={styles.treeSectionLink}>Agents</Link>
<button
className={styles.treeSectionChevronBtn}
onClick={() => setAgentsCollapsed((v) => !v)}
aria-expanded={!agentsCollapsed}
aria-label={agentsCollapsed ? 'Expand Agents' : 'Collapse Agents'}
>
{agentsCollapsed ? '▸' : '▾'}
</button>
</div>
{!agentsCollapsed && (
<SidebarTree
nodes={agentNodes}

55
src/mocks/agentEvents.ts Normal file
View File

@@ -0,0 +1,55 @@
import type { FeedEvent } from '../design-system/composites/EventFeed/EventFeed'
const MINUTE = 60_000
const HOUR = 3_600_000
export const agentEvents: FeedEvent[] = [
{
id: 'evt-1',
severity: 'error',
message: '[notification-hub] notif-1 status changed to DEAD — no heartbeat for 47m',
timestamp: new Date(Date.now() - 47 * MINUTE),
},
{
id: 'evt-2',
severity: 'warning',
message: '[payment-svc] pay-2 status changed to STALE — missed 3 consecutive heartbeats',
timestamp: new Date(Date.now() - 3 * MINUTE),
},
{
id: 'evt-3',
severity: 'success',
message: '[order-service] ord-3 started — instance joined cluster (v3.2.1)',
timestamp: new Date(Date.now() - 2 * HOUR - 15 * MINUTE),
},
{
id: 'evt-4',
severity: 'warning',
message: '[payment-svc] pay-2 error rate elevated: 12 err/h (threshold: 10 err/h)',
timestamp: new Date(Date.now() - 5 * MINUTE),
},
{
id: 'evt-5',
severity: 'running',
message: '[order-service] Route "order-validation" added to ord-1, ord-2, ord-3',
timestamp: new Date(Date.now() - 1 * HOUR - 30 * MINUTE),
},
{
id: 'evt-6',
severity: 'running',
message: '[shipment-svc] Configuration updated — retry policy changed to 3 attempts with exponential backoff',
timestamp: new Date(Date.now() - 4 * HOUR),
},
{
id: 'evt-7',
severity: 'success',
message: '[shipment-svc] ship-1 and ship-2 upgraded to v3.2.0 — rolling restart complete',
timestamp: new Date(Date.now() - 7 * 24 * HOUR),
},
{
id: 'evt-8',
severity: 'error',
message: '[notification-hub] notif-1 failed health check — memory allocation error',
timestamp: new Date(Date.now() - 48 * MINUTE),
},
]

View File

@@ -1,9 +1,10 @@
export interface AgentHealth {
id: string
name: string
appId: string
service: string
version: string
tps: string
tps: number
lastSeen: string
status: 'live' | 'stale' | 'dead'
errorRate?: string
@@ -15,61 +16,48 @@ export interface AgentHealth {
}
export const agents: AgentHealth[] = [
// order-service: 3 instances, all live
{
id: 'prod-1',
name: 'prod-1',
service: 'order-service',
version: 'v3.2.1',
tps: '14.2/s',
lastSeen: '12s ago',
status: 'live',
uptime: '14d 6h',
memoryUsagePct: 62,
cpuUsagePct: 24,
activeRoutes: 3,
totalRoutes: 3,
id: 'ord-1', name: 'ord-1', appId: 'order-service', service: 'order-service', version: 'v3.2.1',
tps: 14.2, lastSeen: '12s ago', status: 'live', uptime: '14d 6h',
memoryUsagePct: 62, cpuUsagePct: 24, activeRoutes: 3, totalRoutes: 3,
},
{
id: 'prod-2',
name: 'prod-2',
service: 'payment-svc',
version: 'v3.2.1',
tps: '11.8/s',
lastSeen: '8s ago',
status: 'live',
errorRate: '3 err/h',
uptime: '14d 6h',
memoryUsagePct: 71,
cpuUsagePct: 31,
activeRoutes: 2,
totalRoutes: 2,
id: 'ord-2', name: 'ord-2', appId: 'order-service', service: 'order-service', version: 'v3.2.1',
tps: 11.8, lastSeen: '8s ago', status: 'live', errorRate: '3 err/h', uptime: '14d 6h',
memoryUsagePct: 71, cpuUsagePct: 31, activeRoutes: 3, totalRoutes: 3,
},
{
id: 'prod-3',
name: 'prod-3',
service: 'shipment-svc',
version: 'v3.2.0',
tps: '12.1/s',
lastSeen: '5s ago',
status: 'live',
uptime: '7d 14h',
memoryUsagePct: 55,
cpuUsagePct: 19,
activeRoutes: 2,
totalRoutes: 2,
id: 'ord-3', name: 'ord-3', appId: 'order-service', service: 'order-service', version: 'v3.2.1',
tps: 8.4, lastSeen: '4s ago', status: 'live', uptime: '2h 15m',
memoryUsagePct: 38, cpuUsagePct: 12, activeRoutes: 3, totalRoutes: 3,
},
// payment-svc: 2 instances, one stale
{
id: 'pay-1', name: 'pay-1', appId: 'payment-svc', service: 'payment-svc', version: 'v3.2.1',
tps: 9.7, lastSeen: '6s ago', status: 'live', uptime: '14d 6h',
memoryUsagePct: 58, cpuUsagePct: 22, activeRoutes: 2, totalRoutes: 2,
},
{
id: 'prod-4',
name: 'prod-4',
service: 'shipment-svc',
version: 'v3.2.0',
tps: '9.1/s',
lastSeen: '3s ago',
status: 'live',
uptime: '7d 14h',
memoryUsagePct: 48,
cpuUsagePct: 15,
activeRoutes: 2,
totalRoutes: 2,
id: 'pay-2', name: 'pay-2', appId: 'payment-svc', service: 'payment-svc', version: 'v3.2.1',
tps: 0.3, lastSeen: '3m ago', status: 'stale', errorRate: '12 err/h', uptime: '14d 6h',
memoryUsagePct: 82, cpuUsagePct: 67, activeRoutes: 1, totalRoutes: 2,
},
// shipment-svc: 2 instances, both live
{
id: 'ship-1', name: 'ship-1', appId: 'shipment-svc', service: 'shipment-svc', version: 'v3.2.0',
tps: 12.1, lastSeen: '5s ago', status: 'live', uptime: '7d 14h',
memoryUsagePct: 55, cpuUsagePct: 19, activeRoutes: 2, totalRoutes: 2,
},
{
id: 'ship-2', name: 'ship-2', appId: 'shipment-svc', service: 'shipment-svc', version: 'v3.2.0',
tps: 9.1, lastSeen: '3s ago', status: 'live', uptime: '7d 14h',
memoryUsagePct: 48, cpuUsagePct: 15, activeRoutes: 2, totalRoutes: 2,
},
// notification-hub: 1 instance, DEAD
{
id: 'notif-1', name: 'notif-1', appId: 'notification-hub', service: 'notification-hub', version: 'v3.1.9',
tps: 0, lastSeen: '47m ago', status: 'dead', errorRate: '0 err/h', uptime: '0',
memoryUsagePct: 0, cpuUsagePct: 0, activeRoutes: 0, totalRoutes: 1,
},
]

View File

@@ -8,7 +8,7 @@ export interface SidebarAgent {
id: string
name: string
status: 'live' | 'stale' | 'dead'
tps: string
tps: number
}
export interface SidebarApp {
@@ -31,8 +31,9 @@ export const SIDEBAR_APPS: SidebarApp[] = [
{ id: 'order-enrichment', name: 'order-enrichment', exchangeCount: 541 },
],
agents: [
{ id: 'prod-1', name: 'prod-1', status: 'live', tps: '14.2/s' },
{ id: 'prod-2', name: 'prod-2', status: 'live', tps: '11.8/s' },
{ id: 'ord-1', name: 'ord-1', status: 'live', tps: 14.2 },
{ id: 'ord-2', name: 'ord-2', status: 'live', tps: 11.8 },
{ id: 'ord-3', name: 'ord-3', status: 'live', tps: 8.4 },
],
},
{
@@ -45,7 +46,8 @@ export const SIDEBAR_APPS: SidebarApp[] = [
{ id: 'payment-validate', name: 'payment-validate', exchangeCount: 498 },
],
agents: [
{ id: 'prod-2', name: 'prod-2', status: 'live', tps: '11.8/s' },
{ id: 'pay-1', name: 'pay-1', status: 'live', tps: 9.7 },
{ id: 'pay-2', name: 'pay-2', status: 'stale', tps: 0.3 },
],
},
{
@@ -58,8 +60,8 @@ export const SIDEBAR_APPS: SidebarApp[] = [
{ id: 'shipment-track', name: 'shipment-track', exchangeCount: 923 },
],
agents: [
{ id: 'prod-3', name: 'prod-3', status: 'live', tps: '12.1/s' },
{ id: 'prod-4', name: 'prod-4', status: 'live', tps: '9.1/s' },
{ id: 'ship-1', name: 'ship-1', status: 'live', tps: 12.1 },
{ id: 'ship-2', name: 'ship-2', status: 'live', tps: 9.1 },
],
},
{
@@ -70,6 +72,8 @@ export const SIDEBAR_APPS: SidebarApp[] = [
routes: [
{ id: 'notification-dispatch', name: 'notification-dispatch', exchangeCount: 471 },
],
agents: [],
agents: [
{ id: 'notif-1', name: 'notif-1', status: 'dead', tps: 0 },
],
},
]

View File

@@ -1,28 +0,0 @@
import { useParams } from 'react-router-dom'
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
import { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState'
import { SIDEBAR_APPS } from '../../mocks/sidebar'
export function AgentDetail() {
const { id } = useParams<{ id: string }>()
return (
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
<TopBar
breadcrumb={[
{ label: 'Agents', href: '/agents' },
{ label: id ?? '' },
]}
environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }}
/>
<EmptyState
title="Agent Detail"
description="Agent detail view coming soon."
/>
</AppShell>
)
}

View File

@@ -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;
}

View File

@@ -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<string, { throughput: number; errorRate: number }> = {
'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<string | null>(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<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: '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 (
<AppShell
sidebar={
<Sidebar apps={SIDEBAR_APPS} />
}
>
{/* Top bar */}
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
<TopBar
breadcrumb={[
{ label: 'Dashboard', href: '/' },
{ label: 'Agents' },
]}
breadcrumb={buildBreadcrumb(scope)}
environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }}
/>
{/* Scrollable content */}
<div className={styles.content}>
{/* System overview strip */}
<div className={styles.overviewStrip}>
<div className={styles.overviewCard}>
<div className={styles.overviewLabel}>Total Agents</div>
<div className={styles.overviewValue}>{agents.length}</div>
</div>
<div className={styles.overviewCard}>
<div className={styles.overviewLabel}>Live</div>
<div className={`${styles.overviewValue} ${styles.valueLive}`}>{liveCount}</div>
</div>
<div className={styles.overviewCard}>
<div className={styles.overviewLabel}>Stale</div>
<div className={`${styles.overviewValue} ${agents.some(a => a.status === 'stale') ? styles.valueStale : ''}`}>
{agents.filter((a) => a.status === 'stale').length}
</div>
</div>
<div className={styles.overviewCard}>
<div className={styles.overviewLabel}>Dead</div>
<div className={`${styles.overviewValue} ${agents.some(a => a.status === 'dead') ? styles.valueDead : ''}`}>
{agents.filter((a) => a.status === 'dead').length}
</div>
</div>
<div className={styles.overviewCard}>
<div className={styles.overviewLabel}>Total TPS</div>
<div className={styles.overviewValue}>{totalTps.toFixed(1)}/s</div>
</div>
<div className={styles.overviewCard}>
<div className={styles.overviewLabel}>Active Routes</div>
<div className={styles.overviewValue}>{totalActiveRoutes}</div>
</div>
{/* 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}>&#9656;</span>
<Link to={`/agents/${scope.appId}`} className={styles.scopeLink}>{scope.appId}</Link>
</>
)}
<span className={styles.scopeSep}>&#9656;</span>
<span className={styles.scopeCurrent}>
{scope.level === 'app' ? scope.appId : scope.instanceId}
</span>
</div>
)}
{/* Section header */}
<div className={styles.sectionHeaderRow}>
<span className={styles.sectionTitle}>Agent Details</span>
<span className={styles.sectionMeta}>{liveCount}/{agents.length} live · Click to expand charts</span>
<span className={styles.sectionTitle}>
{scope.level === 'all' ? 'Agent Groups' : scope.level === 'app' ? scope.appId : scope.instanceId}
</span>
<span className={styles.sectionMeta}>
{liveCount}/{totalInstances} live
</span>
</div>
{/* Agent cards grid */}
<div className={styles.agentGrid}>
{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 */}
<div className={isFullWidth ? styles.groupGridSingle : styles.groupGrid}>
{groups.map((group) => (
<GroupCard
key={group.appId}
title={group.appId}
accent={appHealth(group)}
headerRight={
<span className={styles.instanceCountBadge}>
{group.instances.length} instance{group.instances.length !== 1 ? 's' : ''}
</span>
}
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}
>
{/* Instance header row */}
<div className={styles.instanceHeader}>
<span />
<span>Instance</span>
<span>State</span>
<span>Uptime</span>
<span>TPS</span>
<span>Errors</span>
<span>Heartbeat</span>
</div>
return (
<Card
key={agent.id}
accent={cardAccent as 'success' | 'warning' | 'error'}
className={styles.agentCard}
>
{/* Agent card header */}
<div
className={styles.agentCardHeader}
onClick={() => toggleAgent(agent.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') toggleAgent(agent.id)
}}
aria-expanded={isExpanded}
>
<div className={styles.agentCardLeft}>
<StatusDot variant={statusVariant} />
<div>
<div className={styles.agentCardName}>{agent.name}</div>
<div className={styles.agentCardService}>{agent.service} {agent.version}</div>
</div>
</div>
<div className={styles.agentCardRight}>
{/* Instance rows */}
{group.instances.map((inst) => (
<div key={inst.id}>
<Link
to={`/agents/${inst.appId}/${inst.id}`}
className={[
styles.instanceRow,
scope.level === 'instance' && scope.instanceId === inst.id ? styles.instanceRowActive : '',
].filter(Boolean).join(' ')}
>
<StatusDot variant={inst.status === 'live' ? 'live' : inst.status === 'stale' ? 'stale' : 'dead'} />
<MonoText size="sm" className={styles.instanceName}>{inst.name}</MonoText>
<Badge
label={agent.status.toUpperCase()}
color={agent.status === 'live' ? 'success' : agent.status === 'stale' ? 'warning' : 'error'}
label={inst.status.toUpperCase()}
color={inst.status === 'live' ? 'success' : inst.status === 'stale' ? 'warning' : 'error'}
variant="filled"
/>
<span className={styles.expandIcon}>{isExpanded ? '▲' : '▼'}</span>
</div>
</div>
<MonoText size="xs" className={styles.instanceMeta}>{inst.uptime}</MonoText>
<MonoText size="xs" className={styles.instanceMeta}>{inst.tps.toFixed(1)}/s</MonoText>
<MonoText size="xs" className={inst.errorRate ? styles.instanceError : styles.instanceMeta}>
{inst.errorRate ?? '0 err/h'}
</MonoText>
<MonoText size="xs" className={
inst.status === 'dead' ? styles.instanceHeartbeatDead :
inst.status === 'stale' ? styles.instanceHeartbeatStale :
styles.instanceMeta
}>
{inst.lastSeen}
</MonoText>
</Link>
{/* Agent metrics row */}
<div className={styles.agentMetrics}>
<div className={styles.agentMetric}>
<span className={styles.metricLabel}>TPS</span>
<MonoText size="sm" className={styles.metricValue}>{agent.tps}</MonoText>
</div>
<div className={styles.agentMetric}>
<span className={styles.metricLabel}>Uptime</span>
<MonoText size="sm" className={styles.metricValue}>{agent.uptime}</MonoText>
</div>
<div className={styles.agentMetric}>
<span className={styles.metricLabel}>Last Seen</span>
<MonoText size="sm" className={styles.metricValue}>{agent.lastSeen}</MonoText>
</div>
{agent.errorRate && (
<div className={styles.agentMetric}>
<span className={styles.metricLabel}>Error Rate</span>
<MonoText size="sm" className={styles.metricValueError}>{agent.errorRate}</MonoText>
{/* Expanded charts for single instance */}
{singleInstance?.id === inst.id && trendData && (
<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>
)}
<div className={styles.agentMetric}>
<span className={styles.metricLabel}>CPU</span>
<MonoText size="sm" className={agent.cpuUsagePct > 70 ? styles.metricValueWarn : styles.metricValue}>
{agent.cpuUsagePct}%
</MonoText>
</div>
<div className={styles.agentMetric}>
<span className={styles.metricLabel}>Memory</span>
<MonoText size="sm" className={agent.memoryUsagePct > 80 ? styles.metricValueError : agent.memoryUsagePct > 70 ? styles.metricValueWarn : styles.metricValue}>
{agent.memoryUsagePct}%
</MonoText>
</div>
<div className={styles.agentMetric}>
<span className={styles.metricLabel}>Routes</span>
<MonoText size="sm" className={styles.metricValue}>
{agent.activeRoutes}/{agent.totalRoutes}
</MonoText>
</div>
</div>
{/* Expanded detail: trend charts */}
{isExpanded && trendData && (
<div className={styles.agentCharts}>
<div className={styles.agentChart}>
<div className={styles.chartTitle}>Throughput (msg/s)</div>
<LineChart
series={[{ label: 'tps', data: trendData.throughputData }]}
height={140}
width={380}
yLabel="msg/s"
/>
</div>
<div className={styles.agentChart}>
<div className={styles.chartTitle}>Error Rate (err/h)</div>
<LineChart
series={[{ label: 'errors', data: trendData.errorRateData, color: 'var(--error)' }]}
height={140}
width={380}
yLabel="err/h"
/>
</div>
</div>
)}
</Card>
)
})}
))}
</GroupCard>
))}
</div>
{/* EventFeed */}
{filteredEvents.length > 0 && (
<div className={styles.eventSection}>
<div className={styles.sectionHeaderRow}>
<span className={styles.sectionTitle}>Timeline</span>
</div>
<EventFeed events={filteredEvents} />
</div>
)}
</div>
</AppShell>
)

View File

@@ -13,6 +13,7 @@ import {
Dropdown,
EventFeed,
FilterBar,
GroupCard,
LineChart,
MenuItem,
Modal,
@@ -430,6 +431,25 @@ export function CompositesSection() {
</div>
</DemoCard>
{/* 11b. GroupCard */}
<DemoCard
id="groupcard"
title="GroupCard"
description="Generic card with header, meta row, children, and optional footer. Used for grouping instances by application."
>
<div style={{ maxWidth: 500 }}>
<GroupCard
title="order-service"
accent="success"
headerRight={<span style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text-muted)', background: 'var(--bg-inset)', padding: '2px 8px', borderRadius: 10 }}>3 instances</span>}
meta={<div style={{ display: 'flex', gap: 16, fontSize: 11, color: 'var(--text-muted)' }}><span><strong>34.4</strong> msg/s</span><span><strong>9</strong>/9 routes</span></div>}
footer={undefined}
>
<div style={{ padding: '8px 16px', fontSize: 12, color: 'var(--text-secondary)' }}>Instance rows go here</div>
</GroupCard>
</div>
</DemoCard>
{/* 12. FilterBar */}
<DemoCard
id="filterbar"

View File

@@ -35,8 +35,8 @@ const SAMPLE_APPS: SidebarApp[] = [
{ id: 'r2', name: 'payment-validate', exchangeCount: 3102 },
],
agents: [
{ id: 'ag1', name: 'agent-prod-1', status: 'live' as const, tps: '42 tps' },
{ id: 'ag2', name: 'agent-prod-2', status: 'live' as const, tps: '38 tps' },
{ id: 'ag1', name: 'agent-prod-1', status: 'live' as const, tps: 42 },
{ id: 'ag2', name: 'agent-prod-2', status: 'live' as const, tps: 38 },
],
},
{
@@ -48,7 +48,7 @@ const SAMPLE_APPS: SidebarApp[] = [
{ id: 'r3', name: 'notify-customer', exchangeCount: 2201 },
],
agents: [
{ id: 'ag3', name: 'agent-staging-1', status: 'stale' as const, tps: '5 tps' },
{ id: 'ag3', name: 'agent-staging-1', status: 'stale' as const, tps: 5 },
],
},
{