From c88746693a3ef0bbf616f95e5deede7595e47754 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Fri, 20 Mar 2026 11:17:17 -0700 Subject: [PATCH] docs(plans): add dashboard and realtime agent plans --- docs/plans/2026-03-14-dashboard-design.md | 111 ++ docs/plans/2026-03-14-dashboard-plan.md | 1082 +++++++++++++++++ .../2026-03-14-realtime-agents-design.md | 59 + docs/plans/2026-03-14-realtime-agents-plan.md | 619 ++++++++++ 4 files changed, 1871 insertions(+) create mode 100644 docs/plans/2026-03-14-dashboard-design.md create mode 100644 docs/plans/2026-03-14-dashboard-plan.md create mode 100644 docs/plans/2026-03-14-realtime-agents-design.md create mode 100644 docs/plans/2026-03-14-realtime-agents-plan.md diff --git a/docs/plans/2026-03-14-dashboard-design.md b/docs/plans/2026-03-14-dashboard-design.md new file mode 100644 index 0000000..2f99b7e --- /dev/null +++ b/docs/plans/2026-03-14-dashboard-design.md @@ -0,0 +1,111 @@ +# Dashboard with Real-time Stats and Graphs + +**Date:** 2026-03-14 +**Status:** Approved + +## Overview + +Add a comprehensive dashboard at `/` combining server-side aggregation endpoints, the existing WebSocket stream, and uPlot charts for real-time agent monitoring with stats and graphs. + +## Architecture + +Three data sources feed the dashboard: + +1. **Server-side aggregation endpoints** (new) - historical stats on page load +2. **WebSocket stream** (existing) - live events update charts in real-time +3. **Existing REST endpoints** - sessions/runs for linking out + +### New Backend Endpoints + +#### `GET /v1/stats/summary` + +Current-day aggregates: + +```json +{ + "active_sessions": 3, + "runs_today": 47, + "tool_calls_today": 312, + "errors_today": 2, + "by_framework": { + "openclaw": { "runs": 30, "tools": 210, "errors": 1 }, + "claude-code": { "runs": 17, "tools": 102, "errors": 1 } + } +} +``` + +Simple `COUNT` + `GROUP BY` over the `events` table using `type` and `source_framework` columns, filtered to `ts >= today midnight`. + +#### `GET /v1/stats/timeseries?window=1h&bucket=1m` + +Bucketed event counts: + +```json +{ + "window": "1h", + "bucket": "1m", + "series": [ + { "ts": "2026-03-14T10:00:00Z", "runs": 2, "tools": 14, "errors": 0 }, + { "ts": "2026-03-14T10:01:00Z", "runs": 1, "tools": 8, "errors": 0 } + ] +} +``` + +Bucket sizes auto-calculated if not provided: 1h→1m, 6h→5m, 24h→15m, 7d→1h. Uses Postgres `date_bin` for bucketing. + +## Dashboard Layout + +Single-scroll page with four sections: + +### 1. Summary Strip + +Four stat cards in a horizontal row: +- **Active Sessions** - sessions with no `session.end` event, with framework breakdown +- **Runs Today** - total runs since midnight +- **Tool Calls** - total tool spans today +- **Errors** - error events today, red-highlighted if > 0 + +### 2. OpenClaw VM Strip + +Reuse existing VM pill component (zap/orb/sun online/offline status). + +### 3. Activity Charts + +Two charts side by side: +- **Left: Event rate** - uPlot stacked area time-series. One series per category (runs, tools, errors). Time window selector (1h/6h/24h/7d) in top-right. +- **Right: Framework breakdown** - horizontal bar chart showing events by framework. Rendered as styled divs (categorical, no uPlot needed). + +### 4. Bottom Panels + +Two columns: +- **Left: Recent activity feed** - last 20 events as compact timeline (reuse existing timeline helpers) +- **Right: Top tools** - ranked list with counts and bar visualization + +## Frontend + +- **uPlot** loaded from CDN for time-series charts +- Thin wrapper `createChart(el, series, opts)` applies dark theme from existing CSS vars +- Real-time update flow: + 1. Page load → fetch summary + timeseries (default 1h window) + 2. Connect WebSocket → append live events to charts + 3. On each WS event: increment counters, update chart bucket, update top tools, prepend to feed + 4. Time window change → re-fetch from server, rebuild chart +- Framework bars: styled `
` elements with proportional widths +- Activity feed: reuse `getEventIcon`, `getEventLabel`, `getEventBody` from existing code (extracted as shared helpers) +- Time window selector: segmented control with 1h / 6h / 24h / 7d buttons + +## File Changes + +### New files +- `internal/store/postgres/stats.go` - `Summary()` and `Timeseries()` query functions + +### Modified files +- `cmd/query-api/main.go` - add `/v1/stats/summary` and `/v1/stats/timeseries` handlers +- `cmd/web-ui/static/index.html` - add uPlot CDN script/link tags +- `cmd/web-ui/static/app.js` - dashboard route at `/`, shared helpers, chart rendering, real-time updates +- `cmd/web-ui/static/style.css` - dashboard layout, summary cards, chart containers, time window selector, framework bars + +### No changes to +- Database schema (queries use existing `events` table) +- Event format or ingestion pipeline +- Existing pages (sessions, agents, openclaw) diff --git a/docs/plans/2026-03-14-dashboard-plan.md b/docs/plans/2026-03-14-dashboard-plan.md new file mode 100644 index 0000000..3ef6dbc --- /dev/null +++ b/docs/plans/2026-03-14-dashboard-plan.md @@ -0,0 +1,1082 @@ +# 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 = ` + +
+
+
Active Sessions
+
-
+
+
+
Runs Today
+
-
+
+
+
Tool Calls
+
-
+
+
+
Errors
+
-
+
+
+
+
+
+
+ Event Rate +
+ + + + +
+
+
+
+
+
+ By Framework +
+
+

