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

27 KiB

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

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:

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

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:

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

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:

  <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:

  <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:

    <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

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:

/* ── 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

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:

if (path === '/' || path === '/sessions') {
  renderSessions();

to:

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:

let dashboardState = null;
let dashboardUnsubscribe = null;
let dashboardChart = null;

In cleanupLiveViews(), add cleanup for the dashboard:

if (dashboardUnsubscribe) {
  dashboardUnsubscribe();
  dashboardUnsubscribe = null;
}
if (dashboardChart) {
  dashboardChart.destroy();
  dashboardChart = null;
}

Step 3: Add the renderDashboard function

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

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:

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

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

git add -A
git commit -m "fix: dashboard polish and fixes"