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>
15 KiB
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.tsxwith the component and exported types - 1.2 Create
src/design-system/composites/LogViewer/LogViewer.module.csswith all styles - 1.3 Create
src/design-system/composites/LogViewer/LogViewer.test.tsxwith tests - 1.4 Run
npx vitest run src/design-system/composites/LogViewerand fix any failures
API
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
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
/* 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
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
useRefto track whether the user is at the bottom of the scroll container. On new entries (viauseEffectonentries), scrolls to bottom only ifisAtBottomRef.currentistrue. 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 usecolor-mixwith 12% opacity tint. - No Badge dependency: The level badge is a styled
<span>rather than using theBadgeprimitive. This avoids pulling inhashColor/useThemeand 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):
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'):
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
DataTableandColumnimports toAgentHealth.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:
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.
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:
<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 GroupCardselectedId— highlights the currently selected row (replaces the manualinstanceRowActiveCSS class)onRowClick— replaces the manualonClickon<tr>elementspageSize={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
- Task 1 — LogViewer composite (no dependencies)
- Task 2 — Barrel exports (depends on Task 1)
- 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
# 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}