Files
agentmon/docs/plans/2026-03-14-dashboard-plan.md
T
2026-03-20 11:17:17 -07:00

1083 lines
27 KiB
Markdown

# 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 `<head>` to add uPlot CSS before the app stylesheet:
```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.min.css">
<link rel="stylesheet" href="/static/style.css">
```
Add uPlot JS before app.js:
```html
<script src="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.iife.min.js"></script>
<script src="/static/app.js"></script>
```
Update the header logo link and nav to include Dashboard:
```html
<div class="header-logo">
<h1><a href="/">agentmon<span class="logo-dot"></span></a></h1>
</div>
<nav><a href="/">Dashboard</a><a href="/sessions">Sessions</a><a href="/agents">Agents</a><a href="/openclaw">OpenClaw</a></nav>
```
**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 = `
<div class="page-header">
<h2>Dashboard <span class="live-indicator"><span class="live-dot"></span>Live</span></h2>
</div>
<div class="dashboard-summary">
<div class="summary-card">
<div class="summary-card-label">Active Sessions</div>
<div class="summary-card-value" id="dash-active">-</div>
</div>
<div class="summary-card">
<div class="summary-card-label">Runs Today</div>
<div class="summary-card-value" id="dash-runs">-</div>
</div>
<div class="summary-card">
<div class="summary-card-label">Tool Calls</div>
<div class="summary-card-value" id="dash-tools">-</div>
</div>
<div class="summary-card">
<div class="summary-card-label">Errors</div>
<div class="summary-card-value" id="dash-errors">-</div>
</div>
</div>
<div class="vm-strip" id="dash-vm-strip"></div>
<div class="charts-row">
<div class="chart-panel">
<div class="chart-header">
<span class="chart-title">Event Rate</span>
<div class="window-selector">
<button class="window-btn active" data-w="1h">1h</button>
<button class="window-btn" data-w="6h">6h</button>
<button class="window-btn" data-w="24h">24h</button>
<button class="window-btn" data-w="7d">7d</button>
</div>
</div>
<div class="chart-container" id="dash-chart"></div>
</div>
<div class="chart-panel">
<div class="chart-header">
<span class="chart-title">By Framework</span>
</div>
<div class="fw-bars" id="dash-fw-bars">
<p class="empty-state" style="padding:1rem">Loading...</p>
</div>
</div>
</div>
<div class="bottom-panels">
<div class="feed-panel">
<div class="chart-header">
<span class="chart-title">Recent Activity</span>
</div>
<div class="timeline" id="dash-feed">
<p class="empty-state" style="padding:1rem">Loading...</p>
</div>
</div>
<div class="tools-panel">
<div class="chart-header">
<span class="chart-title">Top Tools</span>
</div>
<ul class="stat-list" id="dash-top-tools">
<li style="color:var(--text-dim);font-size:0.8rem">Loading...</li>
</ul>
</div>
</div>
`;
// 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 => `
<div class="vm-pill ${vm.active ? 'active' : 'inactive'}">
<span class="vm-pill-dot"></span>
<span class="vm-pill-name">${escapeHTML(vm.name)}</span>
<span class="vm-pill-label">${vm.active ? 'online' : 'offline'}</span>
</div>
`).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 = '<p class="empty-state" style="padding:2rem">No data for this window</p>';
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 = '<p class="empty-state" style="padding:1rem">No framework data</p>';
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 `
<div class="fw-bar-row">
<div class="fw-bar-label">
<span class="fw-bar-name">${escapeHTML(name)}</span>
<span class="fw-bar-count">${total} events</span>
</div>
<div class="fw-bar-track">
<div class="fw-bar-fill ${escapeHTML(cssClass)}" style="width:${pct}%"></div>
</div>
</div>
`;
}).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 = '<p class="empty-state" style="padding:1rem">Waiting for events...</p>';
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
? `<span class="timeline-vm-tag ${vmClass}">${escapeHTML(framework)}</span>`
: '';
return `
<div class="timeline-event">
<div class="timeline-event-header">
${getEventIcon(eventType)}
${tag}
<span class="timeline-event-type">${escapeHTML(getEventLabel(eventType))}</span>
<span class="timeline-event-time">${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())}</span>
</div>
${getEventBody(evt)}
</div>
`;
}).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 = '<li style="color:var(--text-dim);font-size:0.8rem">No tool data yet</li>';
return;
}
list.innerHTML = topTools.map(([name, count]) => `
<li>
<span class="stat-list-name">${escapeHTML(name)}</span>
<span class="stat-list-count">${count}</span>
</li>
`).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:<web-ui-port>/`
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"
```