Files
design-system/docs/superpowers/plans/2026-03-24-observability-components.md
hsiegeln e664e449c3 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>
2026-03-24 12:10:00 +01:00

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.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

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 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):

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 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:

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 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

# 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}