Operator dashboard

AI Ops Dashboard

Where it all comes together

โš™ Tabs

Show or hide any dashboard tab.

How: Toggle tabs on/off and pick the default tab. Hidden tabs stay installed and can be turned back on anytime โ€” nothing is deleted.

Change the dashboard's color theme.

How: Pick any of the 8 themes โ€” your choice is saved in this browser only (it doesn't change the shared default).

Data updated 10/19/2018, 6:46 PM PDT
Rendered 6/20/2026, 12:12 AM PDT ยท auto-refresh 120s
โ† AI Operations Dashboard docsยทhelp/panels.md

Panels

A panel is a self-contained module that produces one tab on the dashboard. Each panel declares the data it reads, exposes a server-side loader, and renders a React component with the result.

Anatomy of a panel

The full contract lives in lib/panels/types.ts. The required and optional fields:

export type PanelDefinition<TData = unknown> = {
  id: PanelId                           // URL slug โ€” ?tab=<id>
  label: string                         // tab strip label
  hint?: string                         // tooltip on hover
  icon?: string                         // single glyph rendered before label
  sources?: PanelSource[]               // declarative file list
  loader: (ctx: PanelLoaderContext) => Promise<TData> | TData
  heroStats?: (data: TData) => HeroStat[]
  Component: ComponentType<{ data: TData }>
  Empty?: ComponentType                 // fallback when data is empty
}

id + label + hint + icon

id is the URL slug, used as ?tab=<id>. label shows in the tab strip. hint is the tooltip. icon is a single glyph or emoji (we use "$" for the Usage panel and "โ—ท" for Scheduled Jobs).

sources

Optional but recommended. Declarative list of what files this panel reads. Used for self-documentation and to wire up the dev file-watcher so changes hot-reload the right panels.

sources: [
  { path: "data/pending.json", kind: "file", purpose: "Source of truth for the pending queue" },
  { path: "knowledge/postmortems/", kind: "dir", purpose: "Folder of incident markdown files" },
  { path: "https://api.example.com/status", kind: "url", purpose: "External health endpoint" },
]

loader

Server-side function called once per request in SSR mode, once per build in static mode. Receives PanelLoaderContext:

type PanelLoaderContext = {
  projectRoot: string                   // absolute path
  now: Date                             // request time
  searchParams: Record<string, string | string[] | undefined>
}

Use the data-source adapters for everything. Loaders that throw are caught by the dashboard and the panel renders an error card โ€” the rest of the page still works.

heroStats

Optional. Returns up to ~2 HeroStat cards that appear in the always-on strip above the active panel. Stats from every panel are collected and the first 5 are rendered.

type HeroStat = {
  label: string                         // uppercase label
  value: string | number                // big number
  sub?: string                          // subtitle
  accent?: string                       // CSS color (defaults to theme accent)
  href?: string                         // optional link
}

Component

The React Server Component that renders with whatever the loader returned. Use the shared CSS variables for theme-aware colors (var(--color-fg), var(--color-accent), etc.) and shared utility classes (data-card, chip, chip-warning, etc.) โ€” all defined in app/globals.css.

Empty

Optional. Renders when loader returns null, undefined, or an empty collection. If omitted, a generic empty state is shown.

Error handling

Panels are isolated. If one panel's loader throws, the dashboard:

  1. Catches the error.
  2. Renders a single error card in that panel's slot.
  3. Renders every other panel normally.

This is enforced in app/page.tsx. You can throw freely in a loader without worrying about taking down the page.

The 12 built-in panels

overview

What it shows: Synthesis of the other panels โ€” top 5 high-priority pending items, last operator-log entry, last 10 commits, AI spend at a glance.

Data it reads: data/pending.json, data/recent-commits.json, docs/operator-log.md, data/usage.json.

Sample shape: No dedicated file. Reuses other panels' data.

plan

What it shows: Live state (last update / current state / next step), roadmap, sub-task ledger, decision log. Hero stat: completed sub-tasks ratio. (The sprint-progress board lives on its own Sprints tab.)

Data it reads: plans/execution-status.md, plans/roadmap.md.

Sample shape:

**Last update:** 2026-05-18
**Current state:** Sprint 3 in progress.
**Next:** Wire up SSE refresh in node mode.

## Sub-task ledger

| ID | Task | Date | Commit | Status |
|---|---|---|---|---|
| S3.1 | SSE handler | 2026-05-17 | a1b2c3d | โœ… |
| S3.2 | Client polyfill | โ€” | (pending) | ๐ŸŸก in progress |

sprints

What it shows: The sprint-progress board โ€” current and recent sprints with their progress (e.g. completed vs. total sub-tasks per sprint). Split out of the Plan panel so the roadmap and the in-flight sprint each get their own surface. Hero stat: current-sprint completion ratio.

Data it reads: plans/execution-status.md.

Sample shape:

## Sprint progress

| Sprint | Goal | Done | Total | Status |
|---|---|---|---|---|
| S3 | SSE refresh in node mode | 4 | 6 | ๐ŸŸก in progress |
| S2 | Multi-repo Activity aggregation | 5 | 5 | โœ… shipped |

activity

What it shows: 90-day commit heat map plus a chronological commit list grouped by day. Hero stats: commits in last 7d / 90d.

Data it reads: data/recent-commits.json (frozen git log; see deployment.md).

Sample shape:

{
  "generatedAt": "2026-05-18T15:00:00Z",
  "commits": [
    {
      "hash": "a1b2c3d4e5f6...",
      "shortHash": "a1b2c3d",
      "date": "2026-05-18",
      "timestamp": "2026-05-18T14:32:00-07:00",
      "author": "Jane Dev",
      "subject": "Ship SSE refresh handler"
    }
  ]
}

pending

What it shows: Open work items sorted by priority. Each item has title, why, doer chip, optional ordered step list, optional ref link. Hero stat: open count + high-priority count.

Data it reads: data/pending.json.

Sample shape:

{
  "items": [
    {
      "title": "Wire up SSE handler",
      "why": "Polling at 60s feels laggy in node mode",
      "priority": "high",
      "doer": "you",
      "dateAdded": "2026-05-18",
      "steps": [
        "Add /api/stream route",
        "Wire EventSource into hero strip",
        "Smoke-test with `data:watch`"
      ]
    }
  ]
}

log

What it shows: Operator log entries โ€” what you shipped, when, why. Parsed from a single markdown file with ## YYYY-MM-DD HH:MM โ€” title headers.

Data it reads: docs/operator-log.md.

Sample shape:

## 2026-05-18 14:32 โ€” Shipped SSE refresh

**Why:** Polling at 60s felt laggy in node mode.
**What shipped:** /api/stream + EventSource wiring + smoke test.
**Tags:** sse, node-mode, refresh
**Next:** Static-mode fallback path.

postmortems

What it shows: Severity-tinted cards for every markdown file in knowledge/postmortems/ plus a cross-cutting prevention summary. Hero stat: total postmortems.

Data it reads: knowledge/postmortems/**/*.md (recursive walk).

Sample shape:

---
title: SSE handler dropped connections under load
date: 2026-05-15
severity: p1
---

## What happened
SSE connections dropped silently after 30s for clients behind Cloudflare.

## Prevention
- Add keepalive ping every 15s
- Add Cloudflare-specific integration test
- Document SSE caveats in deployment.md

usage

What it shows: AI spend rolled up by day, month, quarter, and model. 30-day daily bar chart. Provider-agnostic โ€” works for Anthropic, OpenAI, OpenRouter, anything.

Data it reads: data/usage.json (built by npm run data:aggregate-usage from data/usage-log.jsonl).

Sample shape:

{
  "spendToday": 1.23,
  "spendThisMonth": 47.81,
  "spendThisQuarter": 124.55,
  "monthLabel": "May 2026",
  "quarterLabel": "2026-Q2",
  "daily": [{ "date": "2026-05-18", "spend": 1.23, "tokens": 42000 }],
  "byModel": [{ "model": "claude-opus-4-7", "spend": 30.12, "tokens": 1200000 }]
}

scheduled

The tab label is now Scheduled Jobs (the panel id stays scheduled).

What it shows: Recurring jobs catalogue โ€” name, cadence, purpose, last run, status. Useful for "what cron jobs do I have and are they running?"

Data it reads: data/scheduled-jobs.json.

Sample shape:

{
  "jobs": [
    {
      "id": "freeze-commits",
      "name": "Freeze recent commits",
      "cadence": "pre-push hook",
      "purpose": "Regenerate data/recent-commits.json before push",
      "kind": "git-hook",
      "lastRun": "2026-05-18T14:32:00Z",
      "lastStatus": "ok"
    }
  ]
}

repos

What it shows: Connected-repo roll-up โ€” each repo's pending count, last operator-log entry, recent commits, and AI spend aggregated across every repo wired in via the ai-ops-dashboard CLI. Useful for "what's happening across all my projects?"

Data it reads: data/connected-repos.json plus each connected repo's data/ and docs/ files.

Sample shape:

{
  "repos": [
    {
      "slug": "woodwiki",
      "path": "../woodwiki",
      "label": "Woodwiki",
      "pendingCount": 3,
      "lastCommit": "2026-05-18",
      "spendThisMonth": 12.40
    }
  ]
}

docs

What it shows: In-app viewer for your own operator docs โ€” renders any markdown file from your docs/ folder (with operator-log.md excluded, since the Log panel owns that) as styled HTML, with a sidebar index. Read your team's runbooks and notes without leaving the dashboard. (The dashboard's *own* product guides โ€” getting-started, configuration, panels, etc. โ€” live separately and render at /help.) In this product repo the operator docs/ is empty by default.

