1355 lines
44 KiB
JavaScript
1355 lines
44 KiB
JavaScript
(function() {
|
|
const app = document.getElementById('app');
|
|
|
|
let ws = null;
|
|
let wsReconnectTimeout = null;
|
|
const wsCallbacks = new Set();
|
|
|
|
let sessionsState = { sessions: [], cursor: null };
|
|
let openclawState = { instances: {} };
|
|
let openclawUnsubscribe = null;
|
|
let agentsState = createAgentsState();
|
|
let agentsUnsubscribe = null;
|
|
let dashboardState = null;
|
|
let dashboardUnsubscribe = null;
|
|
let dashboardChart = null;
|
|
let dashboardResizeObserver = null;
|
|
|
|
function getWsURL() {
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
return protocol + '//' + window.location.host + '/api/v1/ws';
|
|
}
|
|
|
|
function connectWS() {
|
|
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
ws = new WebSocket(getWsURL());
|
|
|
|
ws.onopen = () => {
|
|
console.log('WebSocket connected');
|
|
wsCallbacks.forEach(cb => cb({ type: 'connected' }));
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
wsCallbacks.forEach(cb => cb({ type: 'message', data }));
|
|
} catch (e) {
|
|
console.error('Failed to parse WS message:', e);
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
console.log('WebSocket disconnected');
|
|
wsCallbacks.forEach(cb => cb({ type: 'disconnected' }));
|
|
wsReconnectTimeout = setTimeout(connectWS, 5000);
|
|
};
|
|
|
|
ws.onerror = (err) => {
|
|
console.error('WebSocket error:', err);
|
|
};
|
|
} catch (e) {
|
|
console.error('Failed to connect WebSocket:', e);
|
|
wsReconnectTimeout = setTimeout(connectWS, 5000);
|
|
}
|
|
}
|
|
|
|
function subscribeWS(callback) {
|
|
wsCallbacks.add(callback);
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
connectWS();
|
|
}
|
|
return () => wsCallbacks.delete(callback);
|
|
}
|
|
|
|
function cleanupLiveViews() {
|
|
if (openclawUnsubscribe) {
|
|
openclawUnsubscribe();
|
|
openclawUnsubscribe = null;
|
|
}
|
|
if (agentsUnsubscribe) {
|
|
agentsUnsubscribe();
|
|
agentsUnsubscribe = null;
|
|
}
|
|
if (dashboardUnsubscribe) {
|
|
dashboardUnsubscribe();
|
|
dashboardUnsubscribe = null;
|
|
}
|
|
if (dashboardChart) {
|
|
dashboardChart.destroy();
|
|
dashboardChart = null;
|
|
}
|
|
if (dashboardResizeObserver) {
|
|
dashboardResizeObserver.disconnect();
|
|
dashboardResizeObserver = null;
|
|
}
|
|
}
|
|
|
|
function route() {
|
|
cleanupLiveViews();
|
|
|
|
const path = window.location.pathname;
|
|
if (path === '/') {
|
|
renderDashboard();
|
|
} else if (path === '/sessions') {
|
|
renderSessions();
|
|
} else if (path.startsWith('/agents')) {
|
|
renderAgents();
|
|
} else if (path.startsWith('/openclaw')) {
|
|
renderOpenClaw();
|
|
} else if (path.startsWith('/sessions/')) {
|
|
renderSession(path.split('/sessions/')[1]);
|
|
} else if (path.startsWith('/runs/')) {
|
|
renderRun(path.split('/runs/')[1]);
|
|
} else {
|
|
app.innerHTML = '<p>Page not found</p>';
|
|
}
|
|
}
|
|
|
|
function navigate(path) {
|
|
history.pushState(null, '', path);
|
|
route();
|
|
}
|
|
|
|
window.addEventListener('popstate', route);
|
|
|
|
async function api(path) {
|
|
const resp = await fetch('/api' + path);
|
|
if (!resp.ok) {
|
|
throw new Error('API error');
|
|
}
|
|
return resp.json();
|
|
}
|
|
|
|
function escapeHTML(value) {
|
|
return String(value ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function relativeTime(ts) {
|
|
if (!ts) {
|
|
return '-';
|
|
}
|
|
|
|
const now = Date.now();
|
|
const then = new Date(ts).getTime();
|
|
const diff = now - then;
|
|
|
|
if (diff < 60000) return 'just now';
|
|
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
|
|
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
|
|
return Math.floor(diff / 86400000) + 'd ago';
|
|
}
|
|
|
|
function formatDuration(ms) {
|
|
if (ms === undefined || ms === null || ms === '') return '-';
|
|
if (ms < 1000) return ms + 'ms';
|
|
if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
|
|
return (ms / 60000).toFixed(1) + 'm';
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (!bytes) return null;
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
let unitIndex = 0;
|
|
let value = bytes;
|
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
value /= 1024;
|
|
unitIndex++;
|
|
}
|
|
return value.toFixed(1) + ' ' + units[unitIndex];
|
|
}
|
|
|
|
function statusIcon(status) {
|
|
if (status === 'success') return '<span class="status-badge status-success"><span class="status-dot"></span>success</span>';
|
|
if (status === 'error') return '<span class="status-badge status-error"><span class="status-dot"></span>error</span>';
|
|
return '<span class="status-badge status-unknown"><span class="status-dot"></span>unknown</span>';
|
|
}
|
|
|
|
function extractEnvelope(record) {
|
|
if (record && typeof record === 'object' && record.payload && record.payload.event && record.payload.schema) {
|
|
return record.payload;
|
|
}
|
|
return record || {};
|
|
}
|
|
|
|
function getEnvelopeEvent(record) {
|
|
const envelope = extractEnvelope(record);
|
|
return envelope.event || envelope.Event || {};
|
|
}
|
|
|
|
function getEnvelopeType(record) {
|
|
return record?.type || getEnvelopeEvent(record).type || '';
|
|
}
|
|
|
|
function getEnvelopeTS(record) {
|
|
return record?.ts || getEnvelopeEvent(record).ts || '';
|
|
}
|
|
|
|
function getEnvelopeSource(record) {
|
|
return getEnvelopeEvent(record).source || {};
|
|
}
|
|
|
|
function getEnvelopePayload(record) {
|
|
const envelope = extractEnvelope(record);
|
|
return envelope.payload || envelope.Payload || {};
|
|
}
|
|
|
|
function getEnvelopeAttributes(record) {
|
|
const envelope = extractEnvelope(record);
|
|
return envelope.attributes || envelope.Attributes || {};
|
|
}
|
|
|
|
function getEnvelopeCorrelation(record) {
|
|
const envelope = extractEnvelope(record);
|
|
return envelope.correlation || envelope.Correlation || {};
|
|
}
|
|
|
|
function getRecordID(record) {
|
|
return record?.event_id || getEnvelopeEvent(record).id || '';
|
|
}
|
|
|
|
function isCurrentPath(prefix) {
|
|
return window.location.pathname.startsWith(prefix);
|
|
}
|
|
|
|
async function renderSessions() {
|
|
app.innerHTML = `
|
|
<div class="page-header">
|
|
<h2>Sessions</h2>
|
|
</div>
|
|
<div class="filters">
|
|
<label>From <input type="date" id="filter-from"></label>
|
|
<label>To <input type="date" id="filter-to"></label>
|
|
<label>Framework
|
|
<select id="filter-framework">
|
|
<option value="">All</option>
|
|
<option value="claude-code">claude-code</option>
|
|
<option value="opencode">opencode</option>
|
|
<option value="openclaw">openclaw</option>
|
|
</select>
|
|
</label>
|
|
<label>Host <input type="text" id="filter-host" placeholder="hostname"></label>
|
|
</div>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Session</th>
|
|
<th>Framework</th>
|
|
<th>Host</th>
|
|
<th>Runs</th>
|
|
<th>Time</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="sessions-body"></tbody>
|
|
</table>
|
|
</div>
|
|
<button id="load-more" class="load-more" style="display:none">Load more</button>
|
|
`;
|
|
|
|
['from', 'to', 'framework', 'host'].forEach(f => {
|
|
document.getElementById('filter-' + f).addEventListener('change', () => {
|
|
sessionsState.sessions = [];
|
|
sessionsState.cursor = null;
|
|
loadSessions();
|
|
});
|
|
});
|
|
|
|
document.getElementById('load-more').addEventListener('click', loadSessions);
|
|
|
|
sessionsState = { sessions: [], cursor: null };
|
|
await loadSessions();
|
|
}
|
|
|
|
async function loadSessions() {
|
|
const params = new URLSearchParams();
|
|
const from = document.getElementById('filter-from').value;
|
|
const to = document.getElementById('filter-to').value;
|
|
const framework = document.getElementById('filter-framework').value;
|
|
const host = document.getElementById('filter-host').value;
|
|
|
|
if (from) params.set('from', from);
|
|
if (to) params.set('to', to);
|
|
if (framework) params.set('framework', framework);
|
|
if (host) params.set('host', host);
|
|
if (sessionsState.cursor) params.set('cursor', sessionsState.cursor);
|
|
|
|
const data = await api('/v1/sessions?' + params.toString());
|
|
sessionsState.sessions = sessionsState.sessions.concat(data.sessions || []);
|
|
sessionsState.cursor = data.next_cursor;
|
|
|
|
const tbody = document.getElementById('sessions-body');
|
|
tbody.innerHTML = sessionsState.sessions.map(s => `
|
|
<tr class="clickable" data-session="${escapeHTML(s.session_id)}">
|
|
<td class="id-cell">${escapeHTML(s.session_id.substring(0, 12))}...</td>
|
|
<td>${escapeHTML(s.framework || '-')}</td>
|
|
<td>${escapeHTML(s.host || '-')}</td>
|
|
<td>${s.run_count}</td>
|
|
<td title="${escapeHTML(s.started_at)}">${escapeHTML(relativeTime(s.started_at))}</td>
|
|
</tr>
|
|
`).join('') || '<tr><td colspan="5" class="empty-state">No sessions found</td></tr>';
|
|
|
|
tbody.querySelectorAll('tr.clickable').forEach(row => {
|
|
row.addEventListener('click', () => navigate('/sessions/' + row.dataset.session));
|
|
});
|
|
|
|
document.getElementById('load-more').style.display = sessionsState.cursor ? 'block' : 'none';
|
|
}
|
|
|
|
async function renderSession(sessionID) {
|
|
const data = await api('/v1/sessions/' + sessionID);
|
|
const s = data.session;
|
|
const runs = data.runs || [];
|
|
const duration = s.ended_at
|
|
? formatDuration(new Date(s.ended_at) - new Date(s.started_at))
|
|
: 'ongoing';
|
|
|
|
app.innerHTML = `
|
|
<a href="/sessions" class="back-link">← Back to Sessions</a>
|
|
<div class="page-header">
|
|
<h2>Session <span style="font-family:var(--font-mono);font-size:1.1rem;color:var(--accent)">${escapeHTML(sessionID.substring(0, 16))}...</span></h2>
|
|
<div class="meta">
|
|
<span class="meta-item"><span class="meta-label">Started</span> ${escapeHTML(new Date(s.started_at).toLocaleString())}</span>
|
|
<span class="meta-item"><span class="meta-label">Framework</span> ${escapeHTML(s.framework || '-')}</span>
|
|
<span class="meta-item"><span class="meta-label">Host</span> ${escapeHTML(s.host || '-')}</span>
|
|
<span class="meta-item"><span class="meta-label">Duration</span> ${escapeHTML(duration)}</span>
|
|
</div>
|
|
</div>
|
|
<div class="section-title">Runs <span class="count">${runs.length}</span></div>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Run ID</th>
|
|
<th>Status</th>
|
|
<th>Spans</th>
|
|
<th>Duration</th>
|
|
<th>Started</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${runs.map(r => {
|
|
const runDuration = r.ended_at
|
|
? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
|
|
: '-';
|
|
return `
|
|
<tr class="clickable" data-run="${escapeHTML(r.run_id)}">
|
|
<td class="id-cell">${escapeHTML(r.run_id.substring(0, 12))}...</td>
|
|
<td>${statusIcon(r.status)}</td>
|
|
<td>${r.span_count}</td>
|
|
<td>${escapeHTML(runDuration)}</td>
|
|
<td>${escapeHTML(new Date(r.started_at).toLocaleTimeString())}</td>
|
|
</tr>
|
|
`;
|
|
}).join('') || '<tr><td colspan="5" class="empty-state">No runs</td></tr>'}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
|
|
document.querySelectorAll('tr.clickable').forEach(row => {
|
|
row.addEventListener('click', () => navigate('/runs/' + row.dataset.run));
|
|
});
|
|
|
|
document.querySelector('.back-link').addEventListener('click', e => {
|
|
e.preventDefault();
|
|
navigate('/sessions');
|
|
});
|
|
}
|
|
|
|
async function renderRun(runID) {
|
|
const data = await api('/v1/runs/' + runID);
|
|
const r = data.run;
|
|
const spans = data.spans || [];
|
|
const duration = r.ended_at
|
|
? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
|
|
: 'ongoing';
|
|
|
|
app.innerHTML = `
|
|
<a href="/sessions/${escapeHTML(r.session_id)}" class="back-link">← Back to Session</a>
|
|
<div class="page-header">
|
|
<h2>Run <span style="font-family:var(--font-mono);font-size:1.1rem;color:var(--accent)">${escapeHTML(runID.substring(0, 16))}...</span> ${statusIcon(r.status)}</h2>
|
|
<div class="meta">
|
|
<span class="meta-item"><span class="meta-label">Started</span> ${escapeHTML(new Date(r.started_at).toLocaleString())}</span>
|
|
<span class="meta-item"><span class="meta-label">Duration</span> ${escapeHTML(duration)}</span>
|
|
</div>
|
|
</div>
|
|
<div class="section-title">Spans <span class="count">${spans.length}</span></div>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Kind</th>
|
|
<th>Status</th>
|
|
<th>Duration</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="spans-body">
|
|
${spans.map((sp, i) => `
|
|
<tr class="expandable" data-index="${i}">
|
|
<td><span class="expand-icon">▶</span>${escapeHTML(sp.name)}</td>
|
|
<td>${escapeHTML(sp.kind)}</td>
|
|
<td>${statusIcon(sp.status)}</td>
|
|
<td>${escapeHTML(formatDuration(sp.duration_ms))}</td>
|
|
</tr>
|
|
<tr class="span-detail-row" data-index="${i}" style="display:none">
|
|
<td colspan="4">
|
|
<div class="span-details">${escapeHTML(JSON.stringify(sp.payload, null, 2))}</div>
|
|
</td>
|
|
</tr>
|
|
`).join('') || '<tr><td colspan="4" class="empty-state">No spans</td></tr>'}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
|
|
document.querySelectorAll('tr.expandable').forEach(row => {
|
|
row.addEventListener('click', () => {
|
|
const idx = row.dataset.index;
|
|
const detailRow = document.querySelector(`tr.span-detail-row[data-index="${idx}"]`);
|
|
const icon = row.querySelector('.expand-icon');
|
|
if (detailRow.style.display === 'none') {
|
|
detailRow.style.display = 'table-row';
|
|
icon.innerHTML = '▼';
|
|
} else {
|
|
detailRow.style.display = 'none';
|
|
icon.innerHTML = '▶';
|
|
}
|
|
});
|
|
});
|
|
|
|
document.querySelector('.back-link').addEventListener('click', e => {
|
|
e.preventDefault();
|
|
navigate('/sessions/' + r.session_id);
|
|
});
|
|
}
|
|
|
|
async function renderOpenClaw() {
|
|
app.innerHTML = '<div class="page-header"><h2>OpenClaw</h2></div><p class="empty-state">Loading...</p>';
|
|
|
|
openclawUnsubscribe = subscribeWS(handleOpenClawWS);
|
|
|
|
try {
|
|
const data = await api('/v1/events?event_type=openclaw.snapshot&limit=100');
|
|
mergeOpenClawEvents(data.events || []);
|
|
if (isCurrentPath('/openclaw')) {
|
|
renderOpenClawGrid();
|
|
}
|
|
} catch (e) {
|
|
if (isCurrentPath('/openclaw')) {
|
|
app.innerHTML = `<div class="page-header"><h2>OpenClaw</h2></div><p class="empty-state">Error loading: ${escapeHTML(e.message)}</p>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleOpenClawWS(msg) {
|
|
if (msg.type !== 'message') {
|
|
return;
|
|
}
|
|
|
|
if (getEnvelopeType(msg.data) !== 'openclaw.snapshot') {
|
|
return;
|
|
}
|
|
|
|
mergeOpenClawEvents([msg.data]);
|
|
|
|
if (isCurrentPath('/openclaw')) {
|
|
renderOpenClawGrid();
|
|
}
|
|
if (isCurrentPath('/agents')) {
|
|
renderAgentVMStrip();
|
|
}
|
|
}
|
|
|
|
function mergeOpenClawEvents(events) {
|
|
for (const evt of events) {
|
|
const payload = getEnvelopePayload(evt);
|
|
const instance = payload.instance || {};
|
|
if (!instance.name) {
|
|
continue;
|
|
}
|
|
|
|
const existing = openclawState.instances[instance.name];
|
|
const nextTS = new Date(getEnvelopeTS(evt) || 0).getTime();
|
|
const currentTS = existing ? new Date(getEnvelopeTS(existing) || 0).getTime() : 0;
|
|
if (!existing || nextTS >= currentTS) {
|
|
openclawState.instances[instance.name] = evt;
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderOpenClawGrid() {
|
|
const names = Object.keys(openclawState.instances).sort();
|
|
|
|
if (names.length === 0) {
|
|
app.innerHTML = `
|
|
<div class="page-header"><h2>OpenClaw</h2></div>
|
|
<p class="empty-state">No OpenClaw instances found</p>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
app.innerHTML = `
|
|
<div class="page-header">
|
|
<h2>OpenClaw <span class="live-indicator"><span class="live-dot"></span>Live</span></h2>
|
|
</div>
|
|
<div class="vm-grid">
|
|
${names.map(name => {
|
|
const evt = openclawState.instances[name];
|
|
const payload = getEnvelopePayload(evt);
|
|
const inst = payload.instance || {};
|
|
const host = payload.host || {};
|
|
const guest = payload.guest;
|
|
const issues = payload.issues;
|
|
|
|
return `
|
|
<div class="vm-card">
|
|
<div class="vm-card-header">
|
|
<h3>${escapeHTML(inst.name || name)}</h3>
|
|
<div class="vm-status ${host.state === 'running' ? 'running' : 'stopped'}">
|
|
${host.state === 'running' ? 'Running' : 'Stopped'}
|
|
</div>
|
|
</div>
|
|
<div class="vm-updated">Updated ${escapeHTML(relativeTime(getEnvelopeTS(evt)))}</div>
|
|
<table class="vm-stats">
|
|
<tr><td>Host</td><td>${escapeHTML(inst.host || '-')}</td></tr>
|
|
<tr><td>Domain</td><td>${escapeHTML(inst.domain || '-')}</td></tr>
|
|
<tr><td>vCPUs</td><td>${host.vcpus || '-'}</td></tr>
|
|
<tr><td>Memory</td><td>${escapeHTML(formatBytes(host.memory_kib ? host.memory_kib * 1024 : 0) || '-')}</td></tr>
|
|
<tr><td>Disk</td><td>${escapeHTML(formatBytes(host.disk_actual_bytes) || '-')}</td></tr>
|
|
<tr><td>Autostart</td><td>${host.autostart ? 'Yes' : 'No'}</td></tr>
|
|
${guest ? `
|
|
<tr><td>Gateway</td><td class="${guest.service_active ? 'status-success' : 'status-error'}">${guest.service_active ? 'Active' : 'Inactive'}</td></tr>
|
|
<tr><td>HTTP</td><td class="${guest.http_status === 200 ? 'status-success' : 'status-error'}">${guest.http_status || 'N/A'}</td></tr>
|
|
<tr><td>Version</td><td>${escapeHTML(guest.version || '-')}</td></tr>
|
|
<tr><td>Guest Memory</td><td>${guest.memory_percent !== undefined ? guest.memory_percent.toFixed(1) : '-'}%</td></tr>
|
|
<tr><td>Guest Disk</td><td>${guest.disk_percent !== undefined ? guest.disk_percent.toFixed(1) : '-'}%</td></tr>
|
|
<tr><td>Load</td><td>${guest.load_average !== undefined ? guest.load_average.toFixed(2) : '-'}</td></tr>
|
|
<tr><td>Uptime</td><td>${escapeHTML(guest.service_uptime || '-')}</td></tr>
|
|
` : ''}
|
|
</table>
|
|
${issues ? `
|
|
<div class="vm-issues">
|
|
${Object.entries(issues).filter(([, value]) => value).map(([key]) => `
|
|
<span class="issue ${escapeHTML(key)}">${escapeHTML(key.replace(/_/g, ' '))}</span>
|
|
`).join('')}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function createAgentsState() {
|
|
return {
|
|
events: [],
|
|
eventIDs: new Set(),
|
|
stats: {
|
|
messages: 0,
|
|
tools: 0,
|
|
errors: 0,
|
|
toolCounts: {},
|
|
},
|
|
};
|
|
}
|
|
|
|
function getVMStatus() {
|
|
const names = ['zap', 'orb', 'sun'];
|
|
return names.map(name => {
|
|
const snapshot = openclawState.instances[name];
|
|
const payload = snapshot ? getEnvelopePayload(snapshot) : {};
|
|
const host = payload.host || {};
|
|
return {
|
|
name,
|
|
active: host.state === 'running',
|
|
};
|
|
});
|
|
}
|
|
|
|
async function renderAgents() {
|
|
agentsState = createAgentsState();
|
|
|
|
app.innerHTML = `
|
|
<div class="page-header">
|
|
<h2>Agents <span class="live-indicator"><span class="live-dot"></span>Live</span></h2>
|
|
</div>
|
|
<div class="vm-strip" id="agents-vm-strip"></div>
|
|
<div class="agents-layout">
|
|
<div class="timeline" id="agents-timeline">
|
|
<p class="empty-state">Loading agent activity...</p>
|
|
</div>
|
|
<div class="stats-panel">
|
|
<div class="stat-card">
|
|
<div class="stat-card-title">Messages</div>
|
|
<div class="stat-card-value" id="stat-messages">0</div>
|
|
<div class="stat-card-sub">received and sent</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-card-title">Tool Calls</div>
|
|
<div class="stat-card-value" id="stat-tools">0</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-card-title">Errors</div>
|
|
<div class="stat-card-value" id="stat-errors">0</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-card-title">Top Tools</div>
|
|
<ul class="stat-list" id="stat-top-tools">
|
|
<li style="color:var(--text-dim);font-size:0.8rem">No data yet</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
renderAgentVMStrip();
|
|
|
|
try {
|
|
const [snapshots, events] = await Promise.all([
|
|
api('/v1/events?event_type=openclaw.snapshot&limit=100').catch(() => ({ events: [] })),
|
|
api('/v1/events?framework=openclaw&limit=200'),
|
|
]);
|
|
|
|
if (!isCurrentPath('/agents')) {
|
|
return;
|
|
}
|
|
|
|
mergeOpenClawEvents(snapshots.events || []);
|
|
renderAgentVMStrip();
|
|
addAgentEvents((events.events || []).slice().reverse());
|
|
renderAgentTimeline();
|
|
renderAgentStats();
|
|
} catch (e) {
|
|
const timeline = document.getElementById('agents-timeline');
|
|
if (timeline) {
|
|
timeline.innerHTML = `<p class="empty-state">Error loading agent activity: ${escapeHTML(e.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
agentsUnsubscribe = subscribeWS(handleAgentsWS);
|
|
}
|
|
|
|
function renderAgentVMStrip() {
|
|
const strip = document.getElementById('agents-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 handleAgentsWS(msg) {
|
|
if (msg.type !== 'message') {
|
|
return;
|
|
}
|
|
|
|
const eventType = getEnvelopeType(msg.data);
|
|
if (eventType === 'openclaw.snapshot') {
|
|
mergeOpenClawEvents([msg.data]);
|
|
renderAgentVMStrip();
|
|
return;
|
|
}
|
|
|
|
const framework = getEnvelopeSource(msg.data).framework || msg.data.source_framework;
|
|
if (framework !== 'openclaw') {
|
|
return;
|
|
}
|
|
|
|
addAgentEvents([msg.data]);
|
|
renderAgentTimeline();
|
|
renderAgentStats();
|
|
}
|
|
|
|
function addAgentEvents(events) {
|
|
let changed = false;
|
|
|
|
for (const evt of events) {
|
|
const id = getRecordID(evt);
|
|
if (!id || agentsState.eventIDs.has(id)) {
|
|
continue;
|
|
}
|
|
agentsState.eventIDs.add(id);
|
|
agentsState.events.push(evt);
|
|
changed = true;
|
|
}
|
|
|
|
if (!changed) {
|
|
return;
|
|
}
|
|
|
|
agentsState.events.sort((a, b) => new Date(getEnvelopeTS(a)).getTime() - new Date(getEnvelopeTS(b)).getTime());
|
|
|
|
while (agentsState.events.length > 500) {
|
|
const removed = agentsState.events.shift();
|
|
agentsState.eventIDs.delete(getRecordID(removed));
|
|
}
|
|
|
|
recomputeAgentStats();
|
|
}
|
|
|
|
function recomputeAgentStats() {
|
|
const stats = {
|
|
messages: 0,
|
|
tools: 0,
|
|
errors: 0,
|
|
toolCounts: {},
|
|
};
|
|
|
|
for (const evt of agentsState.events) {
|
|
const eventType = getEnvelopeType(evt);
|
|
const attrs = getEnvelopeAttributes(evt);
|
|
|
|
if (eventType === 'run.start' || eventType === 'run.end') {
|
|
stats.messages++;
|
|
}
|
|
|
|
if (eventType === 'span.end' && attrs.span_kind === 'tool') {
|
|
stats.tools++;
|
|
const toolName = attrs.name || 'unknown';
|
|
stats.toolCounts[toolName] = (stats.toolCounts[toolName] || 0) + 1;
|
|
}
|
|
|
|
if (eventType === 'error') {
|
|
stats.errors++;
|
|
}
|
|
}
|
|
|
|
agentsState.stats = stats;
|
|
}
|
|
|
|
function getEventIcon(eventType) {
|
|
switch (eventType) {
|
|
case 'run.start':
|
|
return '<div class="event-icon message-in">↓</div>';
|
|
case 'run.end':
|
|
return '<div class="event-icon message-out">↑</div>';
|
|
case 'span.start':
|
|
case 'span.end':
|
|
return '<div class="event-icon tool">⚙</div>';
|
|
case 'error':
|
|
return '<div class="event-icon error">!</div>';
|
|
case 'session.start':
|
|
case 'session.end':
|
|
return '<div class="event-icon session">○</div>';
|
|
default:
|
|
return '<div class="event-icon internal">·</div>';
|
|
}
|
|
}
|
|
|
|
function getEventLabel(eventType) {
|
|
const labels = {
|
|
'session.start': 'Session Started',
|
|
'session.end': 'Session Ended',
|
|
'run.start': 'Message Received',
|
|
'run.end': 'Response Sent',
|
|
'span.start': 'Span Started',
|
|
'span.end': 'Span Completed',
|
|
'error': 'Error',
|
|
'metric.snapshot': 'Metric',
|
|
};
|
|
return labels[eventType] || eventType;
|
|
}
|
|
|
|
function getVMName(evt) {
|
|
return getEnvelopeSource(evt).client_id || evt.client_id || 'unknown';
|
|
}
|
|
|
|
function getVMClassName(vmName) {
|
|
const normalized = String(vmName || 'unknown').toLowerCase();
|
|
return ['zap', 'orb', 'sun'].includes(normalized) ? normalized : 'unknown';
|
|
}
|
|
|
|
function getEventBody(evt) {
|
|
const eventType = getEnvelopeType(evt);
|
|
const payload = getEnvelopePayload(evt);
|
|
const attrs = getEnvelopeAttributes(evt);
|
|
const correlation = getEnvelopeCorrelation(evt);
|
|
|
|
if (eventType === 'span.start' || eventType === 'span.end') {
|
|
const name = attrs.name || attrs.span_kind || 'unknown span';
|
|
const duration = payload.duration_ms !== undefined && payload.duration_ms !== null
|
|
? ` <span class="timeline-duration">${escapeHTML(formatDuration(payload.duration_ms))}</span>`
|
|
: '';
|
|
return `<div class="timeline-event-body tool-name">${escapeHTML(name)}${duration}</div>`;
|
|
}
|
|
|
|
if (eventType === 'run.start') {
|
|
const preview = payload.message_preview || payload.message || '';
|
|
if (!preview) {
|
|
return '';
|
|
}
|
|
const trimmed = preview.length > 140 ? preview.slice(0, 140) + '...' : preview;
|
|
return `<div class="timeline-event-body message-preview">"${escapeHTML(trimmed)}"</div>`;
|
|
}
|
|
|
|
if (eventType === 'run.end') {
|
|
return `<div class="timeline-event-body">${statusIcon(payload.status || 'unknown')}</div>`;
|
|
}
|
|
|
|
if (eventType === 'error') {
|
|
const errPayload = payload.error || {};
|
|
const errType = errPayload.type || 'error';
|
|
const message = errPayload.message || payload.message || 'unknown';
|
|
return `<div class="timeline-event-body error-message">${escapeHTML(errType + ': ' + message)}</div>`;
|
|
}
|
|
|
|
if (eventType === 'session.start' || eventType === 'session.end') {
|
|
return correlation.session_id
|
|
? `<div class="timeline-event-body">session ${escapeHTML(correlation.session_id)}</div>`
|
|
: '';
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function getEventDetails(evt) {
|
|
const details = {};
|
|
const correlation = getEnvelopeCorrelation(evt);
|
|
const attributes = getEnvelopeAttributes(evt);
|
|
const payload = getEnvelopePayload(evt);
|
|
|
|
if (Object.keys(correlation).length > 0) {
|
|
details.correlation = correlation;
|
|
}
|
|
if (Object.keys(attributes).length > 0) {
|
|
details.attributes = attributes;
|
|
}
|
|
if (Object.keys(payload).length > 0) {
|
|
details.payload = payload;
|
|
}
|
|
|
|
if (Object.keys(details).length === 0) {
|
|
return '';
|
|
}
|
|
|
|
return JSON.stringify(details, null, 2);
|
|
}
|
|
|
|
function renderAgentTimeline() {
|
|
const timeline = document.getElementById('agents-timeline');
|
|
if (!timeline) {
|
|
return;
|
|
}
|
|
|
|
const recent = agentsState.events.slice(-100).reverse();
|
|
if (recent.length === 0) {
|
|
timeline.innerHTML = '<p class="empty-state">Waiting for agent activity...</p>';
|
|
return;
|
|
}
|
|
|
|
timeline.innerHTML = recent.map((evt, index) => {
|
|
const eventType = getEnvelopeType(evt);
|
|
const vmName = getVMName(evt);
|
|
const vmClass = getVMClassName(vmName);
|
|
const details = getEventDetails(evt);
|
|
const detailHTML = details ? `<div class="timeline-detail">${escapeHTML(details)}</div>` : '';
|
|
const expandHTML = details ? '<button class="timeline-expand-hint" type="button">details</button>' : '';
|
|
|
|
return `
|
|
<div class="timeline-event" data-index="${index}">
|
|
<div class="timeline-event-header">
|
|
${getEventIcon(eventType)}
|
|
<span class="timeline-vm-tag ${vmClass}">${escapeHTML(vmName)}</span>
|
|
<span class="timeline-event-type">${escapeHTML(getEventLabel(eventType))}</span>
|
|
<span class="timeline-event-time">${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())}</span>
|
|
</div>
|
|
${getEventBody(evt)}
|
|
${expandHTML}
|
|
${detailHTML}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
timeline.querySelectorAll('.timeline-expand-hint').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
button.parentElement.classList.toggle('expanded');
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderAgentStats() {
|
|
const stats = agentsState.stats;
|
|
|
|
const messagesEl = document.getElementById('stat-messages');
|
|
if (messagesEl) {
|
|
messagesEl.textContent = String(stats.messages);
|
|
}
|
|
|
|
const toolsEl = document.getElementById('stat-tools');
|
|
if (toolsEl) {
|
|
toolsEl.textContent = String(stats.tools);
|
|
}
|
|
|
|
const errorsEl = document.getElementById('stat-errors');
|
|
if (errorsEl) {
|
|
errorsEl.textContent = String(stats.errors);
|
|
}
|
|
|
|
const list = document.getElementById('stat-top-tools');
|
|
if (!list) {
|
|
return;
|
|
}
|
|
|
|
const topTools = Object.entries(stats.toolCounts)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 8);
|
|
|
|
if (topTools.length === 0) {
|
|
list.innerHTML = '<li style="color:var(--text-dim);font-size:0.8rem">No 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('');
|
|
}
|
|
|
|
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 === 'session.start') dashboardState.summary.active_sessions++;
|
|
if (eventType === 'session.end') dashboardState.summary.active_sessions = Math.max(0, dashboardState.summary.active_sessions - 1);
|
|
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);
|
|
|
|
if (dashboardResizeObserver) {
|
|
dashboardResizeObserver.disconnect();
|
|
}
|
|
dashboardResizeObserver = new ResizeObserver(entries => {
|
|
for (const entry of entries) {
|
|
if (dashboardChart) {
|
|
dashboardChart.setSize({ width: entry.contentRect.width, height: 200 });
|
|
}
|
|
}
|
|
});
|
|
dashboardResizeObserver.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.toLowerCase().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();
|
|
})();
|