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:
+434
-1
@@ -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();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user