Data it reads: docs/**/*.md (recursive walk, operator-log.md excluded).

Sample shape: No dedicated file. Renders the operator markdown docs in docs/.

skills

What it shows: Read-only inventory of your Claude Code Skills, agents, MCP servers, and plugins โ€” scanned from the filesystem (~/.claude/skills, ~/.claude/agents, plugins, MCPs). Claude Code only. Hero stat: total skills.

Data it reads: ~/.claude/skills/, ~/.claude/agents/, plugin + MCP config (filesystem scan at render time).

Sample shape: No dedicated file. Derived live from the filesystem scan.

Adding a custom panel

Scaffold:

npm run new:panel -- metrics

This writes components/panels/metrics-panel.tsx with a starter template:

import path from "node:path"
import type { PanelDefinition } from "@/lib/panels/types"
import { readJson } from "@/lib/data-sources"

type MetricsData = {
  items: { label: string; value: string }[]
}

export const panel: PanelDefinition<MetricsData> = {
  id: "metrics",
  label: "Metrics",
  hint: "Metrics โ€” describe purpose here.",
  sources: [
    // { path: "data/metrics.json", kind: "file", purpose: "..." },
  ],
  async loader(ctx) {
    return { items: [] }
  },
  heroStats(data) {
    return [{ label: "Metrics", value: data.items.length, accent: "var(--color-accent-soft)" }]
  },
  Component({ data }) {
    if (data.items.length === 0) {
      return <div className="data-card text-sm text-(--color-fg-muted)">No data yet.</div>
    }
    return (
      <div className="data-card">
        <ul className="space-y-2 text-sm">
          {data.items.map(i => (
            <li key={i.label} className="flex justify-between">
              <span>{i.label}</span>
              <span className="text-(--color-fg-muted)">{i.value}</span>
            </li>
          ))}
        </ul>
      </div>
    )
  },
}