Loading...

+
+
+
+
+
+
+ Recent Activity +
+
+

Loading...

+
+
+
+
+ Top Tools +
+
    +
  • Loading...
  • +
+
+
+ `; + + // Wire up window selector + document.querySelectorAll('.window-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.window-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + dashboardState.window = btn.dataset.w; + loadTimeseries(); + }); + }); + + // Render VM strip + renderAgentVMStrip_dash(); + + // Load initial data + try { + const [summaryData, tsData, recentData, snapshots] = await Promise.all([ + api('/v1/stats/summary'), + api('/v1/stats/timeseries?window=1h'), + api('/v1/events?limit=20'), + api('/v1/events?event_type=openclaw.snapshot&limit=100').catch(() => ({ events: [] })), + ]); + + if (!isCurrentPath('/')) return; + + mergeOpenClawEvents(snapshots.events || []); + renderAgentVMStrip_dash(); + + dashboardState.summary = summaryData; + dashboardState.timeseries = tsData; + renderSummaryCards(); + renderTimeseriesChart(); + renderFrameworkBars(); + + // Seed recent events + const events = (recentData.events || []).slice().reverse(); + for (const evt of events) { + const id = getRecordID(evt); + if (id && !dashboardState.recentEventIDs.has(id)) { + dashboardState.recentEventIDs.add(id); + dashboardState.recentEvents.push(evt); + tallyTool(evt); + } + } + renderDashFeed(); + renderDashTopTools(); + } catch (e) { + console.error('Dashboard load error:', e); + } + + // Subscribe to WebSocket for live updates + dashboardUnsubscribe = subscribeWS(handleDashboardWS); +} + +function renderAgentVMStrip_dash() { + const strip = document.getElementById('dash-vm-strip'); + if (!strip) return; + const vms = getVMStatus(); + strip.innerHTML = vms.map(vm => ` +
+ + ${escapeHTML(vm.name)} + ${vm.active ? 'online' : 'offline'} +
+ `).join(''); +} + +function handleDashboardWS(msg) { + if (msg.type !== 'message') return; + + const eventType = getEnvelopeType(msg.data); + + if (eventType === 'openclaw.snapshot') { + mergeOpenClawEvents([msg.data]); + renderAgentVMStrip_dash(); + return; + } + + // Update summary counters + if (dashboardState.summary) { + if (eventType === 'run.start') dashboardState.summary.runs_today++; + if (eventType === 'error') dashboardState.summary.errors_today++; + if (eventType === 'span.end') { + const attrs = getEnvelopeAttributes(msg.data); + if (attrs.span_kind === 'tool') dashboardState.summary.tool_calls_today++; + } + renderSummaryCards(); + } + + // Update recent feed + const id = getRecordID(msg.data); + if (id && !dashboardState.recentEventIDs.has(id)) { + dashboardState.recentEventIDs.add(id); + dashboardState.recentEvents.push(msg.data); + tallyTool(msg.data); + + // Cap at 50, display 20 + while (dashboardState.recentEvents.length > 50) { + const removed = dashboardState.recentEvents.shift(); + dashboardState.recentEventIDs.delete(getRecordID(removed)); + } + + renderDashFeed(); + renderDashTopTools(); + } + + // Append to current timeseries bucket + if (dashboardState.timeseries && dashboardState.window === '1h') { + appendToCurrentBucket(msg.data); + } +} + +function tallyTool(evt) { + const eventType = getEnvelopeType(evt); + if (eventType === 'span.end') { + const attrs = getEnvelopeAttributes(evt); + if (attrs.span_kind === 'tool') { + const name = attrs.name || 'unknown'; + dashboardState.toolCounts[name] = (dashboardState.toolCounts[name] || 0) + 1; + } + } +} + +function renderSummaryCards() { + const s = dashboardState.summary; + if (!s) return; + + const el = (id, val) => { + const e = document.getElementById(id); + if (e) e.textContent = String(val); + }; + + el('dash-active', s.active_sessions); + el('dash-runs', s.runs_today); + el('dash-tools', s.tool_calls_today); + el('dash-errors', s.errors_today); + + const errEl = document.getElementById('dash-errors'); + if (errEl) { + errEl.classList.toggle('has-errors', s.errors_today > 0); + } +} + +async function loadTimeseries() { + try { + const data = await api('/v1/stats/timeseries?window=' + dashboardState.window); + if (!isCurrentPath('/')) return; + dashboardState.timeseries = data; + renderTimeseriesChart(); + } catch (e) { + console.error('Failed to load timeseries:', e); + } +} + +function renderTimeseriesChart() { + const container = document.getElementById('dash-chart'); + if (!container || !dashboardState.timeseries) return; + + const ts = dashboardState.timeseries; + if (!ts.series || ts.series.length === 0) { + container.innerHTML = '

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 ` +
+
+ ${escapeHTML(name)} + ${total} events +
+
+
+
+
+ `; + }).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 ` +
+
+ ${getEventIcon(eventType)} + ${tag} + ${escapeHTML(getEventLabel(eventType))} + ${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())} +
+ ${getEventBody(evt)} +
+ `; + }).join(''); +} + +function renderDashTopTools() { + const list = document.getElementById('dash-top-tools'); + if (!list) return; + + const topTools = Object.entries(dashboardState.toolCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10); + + if (topTools.length === 0) { + list.innerHTML = '
  • No tool data yet
  • '; + return; + } + + list.innerHTML = topTools.map(([name, count]) => ` +
  • + ${escapeHTML(name)} + ${count} +
  • + `).join(''); +} +``` + +**Step 4: Verify no JS syntax errors** + +Open the browser dev tools, navigate to `/`, and verify no console errors. + +**Step 5: Commit** + +```bash +git add cmd/web-ui/static/app.js +git commit -m "feat: add real-time dashboard with charts, stats, and activity feed" +``` + +--- + +### Task 6: Resize handling and polish + +**Files:** +- Modify: `cmd/web-ui/static/app.js` + +**Step 1: Add resize observer for the chart** + +Inside the `renderTimeseriesChart` function, after creating the uPlot instance, add: + +```js +const ro = new ResizeObserver(entries => { + for (const entry of entries) { + if (dashboardChart) { + dashboardChart.setSize({ width: entry.contentRect.width, height: 200 }); + } + } +}); +ro.observe(container); +``` + +**Step 2: Commit** + +```bash +git add cmd/web-ui/static/app.js +git commit -m "feat: add chart resize handling" +``` + +--- + +### Task 7: End-to-end verification + +**Step 1: Build all binaries** + +Run: `cd /home/will/lab/agentmon && go build ./...` +Expected: no errors + +**Step 2: Start the stack locally** + +Run: `cd /home/will/lab/agentmon && ./start-all.sh` +Verify: all services start + +**Step 3: Open the dashboard** + +Navigate to `http://localhost:/` +Verify: +- Summary cards show numbers (may be 0 if no data) +- VM strip shows zap/orb/sun status +- Chart renders (empty is OK with no data) +- Framework bars section renders +- Activity feed and top tools sections render +- Time window buttons switch the chart +- WebSocket connects (check browser console for "WebSocket connected") + +**Step 4: Final commit if any fixes needed** + +```bash +git add -A +git commit -m "fix: dashboard polish and fixes" +``` diff --git a/docs/plans/2026-03-14-realtime-agents-design.md b/docs/plans/2026-03-14-realtime-agents-design.md new file mode 100644 index 0000000..b3d4821 --- /dev/null +++ b/docs/plans/2026-03-14-realtime-agents-design.md @@ -0,0 +1,59 @@ +# Realtime Agents Activity View + +**Date:** 2026-03-14 +**Status:** Approved + +## Overview + +Replace the single-timeline Agents page with a per-agent lane layout showing live presence, in-progress operations, and recent events grouped by VM (client_id). + +## Data Model: Live State Tracker + +Track open events (start without matching end) to derive live status: + +- **Active sessions**: `session.start` events keyed by `session_id`, removed on `session.end`. Each knows its VM, start time, framework. +- **In-progress operations**: `span.start`/`run.start` keyed by `span_id`/`run_id`. Marked complete on matching end event. Show elapsed timer while open. +- **Per-agent grouping**: All state grouped by `client_id` (zap, orb, sun). + +Initial REST load seeds state, WebSocket updates keep it current. + +## Layout: Per-Agent Lanes + +Three columns (one per VM), replacing the single timeline + stats sidebar: + +``` +┌─────────────────────────────────────────────────┐ +│ Agents ● Live │ +├───────────────┬───────────────┬─────────────────┤ +│ ZAP │ ORB │ SUN │ +│ ● 2 sessions │ ● 1 session │ ○ idle │ +│ ───────────── │ ───────────── │ ─────────────── │ +│ ▶ Bash (3.2s) │ ▶ Read (1.1s) │ │ +│ ▶ Grep (...) │ │ (recent events) │ +│ ───────────── │ ───────────── │ │ +│ recent events │ recent events │ │ +└───────────────┴───────────────┴─────────────────┘ +``` + +Each lane has: +- **Header**: VM name, online/offline dot, active session count +- **Active operations**: In-progress spans/runs with pulsing dot + live elapsed timer +- **Recent events**: Completed events, same card style, scoped to this agent + +Summary stats (messages/tools/errors) move to a compact row above the lanes. + +Responsive: lanes stack vertically below 900px. + +## Active Operations Display + +- `span.start` (tool): pulsing green dot + tool name + elapsed counter (1s setInterval) +- `run.start`: shows as "Thinking..." until `run.end` +- On matching end event: pill fades out, completed event appears in recent list +- If session has both active run + active tool spans, show tool spans (more specific) +- Stale guard: after 5 minutes with no update, dim and show "(stale?)" + +## Implementation Notes + +- All changes in `style.css` and `app.js` (no backend changes needed) +- Reuse existing WebSocket subscription and REST API calls +- Existing event envelope fields (`correlation.session_id`, `correlation.span_id`, `correlation.run_id`, `source.client_id`) provide all grouping keys diff --git a/docs/plans/2026-03-14-realtime-agents-plan.md b/docs/plans/2026-03-14-realtime-agents-plan.md new file mode 100644 index 0000000..97fb3f0 --- /dev/null +++ b/docs/plans/2026-03-14-realtime-agents-plan.md @@ -0,0 +1,619 @@ +# Realtime Agents Activity View — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace the single-timeline Agents page with per-agent lanes showing live presence, in-progress operations, and scoped event feeds. + +**Architecture:** Pure frontend change. Replace `createAgentsState()`, `renderAgents()`, `handleAgentsWS()`, and related functions in `app.js`. Add new CSS classes in `style.css`. Reuse existing envelope helpers, WS subscription, and REST API. + +**Tech Stack:** Vanilla JS, CSS custom properties, existing WebSocket + REST API + +--- + +### Task 1: CSS — Lane layout and active operation styles + +**Files:** +- Modify: `cmd/web-ui/static/style.css` (append after existing agents styles, ~line 1430) + +**Step 1: Add lane layout CSS** + +```css +/* ── Agent lanes ──────────────────────────────────────────── */ +.agents-summary-row { + display: flex; + gap: 1.25rem; + margin-bottom: 1.25rem; +} + +.agents-summary-stat { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.6rem 1rem; + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--text-dim); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.agents-summary-stat .value { + color: var(--text-bright); + font-weight: 600; +} + +.agent-lanes { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.25rem; +} + +@media (max-width: 900px) { + .agent-lanes { + grid-template-columns: 1fr; + } +} + +.agent-lane { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.agent-lane-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.875rem 1.125rem; + border-bottom: 1px solid var(--border-soft); +} + +.agent-lane-name { + display: flex; + align-items: center; + gap: 0.5rem; + font-family: var(--font-display); + font-size: 0.9rem; + font-weight: 700; + color: var(--text-bright); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.agent-lane-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + +.agent-lane-dot.online { + background: var(--success); + box-shadow: 0 0 6px rgba(52, 211, 153, 0.5); + animation: livePulse 2s ease-in-out infinite; +} + +.agent-lane-dot.offline { + background: var(--text-dim); + opacity: 0.5; +} + +.agent-lane-status { + font-size: 0.7rem; + font-weight: 600; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.agent-lane-status.has-sessions { + color: var(--success); +} + +/* ── Active operations ────────────────────────────────────── */ +.active-ops { + padding: 0.625rem 1.125rem; + display: flex; + flex-direction: column; + gap: 0.375rem; + border-bottom: 1px solid var(--border-soft); +} + +.active-op { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.625rem; + background: var(--accent-dim); + border: 1px solid rgba(34, 211, 238, 0.15); + border-radius: var(--radius); + font-size: 0.75rem; + animation: fadeUp 0.2s ease both; +} + +[data-theme="light"] .active-op { + border-color: rgba(8, 145, 178, 0.2); +} + +.active-op.stale { + opacity: 0.5; +} + +.active-op-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--success); + box-shadow: 0 0 6px rgba(52, 211, 153, 0.5); + animation: livePulse 1.5s ease-in-out infinite; + flex-shrink: 0; +} + +.active-op.stale .active-op-dot { + background: var(--warning); + box-shadow: none; + animation: none; +} + +.active-op-name { + font-family: var(--font-mono); + color: var(--accent); + font-weight: 500; +} + +.active-op-time { + font-family: var(--font-mono); + color: var(--text-dim); + font-size: 0.7rem; + margin-left: auto; +} + +.active-op-stale { + color: var(--warning); + font-size: 0.65rem; + font-weight: 600; +} + +/* ── Lane event feed ──────────────────────────────────────── */ +.agent-lane-events { + flex: 1; + overflow-y: auto; + max-height: 520px; + padding: 0.625rem; + position: relative; +} + +.agent-lane-events::after { + content: ''; + position: sticky; + bottom: 0; + left: 0; + right: 0; + height: 32px; + background: linear-gradient(to bottom, transparent, var(--surface)); + pointer-events: none; + display: block; +} + +.agent-lane-events .timeline-event { + padding: 0.5rem 0.625rem; + border-radius: var(--radius); + margin-bottom: 0.25rem; + border: 1px solid var(--border-soft); + background: transparent; + font-size: 0.82rem; +} + +.agent-lane-events .timeline-event-header { + margin-bottom: 0.2rem; +} + +.agent-lane-events .timeline-event-time { + font-size: 0.6rem; +} + +.agent-lane-events .empty-state { + padding: 2rem 1rem; + font-size: 0.78rem; +} +``` + +**Step 2: Commit** + +```bash +git add cmd/web-ui/static/style.css +git commit -m "feat(agents): add CSS for per-agent lane layout and active operation pills" +``` + +--- + +### Task 2: JS — Live state tracker data model + +**Files:** +- Modify: `cmd/web-ui/static/app.js` — replace `createAgentsState()` (~line 665) and add `processAgentEvent()` and `getAgentDisplayOps()` + +**Step 1: Replace createAgentsState** + +Replace the existing `createAgentsState()` function (lines ~626-643) with: + +```js +function createAgentsState() { + function agentBucket() { + return { sessions: {}, operations: {}, events: [], eventIDs: new Set() }; + } + return { + agents: { zap: agentBucket(), orb: agentBucket(), sun: agentBucket() }, + stats: { messages: 0, tools: 0, errors: 0, toolCounts: {} }, + timerInterval: null, + }; +} +``` + +**Step 2: Add processAgentEvent function** + +Add after `createAgentsState`: + +```js +function getAgentBucket(evt) { + const name = getVMName(evt).toLowerCase(); + return agentsState.agents[name] || null; +} + +function processAgentEvent(evt) { + const agent = getAgentBucket(evt); + if (!agent) return; + + const eventType = getEnvelopeType(evt); + const correlation = getEnvelopeCorrelation(evt); + const attrs = getEnvelopeAttributes(evt); + + // Track active sessions + if (eventType === 'session.start' && correlation.session_id) { + agent.sessions[correlation.session_id] = { ts: getEnvelopeTS(evt) }; + } + if (eventType === 'session.end' && correlation.session_id) { + delete agent.sessions[correlation.session_id]; + } + + // Track active operations (span) + if (eventType === 'span.start' && correlation.span_id) { + agent.operations['s:' + correlation.span_id] = { + type: 'span', + name: attrs.name || attrs.span_kind || 'unknown', + kind: attrs.span_kind || '', + startedAt: Date.now(), + }; + } + if (eventType === 'span.end' && correlation.span_id) { + delete agent.operations['s:' + correlation.span_id]; + } + + // Track active operations (run) + if (eventType === 'run.start' && correlation.run_id) { + agent.operations['r:' + correlation.run_id] = { + type: 'run', + name: 'Thinking\u2026', + kind: 'run', + startedAt: Date.now(), + }; + } + if (eventType === 'run.end' && correlation.run_id) { + delete agent.operations['r:' + correlation.run_id]; + } + + // Add to recent events list + const id = getRecordID(evt); + if (id && !agent.eventIDs.has(id)) { + agent.eventIDs.add(id); + agent.events.push(evt); + while (agent.events.length > 100) { + const removed = agent.events.shift(); + agent.eventIDs.delete(getRecordID(removed)); + } + } +} + +function getAgentDisplayOps(agent) { + const ops = Object.values(agent.operations); + const hasTools = ops.some(op => op.kind === 'tool'); + return hasTools ? ops.filter(op => op.kind === 'tool') : ops; +} +``` + +**Step 3: Update recomputeAgentStats** + +Replace existing `recomputeAgentStats()` to iterate over all agent buckets: + +```js +function recomputeAgentStats() { + const stats = { messages: 0, tools: 0, errors: 0, toolCounts: {} }; + + for (const agent of Object.values(agentsState.agents)) { + for (const evt of agent.events) { + const eventType = getEnvelopeType(evt); + const attrs = getEnvelopeAttributes(evt); + + if (eventType === 'run.start' || eventType === 'run.end') stats.messages++; + if (eventType === 'span.end' && attrs.span_kind === 'tool') { + stats.tools++; + const toolName = attrs.name || 'unknown'; + stats.toolCounts[toolName] = (stats.toolCounts[toolName] || 0) + 1; + } + if (eventType === 'error') stats.errors++; + } + } + + agentsState.stats = stats; +} +``` + +**Step 4: Commit** + +```bash +git add cmd/web-ui/static/app.js +git commit -m "feat(agents): add live state tracker with session/operation pairing" +``` + +--- + +### Task 3: JS — Rewrite renderAgents and lane rendering + +**Files:** +- Modify: `cmd/web-ui/static/app.js` — replace `renderAgents()`, `renderAgentTimeline()`, `renderAgentStats()`, `addAgentEvents()` + +**Step 1: Replace addAgentEvents** + +Replace existing `addAgentEvents()` with a version that routes to processAgentEvent: + +```js +function addAgentEvents(events) { + let changed = false; + + for (const evt of events) { + const id = getRecordID(evt); + const agent = getAgentBucket(evt); + if (!id || !agent || agent.eventIDs.has(id)) continue; + processAgentEvent(evt); + changed = true; + } + + if (changed) { + // Sort each agent's events + for (const agent of Object.values(agentsState.agents)) { + agent.events.sort((a, b) => new Date(getEnvelopeTS(a)).getTime() - new Date(getEnvelopeTS(b)).getTime()); + } + recomputeAgentStats(); + } +} +``` + +**Step 2: Replace renderAgents with lane layout** + +Replace the existing `renderAgents()` function: + +```js +async function renderAgents() { + agentsState = createAgentsState(); + + app.innerHTML = ` + +
    +
    +
    ZAP

    Loading...

    +
    ORB

    Loading...

    +
    SUN

    Loading...

    +
    + `; + + try { + const [snapshots, events] = await Promise.all([ + api('/v1/events?event_type=openclaw.snapshot&limit=100').catch(() => ({ events: [] })), + api('/v1/events?framework=openclaw&limit=200'), + ]); + + if (!isCurrentPath('/agents')) return; + + mergeOpenClawEvents(snapshots.events || []); + addAgentEvents((events.events || []).slice().reverse()); + renderAgentLanes(); + renderAgentSummary(); + } catch (e) { + document.getElementById('agents-lanes').innerHTML = + `

    Error loading agent activity: ${escapeHTML(e.message)}

    `; + } + + // Start elapsed timer + agentsState.timerInterval = setInterval(updateAgentTimers, 1000); + agentsUnsubscribe = subscribeWS(handleAgentsWS); +} +``` + +**Step 3: Add renderAgentLanes** + +```js +function renderAgentLanes() { + const lanesEl = document.getElementById('agents-lanes'); + if (!lanesEl) return; + + const vmNames = ['zap', 'orb', 'sun']; + + lanesEl.innerHTML = vmNames.map(name => { + const agent = agentsState.agents[name]; + const vmStatus = getVMStatus().find(v => v.name === name); + const isOnline = vmStatus && vmStatus.active; + const sessionCount = Object.keys(agent.sessions).length; + const ops = getAgentDisplayOps(agent); + + const statusClass = sessionCount > 0 ? ' has-sessions' : ''; + const statusText = !isOnline ? 'offline' + : sessionCount > 0 ? sessionCount + ' session' + (sessionCount > 1 ? 's' : '') + : 'idle'; + + const opsHTML = ops.length > 0 ? `
    ${ops.map(op => { + const elapsed = Math.floor((Date.now() - op.startedAt) / 1000); + const stale = elapsed > 300; + return ` +
    + + ${escapeHTML(op.name)} + ${formatElapsed(elapsed)} + ${stale ? '(stale?)' : ''} +
    `; + }).join('')}
    ` : ''; + + const recent = agent.events.slice(-40).reverse(); + const eventsHTML = recent.length > 0 ? recent.map(evt => { + const eventType = getEnvelopeType(evt); + const vmClass = getVMClassName(name); + const details = getEventDetails(evt); + const detailHTML = details ? `
    ${escapeHTML(details)}
    ` : ''; + const expandHTML = details ? '' : ''; + + return ` +
    +
    + ${getEventIcon(eventType)} + ${escapeHTML(getEventLabel(eventType))} + ${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())} +
    + ${getEventBody(evt)} + ${expandHTML} + ${detailHTML} +
    `; + }).join('') : '

    No recent activity

    '; + + return ` +
    +
    +
    + + ${escapeHTML(name.toUpperCase())} +
    + ${statusText} +
    + ${opsHTML} +
    ${eventsHTML}
    +
    `; + }).join(''); + + // Wire up detail expand buttons + lanesEl.querySelectorAll('.timeline-expand-hint').forEach(button => { + button.addEventListener('click', () => { + button.parentElement.classList.toggle('expanded'); + }); + }); +} +``` + +**Step 4: Add formatElapsed helper** + +Add near the other format helpers (around line 202): + +```js +function formatElapsed(seconds) { + if (seconds < 60) return seconds + 's'; + if (seconds < 3600) return Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's'; + return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm'; +} +``` + +**Step 5: Add renderAgentSummary** + +```js +function renderAgentSummary() { + const el = document.getElementById('agents-summary'); + if (!el) return; + const s = agentsState.stats; + el.innerHTML = ` +
    Messages ${s.messages}
    +
    Tool Calls ${s.tools}
    +
    Errors ${s.errors}
    + `; +} +``` + +**Step 6: Commit** + +```bash +git add cmd/web-ui/static/app.js +git commit -m "feat(agents): per-agent lane layout with active operations and scoped event feeds" +``` + +--- + +### Task 4: JS — WS handler and elapsed timer + +**Files:** +- Modify: `cmd/web-ui/static/app.js` — replace `handleAgentsWS()`, add `updateAgentTimers()` + +**Step 1: Replace handleAgentsWS** + +```js +function handleAgentsWS(msg) { + if (msg.type !== 'message') return; + + const eventType = getEnvelopeType(msg.data); + if (eventType === 'openclaw.snapshot') { + mergeOpenClawEvents([msg.data]); + renderAgentLanes(); + return; + } + + const framework = getEnvelopeSource(msg.data).framework || msg.data.source_framework; + if (framework !== 'openclaw') return; + + addAgentEvents([msg.data]); + renderAgentLanes(); + renderAgentSummary(); +} +``` + +**Step 2: Add updateAgentTimers** + +```js +function updateAgentTimers() { + document.querySelectorAll('.active-op-time[data-start]').forEach(el => { + const start = parseInt(el.dataset.start, 10); + if (!start) return; + const elapsed = Math.floor((Date.now() - start) / 1000); + el.textContent = formatElapsed(elapsed); + + // Check stale + const op = el.closest('.active-op'); + if (op && elapsed > 300 && !op.classList.contains('stale')) { + op.classList.add('stale'); + if (!op.querySelector('.active-op-stale')) { + op.insertAdjacentHTML('beforeend', '(stale?)'); + } + } + }); +} +``` + +**Step 3: Clean up timer on navigation** + +In `cleanupLiveViews()`, add timer cleanup: + +```js +if (agentsState && agentsState.timerInterval) { + clearInterval(agentsState.timerInterval); + agentsState.timerInterval = null; +} +``` + +**Step 4: Remove old functions that are no longer needed** + +Delete: `renderAgentTimeline()`, `renderAgentStats()` (replaced by `renderAgentLanes()` and `renderAgentSummary()`), `renderAgentVMStrip()` (VM status is now in lane headers). + +**Step 5: Commit** + +```bash +git add cmd/web-ui/static/app.js +git commit -m "feat(agents): WS handler, elapsed timer, and stale detection" +```