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