Wire it into ai-ops-dashboard.config.ts:

import { panel as metrics } from "@/components/panels/metrics-panel"

export const config: AiOpsDashboardConfig = {
  // ...
  panels: [overview, metrics, activity],
}

Restart dev and visit /?tab=metrics.

Data-source adapters

All readers from lib/data-sources/index.ts are safe by default โ€” failures return a fallback instead of throwing.

| Adapter | Signature | Use for | |---|---|---| | readJson<T> | (path, fallback) => T | JSON files. Returns fallback on parse error. | | readMarkdown | (path, projectRoot) => MarkdownDoc \| null | Single markdown file with frontmatter. | | walkMarkdown | (dir, projectRoot, opts?) => MarkdownDoc[] | Recursive folder of .md / .mdx. Sorted newest first by mtime. | | readGitLog | (opts) => GitCommit[] | Reads frozen snapshot first, falls back to git log via execFileSync. | | fetchJson<T> | (url, fallback, opts?) => Promise<T> | Remote JSON. 30s in-memory cache by default. | | fileMtime | (path) => Date \| null | File modified time. | | fileExists | (path) => boolean | Existence check. | | formatPT | (date) => string | Format a date as Pacific Time. |

fetchJson example

async loader(ctx) {
  const status = await fetchJson<{ ok: boolean }>(
    "https://status.example.com/api",
    { ok: false },
    { cacheMs: 60_000 }
  )
  return { status }
}

The adapter never throws โ€” if the fetch fails, you get fallback and a console warning in dev.

Performance

Loaders run sequentially on the active panel and in parallel for heroStats collection across all panels. Two rules:

  1. Keep `heroStats` fast. Every panel's loader runs on every page load just to collect the hero strip. If a panel does heavy work, gate that work behind data the loader returns and let heroStats read from it.
  2. Pre-compute slow data. If a loader takes > 200 ms, move the work to a script in scripts/ and read a JSON snapshot in the loader. That's exactly what data/recent-commits.json and data/usage.json are.

Next

Panels