docs: add 4 implementation plans for mock deviation cleanup

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>
This commit is contained in:
hsiegeln
2026-03-24 12:10:00 +01:00
parent b168d7c867
commit e664e449c3
4 changed files with 2213 additions and 0 deletions

View File

@@ -0,0 +1,506 @@
# 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}
```