# 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 `` 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 = { 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(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 (
{entries.map((entry, i) => (
{formatTime(entry.timestamp)} {entry.level.toUpperCase()} {entry.message}
))} {entries.length === 0 && (
No log entries.
)}
) } ``` ### 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({ui}) 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() 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() 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() const el = container.querySelector('[role="log"]') expect(el).toHaveStyle({ maxHeight: '200px' }) }) it('renders with string maxHeight', () => { const { container } = wrap() const el = container.querySelector('[role="log"]') expect(el).toHaveStyle({ maxHeight: '50vh' }) }) it('handles empty entries', () => { wrap() expect(screen.getByText('No log entries.')).toBeInTheDocument() }) it('accepts className prop', () => { const { container } = wrap() const el = container.querySelector('[role="log"]') expect(el?.className).toContain('custom-class') }) it('has role="log" for accessibility', () => { wrap() 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 `` 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 `
` 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 `
` with `` - **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 `
` block inside each `` with `` - [ ] **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 `` 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 `
` 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[] = [ { key: 'status', header: '', width: '12px', render: (_value, row) => ( ), }, { key: 'name', header: 'Instance', render: (_value, row) => ( {row.name} ), }, { key: 'state', header: 'State', render: (_value, row) => ( ), }, { key: 'uptime', header: 'Uptime', render: (_value, row) => ( {row.uptime} ), }, { key: 'tps', header: 'TPS', render: (_value, row) => ( {row.tps.toFixed(1)}/s ), }, { key: 'errorRate', header: 'Errors', render: (_value, row) => ( {row.errorRate ?? '0 err/h'} ), }, { key: 'lastSeen', header: 'Heartbeat', render: (_value, row) => ( {row.lastSeen} ), }, ] ``` ### 3.3 — Replace `` with `` Replace the entire `
...
` block (lines 365-423 of `AgentHealth.tsx`) inside each `` with: ```tsx ``` 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 `
`): ``` .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} ```