feat: add real-time dashboard with charts, stats, and activity feed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
William Valentin
2026-03-14 11:05:07 -07:00
parent cac3404aa4
commit eaf73e5ff5
+434 -1
View File
@@ -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 = `
<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>
`;
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 => `
<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]);
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 = '<p class="empty-state" style="padding:2rem">No data for this window</p>';
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 = '<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('');
}
route();
})();