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:
@@ -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 |
|
||||
|
||||
237
docs/superpowers/specs/2026-03-18-agent-health-page.md
Normal file
237
docs/superpowers/specs/2026-03-18-agent-health-page.md
Normal 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
|
||||
@@ -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 />} />
|
||||
|
||||
79
src/design-system/composites/GroupCard/GroupCard.module.css
Normal file
79
src/design-system/composites/GroupCard/GroupCard.module.css
Normal 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);
|
||||
}
|
||||
58
src/design-system/composites/GroupCard/GroupCard.test.tsx
Normal file
58
src/design-system/composites/GroupCard/GroupCard.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
50
src/design-system/composites/GroupCard/GroupCard.tsx
Normal file
50
src/design-system/composites/GroupCard/GroupCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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}>
|
||||
<div className={styles.treeSectionToggle}>
|
||||
<Link to="/agents" className={styles.treeSectionLink}>Agents</Link>
|
||||
<button
|
||||
className={styles.treeSectionToggle}
|
||||
className={styles.treeSectionChevronBtn}
|
||||
onClick={() => setAgentsCollapsed((v) => !v)}
|
||||
aria-expanded={!agentsCollapsed}
|
||||
aria-label={agentsCollapsed ? 'Expand Agents' : 'Collapse Agents'}
|
||||
>
|
||||
<span className={styles.treeSectionChevron}>{agentsCollapsed ? '▸' : '▾'}</span>
|
||||
<span>Agents</span>
|
||||
{agentsCollapsed ? '▸' : '▾'}
|
||||
</button>
|
||||
</div>
|
||||
{!agentsCollapsed && (
|
||||
<SidebarTree
|
||||
nodes={agentNodes}
|
||||
|
||||
55
src/mocks/agentEvents.ts
Normal file
55
src/mocks/agentEvents.ts
Normal 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),
|
||||
},
|
||||
]
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
// ── 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' }
|
||||
}
|
||||
const base = baseValues[agentId] ?? { throughput: 10, errorRate: 1 }
|
||||
|
||||
const now = new Date('2026-03-18T09:15:00')
|
||||
// ── 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 intervalMs = (3 * 60 * 60 * 1000) / points // 3 hours
|
||||
const interval = (3 * 60 * 60 * 1000) / points
|
||||
|
||||
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 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 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),
|
||||
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 { throughputData, errorRateData }
|
||||
return { throughput, errorRate }
|
||||
}
|
||||
|
||||
// ─── 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)
|
||||
// ── 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 ─────────────────────────────────────────────────────────
|
||||
|
||||
// ─── AgentHealth page ─────────────────────────────────────────────────────────
|
||||
export function AgentHealth() {
|
||||
const [expandedAgent, setExpandedAgent] = useState<string | null>(null)
|
||||
const scope = useScope()
|
||||
|
||||
function toggleAgent(id: string) {
|
||||
setExpandedAgent((prev) => (prev === id ? null : id))
|
||||
// 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}>
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
{/* 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}>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}>⚠</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}
|
||||
{/* 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(' ')}
|
||||
>
|
||||
{/* 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}>
|
||||
<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>
|
||||
</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}>
|
||||
{/* 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.throughputData }]}
|
||||
height={140}
|
||||
width={380}
|
||||
series={[{ label: 'tps', data: trendData.throughput }]}
|
||||
height={160}
|
||||
width={480}
|
||||
yLabel="msg/s"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.agentChart}>
|
||||
<div className={styles.chartPanel}>
|
||||
<div className={styles.chartTitle}>Error Rate (err/h)</div>
|
||||
<LineChart
|
||||
series={[{ label: 'errors', data: trendData.errorRateData, color: 'var(--error)' }]}
|
||||
height={140}
|
||||
width={380}
|
||||
series={[{ label: 'errors', data: trendData.errorRate, color: 'var(--error)' }]}
|
||||
height={160}
|
||||
width={480}
|
||||
yLabel="err/h"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user