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 = ` +
Loading...
+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 ` + + `; + }).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 ` +