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:
- Catches the error.
- Renders a single error card in that panel's slot.
- 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.mdusage
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 -- metricsThis 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:
- 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
loaderreturns and letheroStatsread from it. - 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 whatdata/recent-commits.jsonanddata/usage.jsonare.
Next
- configuration.md โ Wire your panel into config.
- deployment.md โ Ship it.