# Dashboard Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add a real-time dashboard at `/` with summary stats, time-series charts (uPlot), framework breakdown, activity feed, and top tools — powered by new server-side aggregation endpoints plus the existing WebSocket stream. **Architecture:** Two new Postgres query functions (`Summary`, `Timeseries`) exposed as REST endpoints. Frontend fetches historical data on load, then layers live WebSocket events on top. uPlot renders time-series charts; framework bars and top tools are styled HTML. **Tech Stack:** Go (chi router, pgx), Postgres `date_bin`, uPlot (CDN), vanilla JS --- ### Task 1: Stats Query Functions **Files:** - Create: `internal/store/postgres/stats.go` **Step 1: Create the summary query function** ```go package postgres import ( "context" "time" ) type FrameworkStats struct { Runs int `json:"runs"` Tools int `json:"tools"` Errors int `json:"errors"` } type Summary struct { ActiveSessions int `json:"active_sessions"` RunsToday int `json:"runs_today"` ToolCallsToday int `json:"tool_calls_today"` ErrorsToday int `json:"errors_today"` ByFramework map[string]FrameworkStats `json:"by_framework"` } func (d *DB) GetSummary(ctx context.Context) (*Summary, error) { now := time.Now() midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) s := &Summary{ByFramework: make(map[string]FrameworkStats)} // Active sessions: sessions that have a session.start but no session.end today err := d.sql.QueryRowContext(ctx, ` SELECT COUNT(DISTINCT session_id) FROM events WHERE session_id IS NOT NULL AND ts >= $1 AND type = 'session.start' AND session_id NOT IN ( SELECT DISTINCT session_id FROM events WHERE session_id IS NOT NULL AND type = 'session.end' AND ts >= $1 ) `, midnight).Scan(&s.ActiveSessions) if err != nil { return nil, err } // Aggregates by framework rows, err := d.sql.QueryContext(ctx, ` SELECT COALESCE(source_framework, 'unknown'), COUNT(*) FILTER (WHERE type IN ('run.start', 'run.end')) / 2, COUNT(*) FILTER (WHERE type = 'span.end' AND payload->'attributes'->>'span_kind' = 'tool'), COUNT(*) FILTER (WHERE type = 'error') FROM events WHERE ts >= $1 GROUP BY source_framework `, midnight) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var fw string var fs FrameworkStats if err := rows.Scan(&fw, &fs.Runs, &fs.Tools, &fs.Errors); err != nil { return nil, err } s.ByFramework[fw] = fs s.RunsToday += fs.Runs s.ToolCallsToday += fs.Tools s.ErrorsToday += fs.Errors } return s, rows.Err() } ``` **Step 2: Create the timeseries query function** Add to the same file: ```go type TimeseriesBucket struct { TS time.Time `json:"ts"` Runs int `json:"runs"` Tools int `json:"tools"` Errors int `json:"errors"` } type TimeseriesResult struct { Window string `json:"window"` Bucket string `json:"bucket"` Series []TimeseriesBucket `json:"series"` } func bucketForWindow(window string) string { switch window { case "6h": return "5 minutes" case "24h": return "15 minutes" case "7d": return "1 hour" default: return "1 minute" } } func durationForWindow(window string) time.Duration { switch window { case "6h": return 6 * time.Hour case "24h": return 24 * time.Hour case "7d": return 7 * 24 * time.Hour default: return time.Hour } } func bucketLabelForWindow(window string) string { switch window { case "6h": return "5m" case "24h": return "15m" case "7d": return "1h" default: return "1m" } } func (d *DB) GetTimeseries(ctx context.Context, window string) (*TimeseriesResult, error) { bucket := bucketForWindow(window) dur := durationForWindow(window) from := time.Now().Add(-dur) rows, err := d.sql.QueryContext(ctx, ` SELECT date_bin($1::interval, ts, '2000-01-01'::timestamptz) AS bucket_ts, COUNT(*) FILTER (WHERE type IN ('run.start')) AS runs, COUNT(*) FILTER (WHERE type = 'span.end' AND payload->'attributes'->>'span_kind' = 'tool') AS tools, COUNT(*) FILTER (WHERE type = 'error') AS errors FROM events WHERE ts >= $2 GROUP BY bucket_ts ORDER BY bucket_ts ASC `, bucket, from) if err != nil { return nil, err } defer rows.Close() result := &TimeseriesResult{ Window: window, Bucket: bucketLabelForWindow(window), } for rows.Next() { var b TimeseriesBucket if err := rows.Scan(&b.TS, &b.Runs, &b.Tools, &b.Errors); err != nil { return nil, err } result.Series = append(result.Series, b) } return result, rows.Err() } ``` **Step 3: Verify it compiles** Run: `cd /home/will/lab/agentmon && go build ./internal/store/postgres/` Expected: no errors **Step 4: Commit** ```bash git add internal/store/postgres/stats.go git commit -m "feat: add summary and timeseries stats queries" ``` --- ### Task 2: Stats API Endpoints **Files:** - Modify: `cmd/query-api/main.go` **Step 1: Add the summary endpoint** After the existing `/v1/runs/{runID}` handler block (around line 210), add: ```go r.Get("/v1/stats/summary", func(w http.ResponseWriter, r *http.Request) { summary, err := db.GetSummary(r.Context()) if err != nil { httpx.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "db_error"}) return } httpx.WriteJSON(w, http.StatusOK, summary) }) r.Get("/v1/stats/timeseries", func(w http.ResponseWriter, r *http.Request) { window := r.URL.Query().Get("window") switch window { case "1h", "6h", "24h", "7d": default: window = "1h" } ts, err := db.GetTimeseries(r.Context(), window) if err != nil { httpx.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "db_error"}) return } httpx.WriteJSON(w, http.StatusOK, ts) }) ``` **Step 2: Verify it compiles** Run: `cd /home/will/lab/agentmon && go build ./cmd/query-api/` Expected: no errors **Step 3: Commit** ```bash git add cmd/query-api/main.go git commit -m "feat: add stats summary and timeseries API endpoints" ``` --- ### Task 3: Update HTML and Navigation **Files:** - Modify: `cmd/web-ui/static/index.html` **Step 1: Add uPlot CDN and update nav** Update the `
` to add uPlot CSS before the app stylesheet: ```html ``` Add uPlot JS before app.js: ```html ``` Update the header logo link and nav to include Dashboard: ```html ``` **Step 2: Commit** ```bash git add cmd/web-ui/static/index.html git commit -m "feat: add uPlot CDN and dashboard nav link" ``` --- ### Task 4: Dashboard CSS **Files:** - Modify: `cmd/web-ui/static/style.css` **Step 1: Add dashboard styles** Append to end of `style.css`: ```css /* ── Dashboard ────────────────────────────────────────────── */ .dashboard-summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 1.5rem; } @media (max-width: 900px) { .dashboard-summary { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 500px) { .dashboard-summary { grid-template-columns: 1fr; } } .summary-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 1.125rem 1.25rem; transition: border-color 0.2s; } .summary-card:hover { border-color: rgba(34, 211, 238, 0.18); } .summary-card-label { font-size: 0.68rem; font-weight: 700; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 0.5rem; } .summary-card-value { font-family: var(--font-display); font-size: 2rem; font-weight: 800; color: var(--text-bright); letter-spacing: -0.02em; line-height: 1; } .summary-card-value.has-errors { color: var(--error); } .summary-card-sub { font-size: 0.72rem; color: var(--text-dim); margin-top: 0.35rem; font-family: var(--font-mono); } /* ── Charts row ───────────────────────────────────────────── */ .charts-row { display: grid; grid-template-columns: 1fr 320px; gap: 1.25rem; margin-bottom: 1.5rem; } @media (max-width: 900px) { .charts-row { grid-template-columns: 1fr; } } .chart-panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 1.25rem; min-height: 280px; } .chart-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; } .chart-title { font-family: var(--font-display); font-size: 0.88rem; font-weight: 700; color: var(--text-bright); letter-spacing: 0.01em; } .window-selector { display: flex; gap: 0; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } .window-btn { background: transparent; border: none; color: var(--text-dim); font-family: var(--font-mono); font-size: 0.7rem; font-weight: 600; padding: 0.3rem 0.65rem; cursor: pointer; letter-spacing: 0.04em; transition: background 0.15s, color 0.15s; border-right: 1px solid var(--border); } .window-btn:last-child { border-right: none; } .window-btn:hover { color: var(--text-bright); background: var(--surface-2); } .window-btn.active { color: var(--accent); background: var(--accent-dim); } .chart-container { width: 100%; min-height: 200px; } /* ── Framework bars ───────────────────────────────────────── */ .fw-bars { display: flex; flex-direction: column; gap: 0.75rem; margin-top: 0.25rem; } .fw-bar-row { display: flex; flex-direction: column; gap: 0.3rem; } .fw-bar-label { display: flex; justify-content: space-between; align-items: center; } .fw-bar-name { font-family: var(--font-mono); font-size: 0.78rem; color: var(--text); } .fw-bar-count { font-family: var(--font-mono); font-size: 0.72rem; color: var(--text-dim); } .fw-bar-track { height: 6px; background: var(--surface-2); border-radius: 3px; overflow: hidden; } .fw-bar-fill { height: 100%; border-radius: 3px; transition: width 0.4s ease; } .fw-bar-fill.openclaw { background: var(--accent); } .fw-bar-fill.claude-code { background: var(--success); } .fw-bar-fill.opencode { background: var(--purple); } .fw-bar-fill.unknown { background: var(--text-dim); } /* ── Bottom panels ────────────────────────────────────────── */ .bottom-panels { display: grid; grid-template-columns: 1fr 320px; gap: 1.25rem; } @media (max-width: 900px) { .bottom-panels { grid-template-columns: 1fr; } } .feed-panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 1.25rem; max-height: 480px; overflow-y: auto; } .feed-panel .timeline-event { padding: 0.625rem 0.875rem; border-radius: var(--radius); margin-bottom: 0.375rem; border: 1px solid var(--border-soft); background: transparent; } .tools-panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 1.25rem; } /* uPlot theme overrides */ .uplot .u-legend { display: none; } ``` **Step 2: Commit** ```bash git add cmd/web-ui/static/style.css git commit -m "feat: add dashboard CSS styles" ``` --- ### Task 5: Dashboard JavaScript **Files:** - Modify: `cmd/web-ui/static/app.js` **Step 1: Update the router** Change the route function so `/` renders the dashboard and `/sessions` renders sessions: In the `route()` function, change: ```js if (path === '/' || path === '/sessions') { renderSessions(); ``` to: ```js if (path === '/') { renderDashboard(); } else if (path === '/sessions') { renderSessions(); ``` **Step 2: Add dashboard state and cleanup** Near the top of the IIFE, alongside the existing state variables, add: ```js let dashboardState = null; let dashboardUnsubscribe = null; let dashboardChart = null; ``` In `cleanupLiveViews()`, add cleanup for the dashboard: ```js if (dashboardUnsubscribe) { dashboardUnsubscribe(); dashboardUnsubscribe = null; } if (dashboardChart) { dashboardChart.destroy(); dashboardChart = null; } ``` **Step 3: Add the renderDashboard function** ```js async function renderDashboard() { dashboardState = { summary: null, timeseries: null, window: '1h', recentEvents: [], recentEventIDs: new Set(), toolCounts: {}, }; app.innerHTML = `Loading...
No data for this window
'; return; } // Destroy existing chart if (dashboardChart) { dashboardChart.destroy(); dashboardChart = null; } container.innerHTML = ''; const timestamps = ts.series.map(b => Math.floor(new Date(b.ts).getTime() / 1000)); const runs = ts.series.map(b => b.runs); const tools = ts.series.map(b => b.tools); const errors = ts.series.map(b => b.errors); const width = container.clientWidth || 600; const height = 200; const opts = { width, height, cursor: { show: true }, scales: { x: { time: true }, y: { auto: true, min: 0 }, }, axes: [ { stroke: '#4e6070', grid: { stroke: 'rgba(28, 38, 55, 0.6)', width: 1 }, ticks: { stroke: 'rgba(28, 38, 55, 0.6)', width: 1 }, font: '11px Fira Code', }, { stroke: '#4e6070', grid: { stroke: 'rgba(28, 38, 55, 0.6)', width: 1 }, ticks: { stroke: 'rgba(28, 38, 55, 0.6)', width: 1 }, font: '11px Fira Code', size: 50, }, ], series: [ {}, { label: 'Runs', stroke: '#34d399', width: 2, fill: 'rgba(52, 211, 153, 0.08)', }, { label: 'Tools', stroke: '#22d3ee', width: 2, fill: 'rgba(34, 211, 238, 0.08)', }, { label: 'Errors', stroke: '#f87171', width: 2, fill: 'rgba(248, 113, 113, 0.08)', }, ], }; dashboardChart = new uPlot(opts, [timestamps, runs, tools, errors], container); } function appendToCurrentBucket(evt) { const ts = dashboardState.timeseries; if (!ts || !ts.series || ts.series.length === 0) return; const now = Math.floor(Date.now() / 60000) * 60000; // current minute const last = ts.series[ts.series.length - 1]; const lastTs = new Date(last.ts).getTime(); let bucket; if (Math.abs(now - lastTs) < 60000) { bucket = last; } else { bucket = { ts: new Date(now).toISOString(), runs: 0, tools: 0, errors: 0 }; ts.series.push(bucket); } const eventType = getEnvelopeType(evt); if (eventType === 'run.start') bucket.runs++; if (eventType === 'error') bucket.errors++; if (eventType === 'span.end') { const attrs = getEnvelopeAttributes(evt); if (attrs.span_kind === 'tool') bucket.tools++; } renderTimeseriesChart(); } function renderFrameworkBars() { const container = document.getElementById('dash-fw-bars'); if (!container || !dashboardState.summary) return; const byFw = dashboardState.summary.by_framework || {}; const entries = Object.entries(byFw).sort((a, b) => { const totalA = a[1].runs + a[1].tools + a[1].errors; const totalB = b[1].runs + b[1].tools + b[1].errors; return totalB - totalA; }); if (entries.length === 0) { container.innerHTML = 'No framework data
'; return; } const maxTotal = Math.max(...entries.map(([, s]) => s.runs + s.tools + s.errors)); container.innerHTML = entries.map(([name, stats]) => { const total = stats.runs + stats.tools + stats.errors; const pct = maxTotal > 0 ? (total / maxTotal * 100) : 0; const cssClass = name.replace(/[^a-z0-9-]/g, '-'); return ` `; }).join(''); } function renderDashFeed() { const feed = document.getElementById('dash-feed'); if (!feed) return; const recent = dashboardState.recentEvents.slice(-20).reverse(); if (recent.length === 0) { feed.innerHTML = 'Waiting for events...
'; return; } feed.innerHTML = recent.map(evt => { const eventType = getEnvelopeType(evt); const vmName = getVMName(evt); const vmClass = getVMClassName(vmName); const source = getEnvelopeSource(evt); const framework = source.framework || ''; const tag = framework ? `${escapeHTML(framework)}` : ''; return `