From eaf73e5ff51f68f95e5dd07a48cded7fcc859234 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Sat, 14 Mar 2026 11:05:07 -0700 Subject: [PATCH] feat: add real-time dashboard with charts, stats, and activity feed Co-Authored-By: Claude Sonnet 4.6 --- cmd/web-ui/static/app.js | 435 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 434 insertions(+), 1 deletion(-) diff --git a/cmd/web-ui/static/app.js b/cmd/web-ui/static/app.js index b216f71..262c1b0 100644 --- a/cmd/web-ui/static/app.js +++ b/cmd/web-ui/static/app.js @@ -10,6 +10,9 @@ let openclawUnsubscribe = null; let agentsState = createAgentsState(); let agentsUnsubscribe = null; + let dashboardState = null; + let dashboardUnsubscribe = null; + let dashboardChart = null; function getWsURL() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; @@ -70,13 +73,23 @@ agentsUnsubscribe(); agentsUnsubscribe = null; } + if (dashboardUnsubscribe) { + dashboardUnsubscribe(); + dashboardUnsubscribe = null; + } + if (dashboardChart) { + dashboardChart.destroy(); + dashboardChart = null; + } } function route() { cleanupLiveViews(); const path = window.location.pathname; - if (path === '/' || path === '/sessions') { + if (path === '/') { + renderDashboard(); + } else if (path === '/sessions') { renderSessions(); } else if (path.startsWith('/agents')) { renderAgents(); @@ -907,5 +920,425 @@ `).join(''); } + 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...
  • +
+
+
+ `; + + 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(); + }); + }); + + renderDashVMStrip(); + + 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 || []); + renderDashVMStrip(); + + dashboardState.summary = summaryData; + dashboardState.timeseries = tsData; + renderSummaryCards(); + renderTimeseriesChart(); + renderFrameworkBars(); + + 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); + } + + dashboardUnsubscribe = subscribeWS(handleDashboardWS); + } + + function renderDashVMStrip() { + 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]); + renderDashVMStrip(); + return; + } + + 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(); + } + + const id = getRecordID(msg.data); + if (id && !dashboardState.recentEventIDs.has(id)) { + dashboardState.recentEventIDs.add(id); + dashboardState.recentEvents.push(msg.data); + tallyTool(msg.data); + + while (dashboardState.recentEvents.length > 50) { + const removed = dashboardState.recentEvents.shift(); + dashboardState.recentEventIDs.delete(getRecordID(removed)); + } + + renderDashFeed(); + renderDashTopTools(); + } + + 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; + } + + 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); + + const ro = new ResizeObserver(entries => { + for (const entry of entries) { + if (dashboardChart) { + dashboardChart.setSize({ width: entry.contentRect.width, height: 200 }); + } + } + }); + ro.observe(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; + 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(''); + } + route(); })();