Plan 1: KpiStrip + StatusText + Card title (metrics) Plan 2: SplitPane + EntityList (admin) Plan 3: LogViewer + AgentHealth DataTable refactor (observability) Plan 4: COMPONENT_GUIDE.md + Inventory updates (documentation) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
507 lines
15 KiB
Markdown
507 lines
15 KiB
Markdown
# Observability Components Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Add LogViewer composite for log display and refactor AgentHealth to use DataTable instead of raw HTML tables.
|
|
|
|
**Architecture:** LogViewer is a scrollable log display with timestamped, severity-colored entries and auto-scroll behavior. The AgentHealth refactor replaces raw `<table>` elements with the existing DataTable composite.
|
|
|
|
**Tech Stack:** React, TypeScript, CSS Modules, Vitest, React Testing Library
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-03-24-mock-deviations-design.md` (Sections 3, 4)
|
|
|
|
---
|
|
|
|
## Task 1: LogViewer composite
|
|
|
|
Create a new composite component that renders a scrollable log viewer with timestamped, severity-colored entries. This replaces the custom log rendering in `AgentInstance.tsx`.
|
|
|
|
### Files
|
|
|
|
- **Create** `src/design-system/composites/LogViewer/LogViewer.tsx`
|
|
- **Create** `src/design-system/composites/LogViewer/LogViewer.module.css`
|
|
- **Create** `src/design-system/composites/LogViewer/LogViewer.test.tsx`
|
|
|
|
### Steps
|
|
|
|
- [ ] **1.1** Create `src/design-system/composites/LogViewer/LogViewer.tsx` with the component and exported types
|
|
- [ ] **1.2** Create `src/design-system/composites/LogViewer/LogViewer.module.css` with all styles
|
|
- [ ] **1.3** Create `src/design-system/composites/LogViewer/LogViewer.test.tsx` with tests
|
|
- [ ] **1.4** Run `npx vitest run src/design-system/composites/LogViewer` and fix any failures
|
|
|
|
### API
|
|
|
|
```tsx
|
|
export interface LogEntry {
|
|
timestamp: string
|
|
level: 'info' | 'warn' | 'error' | 'debug'
|
|
message: string
|
|
}
|
|
|
|
export interface LogViewerProps {
|
|
entries: LogEntry[]
|
|
maxHeight?: number | string // Default: 400
|
|
className?: string
|
|
}
|
|
```
|
|
|
|
### Component implementation — `LogViewer.tsx`
|
|
|
|
```tsx
|
|
import { useRef, useEffect, useCallback } from 'react'
|
|
import styles from './LogViewer.module.css'
|
|
|
|
export interface LogEntry {
|
|
timestamp: string
|
|
level: 'info' | 'warn' | 'error' | 'debug'
|
|
message: string
|
|
}
|
|
|
|
export interface LogViewerProps {
|
|
entries: LogEntry[]
|
|
maxHeight?: number | string
|
|
className?: string
|
|
}
|
|
|
|
const LEVEL_CLASS: Record<LogEntry['level'], string> = {
|
|
info: styles.levelInfo,
|
|
warn: styles.levelWarn,
|
|
error: styles.levelError,
|
|
debug: styles.levelDebug,
|
|
}
|
|
|
|
function formatTime(iso: string): string {
|
|
try {
|
|
return new Date(iso).toLocaleTimeString('en-GB', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false,
|
|
})
|
|
} catch {
|
|
return iso
|
|
}
|
|
}
|
|
|
|
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
|
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
const isAtBottomRef = useRef(true)
|
|
|
|
const handleScroll = useCallback(() => {
|
|
const el = scrollRef.current
|
|
if (!el) return
|
|
// Consider "at bottom" when within 20px of the end
|
|
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 20
|
|
}, [])
|
|
|
|
// Auto-scroll to bottom when entries change, but only if user hasn't scrolled up
|
|
useEffect(() => {
|
|
const el = scrollRef.current
|
|
if (el && isAtBottomRef.current) {
|
|
el.scrollTop = el.scrollHeight
|
|
}
|
|
}, [entries])
|
|
|
|
const heightStyle = typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight
|
|
|
|
return (
|
|
<div
|
|
ref={scrollRef}
|
|
className={[styles.container, className].filter(Boolean).join(' ')}
|
|
style={{ maxHeight: heightStyle }}
|
|
onScroll={handleScroll}
|
|
role="log"
|
|
>
|
|
{entries.map((entry, i) => (
|
|
<div key={i} className={styles.line}>
|
|
<span className={styles.timestamp}>{formatTime(entry.timestamp)}</span>
|
|
<span className={[styles.levelBadge, LEVEL_CLASS[entry.level]].join(' ')}>
|
|
{entry.level.toUpperCase()}
|
|
</span>
|
|
<span className={styles.message}>{entry.message}</span>
|
|
</div>
|
|
))}
|
|
{entries.length === 0 && (
|
|
<div className={styles.empty}>No log entries.</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
### Styles — `LogViewer.module.css`
|
|
|
|
```css
|
|
/* Scrollable container */
|
|
.container {
|
|
overflow-y: auto;
|
|
background: var(--bg-inset);
|
|
border-radius: var(--radius-md);
|
|
padding: 8px 0;
|
|
font-family: var(--font-mono);
|
|
}
|
|
|
|
/* Each log line */
|
|
.line {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 8px;
|
|
padding: 3px 12px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.line:hover {
|
|
background: var(--bg-hover);
|
|
}
|
|
|
|
/* Timestamp */
|
|
.timestamp {
|
|
flex-shrink: 0;
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
min-width: 56px;
|
|
}
|
|
|
|
/* Level badge — pill with tinted background */
|
|
.levelBadge {
|
|
flex-shrink: 0;
|
|
font-size: 9px;
|
|
font-weight: 600;
|
|
font-family: var(--font-mono);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.3px;
|
|
padding: 1px 6px;
|
|
border-radius: 9999px;
|
|
line-height: 1.5;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.levelInfo {
|
|
color: var(--running);
|
|
background: color-mix(in srgb, var(--running) 12%, transparent);
|
|
}
|
|
|
|
.levelWarn {
|
|
color: var(--warning);
|
|
background: color-mix(in srgb, var(--warning) 12%, transparent);
|
|
}
|
|
|
|
.levelError {
|
|
color: var(--error);
|
|
background: color-mix(in srgb, var(--error) 12%, transparent);
|
|
}
|
|
|
|
.levelDebug {
|
|
color: var(--text-muted);
|
|
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
|
|
}
|
|
|
|
/* Message text */
|
|
.message {
|
|
font-size: 12px;
|
|
font-family: var(--font-mono);
|
|
color: var(--text-primary);
|
|
word-break: break-word;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
/* Empty state */
|
|
.empty {
|
|
padding: 24px;
|
|
text-align: center;
|
|
color: var(--text-faint);
|
|
font-size: 12px;
|
|
font-family: var(--font-body);
|
|
}
|
|
```
|
|
|
|
### Tests — `LogViewer.test.tsx`
|
|
|
|
```tsx
|
|
import { describe, it, expect } from 'vitest'
|
|
import { render, screen } from '@testing-library/react'
|
|
import { LogViewer, type LogEntry } from './LogViewer'
|
|
import { ThemeProvider } from '../../providers/ThemeProvider'
|
|
|
|
const wrap = (ui: React.ReactElement) => render(<ThemeProvider>{ui}</ThemeProvider>)
|
|
|
|
const sampleEntries: LogEntry[] = [
|
|
{ timestamp: '2026-03-24T10:00:00Z', level: 'info', message: 'Server started' },
|
|
{ timestamp: '2026-03-24T10:01:00Z', level: 'warn', message: 'Slow query detected' },
|
|
{ timestamp: '2026-03-24T10:02:00Z', level: 'error', message: 'Connection refused' },
|
|
{ timestamp: '2026-03-24T10:03:00Z', level: 'debug', message: 'Cache hit ratio: 0.95' },
|
|
]
|
|
|
|
describe('LogViewer', () => {
|
|
it('renders entries with timestamps and messages', () => {
|
|
wrap(<LogViewer entries={sampleEntries} />)
|
|
expect(screen.getByText('Server started')).toBeInTheDocument()
|
|
expect(screen.getByText('Slow query detected')).toBeInTheDocument()
|
|
expect(screen.getByText('Connection refused')).toBeInTheDocument()
|
|
expect(screen.getByText('Cache hit ratio: 0.95')).toBeInTheDocument()
|
|
})
|
|
|
|
it('renders level badges with correct text', () => {
|
|
wrap(<LogViewer entries={sampleEntries} />)
|
|
expect(screen.getByText('INFO')).toBeInTheDocument()
|
|
expect(screen.getByText('WARN')).toBeInTheDocument()
|
|
expect(screen.getByText('ERROR')).toBeInTheDocument()
|
|
expect(screen.getByText('DEBUG')).toBeInTheDocument()
|
|
})
|
|
|
|
it('renders with custom maxHeight', () => {
|
|
const { container } = wrap(<LogViewer entries={sampleEntries} maxHeight={200} />)
|
|
const el = container.querySelector('[role="log"]')
|
|
expect(el).toHaveStyle({ maxHeight: '200px' })
|
|
})
|
|
|
|
it('renders with string maxHeight', () => {
|
|
const { container } = wrap(<LogViewer entries={sampleEntries} maxHeight="50vh" />)
|
|
const el = container.querySelector('[role="log"]')
|
|
expect(el).toHaveStyle({ maxHeight: '50vh' })
|
|
})
|
|
|
|
it('handles empty entries', () => {
|
|
wrap(<LogViewer entries={[]} />)
|
|
expect(screen.getByText('No log entries.')).toBeInTheDocument()
|
|
})
|
|
|
|
it('accepts className prop', () => {
|
|
const { container } = wrap(<LogViewer entries={sampleEntries} className="custom-class" />)
|
|
const el = container.querySelector('[role="log"]')
|
|
expect(el?.className).toContain('custom-class')
|
|
})
|
|
|
|
it('has role="log" for accessibility', () => {
|
|
wrap(<LogViewer entries={sampleEntries} />)
|
|
expect(screen.getByRole('log')).toBeInTheDocument()
|
|
})
|
|
})
|
|
```
|
|
|
|
### Key design decisions
|
|
|
|
- **Auto-scroll behavior:** Uses a `useRef` to track whether the user is at the bottom of the scroll container. On new entries (via `useEffect` on `entries`), scrolls to bottom only if `isAtBottomRef.current` is `true`. Pauses when user scrolls up (more than 20px from bottom). Resumes when user scrolls back to bottom.
|
|
- **Level colors:** Map to existing design tokens: `info` -> `var(--running)`, `warn` -> `var(--warning)`, `error` -> `var(--error)`, `debug` -> `var(--text-muted)`. Pill backgrounds use `color-mix` with 12% opacity tint.
|
|
- **No Badge dependency:** The level badge is a styled `<span>` rather than using the `Badge` primitive. This avoids pulling in `hashColor`/`useTheme` and keeps the badge styling tightly scoped (9px pill vs Badge's larger size). The spec calls for a very compact pill at 9px mono — a custom element is cleaner.
|
|
- **`role="log"`** on the container for accessibility (indicates a log region to screen readers).
|
|
|
|
---
|
|
|
|
## Task 2: Barrel exports for LogViewer
|
|
|
|
Add LogViewer and its types to the composites barrel export.
|
|
|
|
### Files
|
|
|
|
- **Modify** `src/design-system/composites/index.ts`
|
|
|
|
### Steps
|
|
|
|
- [ ] **2.1** Add LogViewer export and type exports to `src/design-system/composites/index.ts`
|
|
|
|
### Changes
|
|
|
|
Add these lines to `src/design-system/composites/index.ts`, in alphabetical position (after the `LineChart` export):
|
|
|
|
```ts
|
|
export { LogViewer } from './LogViewer/LogViewer'
|
|
export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer'
|
|
```
|
|
|
|
The full insertion point — after line 19 (`export { LineChart } from './LineChart/LineChart'`) and before line 20 (`export { LoginDialog } from './LoginForm/LoginDialog'`):
|
|
|
|
```ts
|
|
export { LineChart } from './LineChart/LineChart'
|
|
export { LogViewer } from './LogViewer/LogViewer'
|
|
export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer'
|
|
export { LoginDialog } from './LoginForm/LoginDialog'
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: AgentHealth DataTable refactor
|
|
|
|
Replace the raw HTML `<table>` in `AgentHealth.tsx` with the existing `DataTable` composite. This is a **page-level refactor** — no design system components are changed.
|
|
|
|
### Files
|
|
|
|
- **Modify** `src/pages/AgentHealth/AgentHealth.tsx` — replace `<table>` with `<DataTable>`
|
|
- **Modify** `src/pages/AgentHealth/AgentHealth.module.css` — remove table CSS
|
|
|
|
### Steps
|
|
|
|
- [ ] **3.1** Add `DataTable` and `Column` imports to `AgentHealth.tsx`
|
|
- [ ] **3.2** Define the instance columns array
|
|
- [ ] **3.3** Replace the `<table>` block inside each `<GroupCard>` with `<DataTable>`
|
|
- [ ] **3.4** Remove unused table CSS classes from `AgentHealth.module.css`
|
|
- [ ] **3.5** Visually verify the page looks identical (run dev server, navigate to `/agents`)
|
|
|
|
### 3.1 — Add imports
|
|
|
|
Add to the composites import block in `AgentHealth.tsx`:
|
|
|
|
```tsx
|
|
import { DataTable } from '../../design-system/composites/DataTable/DataTable'
|
|
import type { Column } from '../../design-system/composites/DataTable/types'
|
|
```
|
|
|
|
### 3.2 — Define columns
|
|
|
|
Add a column definition constant above the `AgentHealth` component function. The columns mirror the existing `<th>` headers. Custom `render` functions handle the StatusDot and Badge cells.
|
|
|
|
**Important:** DataTable requires rows with an `id: string` field. The `AgentHealthData` type already has `id`, so no transformation is needed.
|
|
|
|
```tsx
|
|
const instanceColumns: Column<AgentHealthData>[] = [
|
|
{
|
|
key: 'status',
|
|
header: '',
|
|
width: '12px',
|
|
render: (_value, row) => (
|
|
<StatusDot variant={row.status === 'live' ? 'live' : row.status === 'stale' ? 'stale' : 'dead'} />
|
|
),
|
|
},
|
|
{
|
|
key: 'name',
|
|
header: 'Instance',
|
|
render: (_value, row) => (
|
|
<MonoText size="sm" className={styles.instanceName}>{row.name}</MonoText>
|
|
),
|
|
},
|
|
{
|
|
key: 'state',
|
|
header: 'State',
|
|
render: (_value, row) => (
|
|
<Badge
|
|
label={row.status.toUpperCase()}
|
|
color={row.status === 'live' ? 'success' : row.status === 'stale' ? 'warning' : 'error'}
|
|
variant="filled"
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: 'uptime',
|
|
header: 'Uptime',
|
|
render: (_value, row) => (
|
|
<MonoText size="xs" className={styles.instanceMeta}>{row.uptime}</MonoText>
|
|
),
|
|
},
|
|
{
|
|
key: 'tps',
|
|
header: 'TPS',
|
|
render: (_value, row) => (
|
|
<MonoText size="xs" className={styles.instanceMeta}>{row.tps.toFixed(1)}/s</MonoText>
|
|
),
|
|
},
|
|
{
|
|
key: 'errorRate',
|
|
header: 'Errors',
|
|
render: (_value, row) => (
|
|
<MonoText size="xs" className={row.errorRate ? styles.instanceError : styles.instanceMeta}>
|
|
{row.errorRate ?? '0 err/h'}
|
|
</MonoText>
|
|
),
|
|
},
|
|
{
|
|
key: 'lastSeen',
|
|
header: 'Heartbeat',
|
|
render: (_value, row) => (
|
|
<MonoText size="xs" className={
|
|
row.status === 'dead' ? styles.instanceHeartbeatDead :
|
|
row.status === 'stale' ? styles.instanceHeartbeatStale :
|
|
styles.instanceMeta
|
|
}>
|
|
{row.lastSeen}
|
|
</MonoText>
|
|
),
|
|
},
|
|
]
|
|
```
|
|
|
|
### 3.3 — Replace `<table>` with `<DataTable>`
|
|
|
|
Replace the entire `<table className={styles.instanceTable}>...</table>` block (lines 365-423 of `AgentHealth.tsx`) inside each `<GroupCard>` with:
|
|
|
|
```tsx
|
|
<DataTable
|
|
columns={instanceColumns}
|
|
data={group.instances}
|
|
flush
|
|
selectedId={selectedInstance?.id}
|
|
onRowClick={handleInstanceClick}
|
|
pageSize={50}
|
|
/>
|
|
```
|
|
|
|
Key props:
|
|
- `flush` — strips DataTable's outer border/radius/shadow so it sits seamlessly inside the GroupCard
|
|
- `selectedId` — highlights the currently selected row (replaces the manual `instanceRowActive` CSS class)
|
|
- `onRowClick` — replaces the manual `onClick` on `<tr>` elements
|
|
- `pageSize={50}` — high enough to avoid pagination for typical instance counts per app group
|
|
|
|
### 3.4 — Remove unused CSS
|
|
|
|
Remove these CSS classes from `AgentHealth.module.css` (they were only used by the raw `<table>`):
|
|
|
|
```
|
|
.instanceTable
|
|
.instanceTable thead th
|
|
.thStatus
|
|
.tdStatus
|
|
.instanceRow
|
|
.instanceRow td
|
|
.instanceRow:last-child td
|
|
.instanceRow:hover td
|
|
.instanceRowActive td
|
|
.instanceRowActive td:first-child
|
|
```
|
|
|
|
**Keep** these classes (still used by DataTable `render` functions):
|
|
|
|
```
|
|
.instanceName
|
|
.instanceMeta
|
|
.instanceError
|
|
.instanceHeartbeatStale
|
|
.instanceHeartbeatDead
|
|
```
|
|
|
|
### Visual verification checklist
|
|
|
|
After the refactor, verify at `/agents`:
|
|
- [ ] StatusDot column renders colored dots in the first column
|
|
- [ ] Instance name renders in mono bold
|
|
- [ ] State column shows Badge with correct color variant
|
|
- [ ] Uptime, TPS, Errors, Heartbeat columns show muted mono text
|
|
- [ ] Error values show in `var(--error)` red
|
|
- [ ] Stale/dead heartbeat timestamps show warning/error colors
|
|
- [ ] Row click opens the DetailPanel
|
|
- [ ] Selected row is visually highlighted
|
|
- [ ] Table sits flush inside GroupCard (no double borders)
|
|
- [ ] Alert banner still renders below the table for groups with dead instances
|
|
|
|
---
|
|
|
|
## Execution order
|
|
|
|
1. **Task 1** — LogViewer composite (no dependencies)
|
|
2. **Task 2** — Barrel exports (depends on Task 1)
|
|
3. **Task 3** — AgentHealth DataTable refactor (independent of Tasks 1-2)
|
|
|
|
Tasks 1+2 and Task 3 can be parallelized since they touch different parts of the codebase.
|
|
|
|
## Verification
|
|
|
|
```bash
|
|
# Run LogViewer tests
|
|
npx vitest run src/design-system/composites/LogViewer
|
|
|
|
# Run all tests to check nothing broke
|
|
npx vitest run
|
|
|
|
# Start dev server for visual verification
|
|
npm run dev
|
|
# Then navigate to /agents and /agents/{appId}/{instanceId}
|
|
```
|