(function() {
// ── Theme toggle ─────────────────────────────────────────
const THEME_CYCLE = ['system', 'light', 'dark'];
const THEME_ICONS = {
system: ' ',
light: ' ',
dark: ' ',
};
const THEME_LABELS = { system: 'System theme', light: 'Light theme', dark: 'Dark theme' };
function getTheme() { return localStorage.getItem('theme') || 'system'; }
function applyTheme(theme) {
if (theme === 'system') document.documentElement.removeAttribute('data-theme');
else document.documentElement.setAttribute('data-theme', theme);
}
function updateToggleBtn(theme) {
const btn = document.getElementById('theme-toggle');
if (!btn) return;
btn.innerHTML = THEME_ICONS[theme];
btn.title = THEME_LABELS[theme];
}
function cycleTheme() {
const next = THEME_CYCLE[(THEME_CYCLE.indexOf(getTheme()) + 1) % THEME_CYCLE.length];
if (next === 'system') localStorage.removeItem('theme');
else localStorage.setItem('theme', next);
applyTheme(next);
updateToggleBtn(next);
}
document.addEventListener('DOMContentLoaded', function() {
updateToggleBtn(getTheme());
const btn = document.getElementById('theme-toggle');
if (btn) btn.addEventListener('click', cycleTheme);
});
// ─────────────────────────────────────────────────────────
const app = document.getElementById('app');
let ws = null;
let wsReconnectTimeout = null;
const wsCallbacks = new Set();
let sessionsState = { sessions: [], cursor: null };
let sessionsUnsubscribe = null;
let openclawState = { instances: {} };
let openclawUnsubscribe = null;
let infraUnsubscribe = null;
let swarmState = { services: {} }; // keyed by service name
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 (infraUnsubscribe) {
infraUnsubscribe();
infraUnsubscribe = null;
}
if (agentsUnsubscribe) {
agentsUnsubscribe();
agentsUnsubscribe = null;
}
if (sessionsState && sessionsState.timerInterval) {
clearInterval(sessionsState.timerInterval);
sessionsState.timerInterval = null;
}
if (sessionsUnsubscribe) {
sessionsUnsubscribe();
sessionsUnsubscribe = null;
}
if (dashboardUnsubscribe) {
dashboardUnsubscribe();
dashboardUnsubscribe = null;
}
if (dashboardChart) {
dashboardChart.destroy();
dashboardChart = null;
}
if (dashboardResizeObserver) {
dashboardResizeObserver.disconnect();
dashboardResizeObserver = null;
}
if (agentsState && agentsState.timerInterval) {
clearInterval(agentsState.timerInterval);
agentsState.timerInterval = 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('/infrastructure')) {
renderInfrastructure();
} else if (path.startsWith('/sessions/')) {
renderSession(path.split('/sessions/')[1]);
} else if (path.startsWith('/runs/')) {
renderRun(path.split('/runs/')[1]);
} else {
app.innerHTML = '
Page not found
';
}
updateActiveNav();
}
function navigate(path) {
history.pushState(null, '', path);
route();
}
function updateActiveNav() {
const path = window.location.pathname;
document.querySelectorAll('header nav a').forEach(a => {
const href = a.getAttribute('href');
const isActive = href === '/'
? path === '/'
: path.startsWith(href);
a.classList.toggle('active', isActive);
});
}
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 tryParseJSON(s) { try { return s ? JSON.parse(s) : null; } catch { return null; } }
function escapeHTML(value) {
return String(value ?? '')
.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 ' success ';
if (status === 'error') return ' error ';
return ' unknown ';
}
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 = `
From
To
Framework
All
claude-code
opencode
openclaw
Host
Session
Framework
Host
Runs
Time
Load more
`;
['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, timerInterval: null };
await loadSessions();
sessionsState.timerInterval = setInterval(updateSessionTimers, 30000);
sessionsUnsubscribe = subscribeWS(handleSessionsWS);
}
function isSessionActive(s) { return !s.ended_at; }
function renderSessionRow(s) {
const fw = s.framework || 'unknown';
const fwClass = fw.replace(/[^a-z0-9-]/g, '-');
const active = isSessionActive(s);
const dotState = active ? 'active' : 'ended';
const dotTitle = active ? 'Active session' : 'Session ended';
return `
${escapeHTML(s.session_id.substring(0, 12))}…
${escapeHTML(fw)}
${escapeHTML(s.host || '-')}
${s.run_count}
${escapeHTML(relativeTime(s.started_at))}
`;
}
function updateSessionTimers() {
const tbody = document.getElementById('sessions-body');
if (!tbody) return;
sessionsState.sessions.forEach(s => {
const row = tbody.querySelector(`[data-session="${s.session_id}"]`);
if (row) {
const td = row.cells[4];
if (td) td.textContent = relativeTime(s.started_at);
}
});
}
function handleSessionsWS(msg) {
if (msg.type !== 'message') return;
const eventType = getEnvelopeType(msg.data);
const correlation = getEnvelopeCorrelation(msg.data);
const sessionId = correlation?.session_id || msg.data.event?.id;
if (eventType === 'session.start') {
const source = msg.data.event?.source;
const newSession = {
session_id: sessionId,
started_at: msg.data.event?.ts,
framework: source?.framework || 'unknown',
host: source?.host || '-',
run_count: 1,
active: true,
};
sessionsState.sessions.unshift(newSession);
const tbody = document.getElementById('sessions-body');
if (tbody) {
const row = tbody.insertRow(0);
row.className = 'clickable active';
row.dataset.session = newSession.session_id;
row.innerHTML = renderSessionRow(newSession);
row.addEventListener('click', () => navigate('/sessions/' + row.dataset.session));
}
}
if (eventType === 'run.start' && sessionId) {
const session = sessionsState.sessions.find(s => s.session_id === sessionId);
if (session) {
session.run_count = (session.run_count || 0) + 1;
const tbody = document.getElementById('sessions-body');
if (tbody) {
const row = tbody.querySelector(`[data-session="${sessionId}"]`);
if (row) row.cells[3].textContent = session.run_count;
}
}
}
if (eventType === 'session.end' && sessionId) {
const session = sessionsState.sessions.find(s => s.session_id === sessionId);
if (session) {
session.ended_at = new Date().toISOString();
const tbody = document.getElementById('sessions-body');
if (tbody) {
const row = tbody.querySelector(`[data-session="${sessionId}"]`);
if (row) {
const dot = row.querySelector('.fw-dot');
dot.classList.remove('active');
dot.classList.add('ended');
}
}
}
}
}
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 => {
const fw = s.framework || 'unknown';
const fwClass = fw.replace(/[^a-z0-9-]/g, '-');
const active = isSessionActive(s);
const dotState = active ? 'active' : 'ended';
const dotTitle = active ? 'Active session' : 'Session ended';
return `
${escapeHTML(s.session_id.substring(0, 12))}…
${escapeHTML(fw)}
${escapeHTML(s.host || '-')}
${s.run_count}
${escapeHTML(relativeTime(s.started_at))}
`;
}).join('') || 'No sessions found ';
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 active = !s.ended_at;
const duration = s.ended_at
? formatDuration(new Date(s.ended_at) - new Date(s.started_at))
: 'ongoing';
app.innerHTML = `
← Back to Sessions
Runs ${runs.length}
Run ID
Status
Model
Tools
Spans
Duration
Started
${renderSessionRunsRows(runs)}
`;
bindSessionRunRows();
document.querySelector('.back-link').addEventListener('click', e => {
e.preventDefault();
navigate('/sessions');
});
sessionsUnsubscribe = subscribeWS((msg) => handleSessionWS(sessionID, msg));
}
function handleSessionWS(sessionID, msg) {
if (msg.type !== 'message') return;
const correlation = getEnvelopeCorrelation(msg.data);
if (correlation?.session_id !== sessionID) return;
loadSessionData(sessionID);
}
async function loadSessionData(sessionID) {
if (!isCurrentPath('/sessions/' + sessionID)) return;
const data = await api('/v1/sessions/' + sessionID);
const runs = data.runs || [];
const tbody = document.getElementById('session-runs-body');
if (!tbody) return;
tbody.innerHTML = renderSessionRunsRows(runs);
bindSessionRunRows();
const countSpan = document.querySelector('.section-title .count');
if (countSpan) countSpan.textContent = runs.length;
}
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 = `
← Back to Session
Spans ${spans.length}
Name
Kind
Status
Duration
${spans.map((sp, i) => `
${escapeHTML(sp.name)}
${escapeHTML(sp.kind)}
${statusIcon(sp.status)}
${escapeHTML(formatDuration(sp.duration_ms))}
${escapeHTML(JSON.stringify(sp.payload, null, 2))}
`).join('') || 'No spans '}
`;
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.style.transform = 'rotate(45deg)';
} else {
detailRow.style.display = 'none';
icon.style.transform = '';
}
});
});
document.querySelector('.back-link').addEventListener('click', e => {
e.preventDefault();
navigate('/sessions/' + r.session_id);
});
}
function renderSessionRunsRows(runs) {
if (!runs || runs.length === 0) {
return 'No runs ';
}
return runs.map((r, i) => {
const runDuration = r.ended_at
? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
: '-';
const modelLabel = r.model ? escapeHTML(r.model.replace(/^claude-/, '')) : '-';
const spans = r.spans || [];
const spansHTML = spans.length > 0 ? `
${spans.map(sp => {
const body = getSessionSpanSummary(sp);
return `
${escapeHTML(sp.name || sp.kind || 'span')}
${escapeHTML(body)}
`;
}).join('')}
` : 'No spans yet
';
return `
${escapeHTML(r.run_id.substring(0, 12))}...
${statusIcon(r.status)}
${modelLabel}
${r.tool_count || 0}
${r.span_count}
${escapeHTML(runDuration)}
${escapeHTML(new Date(r.started_at).toLocaleTimeString())}
Spans ${spans.length}
${spansHTML}
`;
}).join('');
}
function getSessionSpanSummary(sp) {
const payload = sp.payload || {};
const innerPayload = payload.payload || {};
if (sp.kind === 'tool') {
const result = innerPayload.result_preview || '';
const duration = sp.duration_ms !== undefined && sp.duration_ms !== null ? formatDuration(sp.duration_ms) : '-';
return result ? `${duration} · ${String(result).slice(0, 80)}` : duration;
}
if (sp.kind === 'agent') {
const usage = innerPayload.usage || {};
const totalTokens = usage.total_tokens !== undefined ? `${usage.total_tokens} tok` : '';
const duration = sp.duration_ms !== undefined && sp.duration_ms !== null ? formatDuration(sp.duration_ms) : '-';
return totalTokens ? `${duration} · ${totalTokens}` : duration;
}
return sp.duration_ms !== undefined && sp.duration_ms !== null ? formatDuration(sp.duration_ms) : '-';
}
function bindSessionRunRows() {
document.querySelectorAll('tr.expandable-run').forEach(row => {
row.addEventListener('click', event => {
if (event.metaKey || event.ctrlKey) {
navigate('/runs/' + row.dataset.run);
return;
}
const idx = row.dataset.index;
const detailRow = document.querySelector(`tr.span-detail-row[data-index="${idx}"]`);
const icon = row.querySelector('.expand-icon');
if (!detailRow) return;
if (detailRow.style.display === 'none') {
detailRow.style.display = 'table-row';
if (icon) icon.style.transform = 'rotate(45deg)';
} else {
detailRow.style.display = 'none';
if (icon) icon.style.transform = '';
}
});
row.addEventListener('dblclick', () => navigate('/runs/' + row.dataset.run));
});
}
async function renderInfrastructure() {
app.innerHTML = 'Loading...
';
infraUnsubscribe = subscribeWS(handleInfraWS);
try {
const [ocData, swarmData] = await Promise.all([
api('/v1/events?event_type=openclaw.snapshot&limit=100'),
api('/v1/events?event_type=swarm.snapshot&limit=10').catch(() => ({ events: [] })),
]);
mergeOpenClawEvents(ocData.events || []);
for (const evt of swarmData.events || []) mergeSwarmSnapshot(evt);
if (isCurrentPath('/infrastructure')) {
renderInfraGrid();
}
} catch (e) {
if (isCurrentPath('/infrastructure')) {
app.innerHTML = `Error: ${escapeHTML(e.message)}
`;
}
}
}
function handleInfraWS(msg) {
if (msg.type !== 'message') return;
const eventType = getEnvelopeType(msg.data);
if (eventType === 'openclaw.snapshot') {
mergeOpenClawEvents([msg.data]);
if (isCurrentPath('/infrastructure')) renderInfraGrid();
if (isCurrentPath('/agents')) renderAgentVMStrip();
return;
}
if (eventType === 'swarm.snapshot') {
mergeSwarmSnapshot(msg.data);
if (isCurrentPath('/infrastructure')) renderInfraGrid();
renderSwarmStrip_dash();
return;
}
if (eventType === 'swarm.service.snapshot') {
mergeSwarmServiceSnapshot(msg.data);
if (isCurrentPath('/infrastructure')) renderInfraGrid();
renderSwarmStrip_dash();
return;
}
}
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 mergeSwarmSnapshot(evt) {
const payload = getEnvelopePayload(evt);
const services = payload.services || [];
for (const svc of services) {
if (svc.name) swarmState.services[svc.name] = svc;
}
}
function mergeSwarmServiceSnapshot(evt) {
const payload = getEnvelopePayload(evt);
const svc = payload.service;
if (svc && svc.name) swarmState.services[svc.name] = svc;
}
function renderInfraGrid() {
const vmNames = Object.keys(openclawState.instances).sort();
const allServices = Object.values(swarmState.services);
const agentmonServices = allServices.filter(s => s.group === 'agentmon');
const swarmServices = allServices.filter(s => s.group !== 'agentmon');
app.innerHTML = `
VMs
${vmNames.length === 0
? '
No VM data
'
: `
${vmNames.map(name => renderVMCard(name)).join('')}
`
}
Swarm Services
${swarmServices.length === 0
? '
No swarm service data
'
: `
${swarmServices.map(svc => renderServiceCard(svc)).join('')}
`
}
Agentmon
${agentmonServices.length === 0
? '
No agentmon service data
'
: `
${agentmonServices.map(svc => renderServiceCard(svc)).join('')}
`
}
`;
}
function renderVMCard(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 `
Updated ${escapeHTML(relativeTime(getEnvelopeTS(evt)))}
Host ${escapeHTML(inst.host || '-')}
Domain ${escapeHTML(inst.domain || '-')}
vCPUs ${host.vcpus || '-'}
Memory ${escapeHTML(formatBytes(host.memory_kib ? host.memory_kib * 1024 : 0) || '-')}
Disk ${escapeHTML(formatBytes(host.disk_actual_bytes) || '-')}
Autostart ${host.autostart ? 'Yes' : 'No'}
${guest ? `
Gateway ${guest.service_active ? 'Active' : 'Inactive'}
HTTP ${guest.http_status || 'N/A'}
Version ${escapeHTML(guest.version || '-')}
Guest Mem ${guest.memory_percent !== undefined ? guest.memory_percent.toFixed(1) : '-'}%
Guest Disk ${guest.disk_percent !== undefined ? guest.disk_percent.toFixed(1) : '-'}%
Load ${guest.load_average !== undefined ? guest.load_average.toFixed(2) : '-'}
Uptime ${escapeHTML(guest.service_uptime || '-')}
` : ''}
${issues && Object.values(issues).some(Boolean) ? `
Issues
${Object.entries(issues).filter(([, value]) => value).map(([key]) => `
${escapeHTML(key.replace(/_/g, ' '))}
`).join('')}
` : ''}
`;
}
function renderServiceCard(svc) {
const role = svc.role || 'unknown';
switch (role) {
case 'llm-proxy': return renderLLMProxyCard(svc);
case 'db': return renderDBCard(svc);
case 'search': return renderSearchCard(svc);
case 'mcp': return renderMCPCard(svc);
case 'voice': return renderVoiceCard(svc);
case 'automation':return renderAutomationCard(svc);
case 'api':
case 'web': return renderAPICard(svc);
case 'worker':
case 'queue': return renderWorkerCard(svc);
default: return renderGenericServiceCard(svc);
}
}
function serviceCardHeader(svc) {
return `
`;
}
function serviceStatRow(label, value, valueClass) {
return `
${escapeHTML(label)}
${value}
`;
}
function formatUptime(sec) {
if (!sec) return '-';
if (sec < 60) return sec + 's';
if (sec < 3600) return Math.floor(sec / 60) + 'm';
if (sec < 86400) return Math.floor(sec / 3600) + 'h ' + Math.floor((sec % 3600) / 60) + 'm';
return Math.floor(sec / 86400) + 'd ' + Math.floor((sec % 86400) / 3600) + 'h';
}
function renderLLMProxyCard(svc) {
const extra = svc.extra || {};
const modelCount = extra.model_count;
const cooldowns = extra.cooldown_count || 0;
const httpStatus = svc.http_status;
const httpClass = httpStatus === 200 ? 'ok' : httpStatus ? 'bad' : '';
return `
${serviceCardHeader(svc)}
${modelCount !== undefined ? modelCount : '-'}
models
${cooldowns > 0 ? `
⚠ ${cooldowns} model${cooldowns > 1 ? 's' : ''} in cooldown
` : ''}
${serviceStatRow('HTTP', httpStatus ? String(httpStatus) : '-', httpClass)}
${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')}
`;
}
function renderDBCard(svc) {
const healthClass = svc.health_state === 'healthy' ? 'ok' : svc.health_state === 'unhealthy' ? 'bad' : '';
return `
${serviceCardHeader(svc)}
${serviceStatRow('Health', escapeHTML(svc.health_state || 'none'), healthClass)}
${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')}
`;
}
function renderSearchCard(svc) {
const extra = svc.extra || {};
const ms = extra.response_ms;
const httpStatus = svc.http_status;
const httpClass = httpStatus === 200 ? 'ok' : httpStatus ? 'bad' : '';
return `
${serviceCardHeader(svc)}
${serviceStatRow('HTTP', httpStatus ? String(httpStatus) : '-', httpClass)}
${ms !== undefined ? serviceStatRow('Response', ms + 'ms', ms < 500 ? 'ok' : 'warn') : ''}
${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
`;
}
function renderMCPCard(svc) {
const extra = svc.extra || {};
const reachable = extra.port_reachable;
return `
${serviceCardHeader(svc)}
${reachable !== undefined ? serviceStatRow('Port', reachable ? 'reachable' : 'unreachable', reachable ? 'ok' : 'bad') : ''}
${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')}
${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
`;
}
function renderVoiceCard(svc) {
const healthClass = svc.health_state === 'healthy' ? 'ok' : svc.health_state === 'unhealthy' ? 'bad' : '';
return `
${serviceCardHeader(svc)}
${serviceStatRow('Health', escapeHTML(svc.health_state || 'none'), healthClass)}
${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')}
${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
`;
}
function renderAutomationCard(svc) {
const healthClass = svc.health_state === 'healthy' ? 'ok' : svc.health_state === 'unhealthy' ? 'bad' : '';
return `
${serviceCardHeader(svc)}
${serviceStatRow('Health', escapeHTML(svc.health_state || 'none'), healthClass)}
${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')}
${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
`;
}
function renderAPICard(svc) {
const httpStatus = svc.http_status;
const httpClass = httpStatus === 200 ? 'ok' : httpStatus ? 'bad' : '';
return `
${serviceCardHeader(svc)}
${serviceStatRow('HTTP', httpStatus ? String(httpStatus) : '-', httpClass)}
${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')}
`;
}
function renderWorkerCard(svc) {
return `
${serviceCardHeader(svc)}
${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')}
${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
`;
}
function renderGenericServiceCard(svc) {
return `
${serviceCardHeader(svc)}
${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')}
${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
`;
}
function createAgentsState() {
return {
agents: {},
stats: { messages: 0, tools: 0, errors: 0, toolCounts: {} },
dbStats: { messages: 0, tools: 0, errors: 0 },
viewMode: 'overview',
selectedAgentKey: '',
timerInterval: null,
};
}
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',
};
});
}
function normalizeAgentKey(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-');
}
function getAgentIdentity(evt) {
const source = getEnvelopeSource(evt);
const correlation = getEnvelopeCorrelation(evt);
const framework = source.framework || evt.source_framework || 'unknown';
const host = source.host || '';
const clientID = source.client_id || '';
const sessionID = correlation.session_id || '';
const name = clientID || host || framework || sessionID || 'unknown';
const key = normalizeAgentKey(clientID || host || sessionID || framework || 'unknown');
return {
key,
name,
framework,
host,
clientID,
sessionID,
};
}
function ensureAgentBucket(evt) {
const identity = getAgentIdentity(evt);
if (!identity.key) {
return null;
}
if (!agentsState.agents[identity.key]) {
agentsState.agents[identity.key] = {
key: identity.key,
name: identity.name,
framework: identity.framework,
host: identity.host,
clientID: identity.clientID,
sessions: {},
operations: {},
events: [],
eventIDs: new Set(),
lastSeenAt: 0,
liveLoaded: false,
liveLoading: false,
};
}
const agent = agentsState.agents[identity.key];
agent.name = identity.name || agent.name || identity.key;
agent.framework = identity.framework || agent.framework;
agent.host = identity.host || agent.host;
agent.clientID = identity.clientID || agent.clientID;
return agent;
}
function getSortedAgentKeys() {
return Object.keys(agentsState.agents).sort((a, b) => {
const left = agentsState.agents[a];
const right = agentsState.agents[b];
const leftOnline = isAgentOnline(left);
const rightOnline = isAgentOnline(right);
if (leftOnline !== rightOnline) {
return leftOnline ? -1 : 1;
}
return (left.name || left.key).localeCompare(right.name || right.key);
});
}
function ensureSelectedAgentKey() {
const keys = getSortedAgentKeys();
if (keys.length === 0) {
agentsState.selectedAgentKey = '';
return '';
}
if (!agentsState.selectedAgentKey || !agentsState.agents[agentsState.selectedAgentKey]) {
agentsState.selectedAgentKey = keys[0];
}
return agentsState.selectedAgentKey;
}
function setAgentsViewMode(mode) {
agentsState.viewMode = mode === 'live' ? 'live' : 'overview';
renderAgentsContent();
if (agentsState.viewMode === 'live') {
void loadSelectedAgentLiveData();
}
}
function selectAgent(key, nextMode) {
if (!key || !agentsState.agents[key]) return;
agentsState.selectedAgentKey = key;
if (nextMode) {
agentsState.viewMode = nextMode;
}
renderAgentsContent();
if (agentsState.viewMode === 'live') {
void loadSelectedAgentLiveData();
}
}
function isOpenClawVM(agent) {
const key = normalizeAgentKey(agent && agent.name);
return ['zap', 'orb', 'sun'].includes(key);
}
function isAgentOnline(agent) {
if (!agent) {
return false;
}
if (isOpenClawVM(agent)) {
const vmStatus = getVMStatus().find(v => v.name === normalizeAgentKey(agent.name));
if (vmStatus) {
return vmStatus.active;
}
}
const hasSessions = Object.keys(agent.sessions).length > 0;
const hasOps = Object.keys(agent.operations).length > 0;
const seenRecently = agent.lastSeenAt > 0 && (Date.now() - agent.lastSeenAt) < 300000;
return hasSessions || hasOps || seenRecently;
}
function getAgentBucket(evt) {
return ensureAgentBucket(evt);
}
function processAgentEvent(evt) {
const agent = getAgentBucket(evt);
if (!agent) return;
const eventType = getEnvelopeType(evt);
const correlation = getEnvelopeCorrelation(evt);
const attrs = getEnvelopeAttributes(evt);
const ts = new Date(getEnvelopeTS(evt)).getTime();
agent.lastSeenAt = Number.isFinite(ts) ? ts : Date.now();
if (eventType === 'session.start' && correlation.session_id) {
agent.sessions[correlation.session_id] = { ts: getEnvelopeTS(evt) };
}
if (eventType === 'session.end' && correlation.session_id) {
delete agent.sessions[correlation.session_id];
}
if (eventType === 'span.start' && correlation.span_id) {
agent.operations['s:' + correlation.span_id] = {
type: 'span',
name: attrs.name || attrs.span_kind || 'unknown',
kind: attrs.span_kind || '',
subType: attrs.type || '',
startedAt: new Date(getEnvelopeTS(evt)).getTime() || Date.now(),
};
}
if (eventType === 'span.end' && correlation.span_id) {
delete agent.operations['s:' + correlation.span_id];
}
if (eventType === 'run.start' && correlation.run_id) {
agent.operations['r:' + correlation.run_id] = {
type: 'run',
name: 'Thinking…',
kind: 'run',
startedAt: new Date(getEnvelopeTS(evt)).getTime() || Date.now(),
};
}
if (eventType === 'run.end' && correlation.run_id) {
delete agent.operations['r:' + correlation.run_id];
}
const id = getRecordID(evt);
if (id && !agent.eventIDs.has(id)) {
agent.eventIDs.add(id);
agent.events.push(evt);
while (agent.events.length > 100) {
const removed = agent.events.shift();
agent.eventIDs.delete(getRecordID(removed));
}
}
}
function getAgentDisplayOps(agent) {
const now = Date.now();
const ops = Object.values(agent.operations).filter(op => (now - op.startedAt) < 300000);
const hasSpecificSpans = ops.some(op => op.kind && op.kind !== 'run');
return hasSpecificSpans ? ops.filter(op => op.kind && op.kind !== 'run') : ops;
}
function isAgentTimelineEvent(evt) {
const eventType = getEnvelopeType(evt);
return [
'session.start',
'session.end',
'run.start',
'run.end',
'span.start',
'span.end',
'error',
].includes(eventType);
}
async function renderAgents() {
agentsState = createAgentsState();
app.innerHTML = `
`;
bindAgentViewToggle();
try {
const [snapshots, events, summaryData] = await Promise.all([
api('/v1/events?event_type=openclaw.snapshot&limit=100').catch(() => ({ events: [] })),
api('/v1/events?limit=300'),
api('/v1/stats/summary').catch(() => null),
]);
if (!isCurrentPath('/agents')) return;
if (summaryData) {
agentsState.dbStats.messages = summaryData.runs_today || 0;
agentsState.dbStats.tools = summaryData.tool_calls_today || 0;
agentsState.dbStats.errors = summaryData.errors_today || 0;
}
mergeOpenClawEvents(snapshots.events || []);
addAgentEvents((events.events || []).filter(isAgentTimelineEvent).slice().reverse());
renderAgentsContent();
} catch (e) {
document.getElementById('agents-content').innerHTML =
`Error loading agent activity: ${escapeHTML(e.message)}
`;
}
agentsState.timerInterval = setInterval(updateAgentTimers, 1000);
agentsUnsubscribe = subscribeWS(handleAgentsWS);
}
function bindAgentViewToggle() {
const root = document.getElementById('agents-view-toggle');
if (!root) return;
root.querySelectorAll('[data-mode]').forEach(button => {
button.addEventListener('click', () => {
setAgentsViewMode(button.dataset.mode || 'overview');
});
});
}
function updateAgentViewToggle() {
const root = document.getElementById('agents-view-toggle');
if (!root) return;
root.querySelectorAll('[data-mode]').forEach(button => {
button.classList.toggle('active', button.dataset.mode === agentsState.viewMode);
});
}
function renderAgentsContent() {
renderAgentSummary();
updateAgentViewToggle();
if (agentsState.viewMode === 'live') {
renderAgentsLive();
return;
}
renderAgentLanes();
}
async function loadSelectedAgentLiveData() {
const selectedKey = ensureSelectedAgentKey();
if (!selectedKey) return;
const agent = agentsState.agents[selectedKey];
if (!agent || agent.liveLoaded || agent.liveLoading || !agent.clientID || !agent.framework) {
return;
}
agent.liveLoading = true;
try {
const params = new URLSearchParams();
params.set('client_id', agent.clientID);
params.set('framework', agent.framework);
params.set('limit', '250');
const data = await api('/v1/agents/live?' + params.toString());
addAgentEvents((data.events || []).slice().reverse());
agent.liveLoaded = true;
} catch (err) {
console.error('Failed to load live agent context:', err);
} finally {
agent.liveLoading = false;
if (isCurrentPath('/agents') && agentsState.viewMode === 'live' && agentsState.selectedAgentKey === selectedKey) {
renderAgentsContent();
}
}
}
function renderAgentLanes() {
const contentEl = document.getElementById('agents-content');
if (!contentEl) return;
contentEl.innerHTML = '
';
const lanesEl = document.getElementById('agents-lanes');
if (!lanesEl) return;
const agentKeys = getSortedAgentKeys();
if (agentKeys.length === 0) {
lanesEl.innerHTML = 'No recent agent activity
';
return;
}
lanesEl.innerHTML = agentKeys.map(key => {
const agent = agentsState.agents[key];
const isOnline = isAgentOnline(agent);
const sessionCount = Object.keys(agent.sessions).length;
const ops = getAgentDisplayOps(agent);
const subagentCount = ops.filter(op => op.kind === 'agent' || op.subType === 'subagent').length;
const statusClass = sessionCount > 0 ? ' has-sessions' : '';
const statusText = !isOnline ? 'offline'
: subagentCount > 0 ? subagentCount + ' subagent' + (subagentCount > 1 ? 's' : '')
: sessionCount > 0 ? sessionCount + ' session' + (sessionCount > 1 ? 's' : '')
: 'idle';
const opsHTML = ops.length > 0 ? `${ops.map(op => {
const elapsed = Math.floor((Date.now() - op.startedAt) / 1000);
const stale = elapsed > 300;
const kindClass = op.kind === 'agent' || op.subType === 'subagent' ? ' subagent' : '';
return `
${escapeHTML(op.name)}
${formatElapsed(elapsed)}
${stale ? '(stale?) ' : ''}
`;
}).join('')}
` : '';
const recent = agent.events.slice(-40).reverse();
const eventsHTML = recent.length > 0 ? recent.map(evt => {
const eventType = getEnvelopeType(evt);
const details = getEventDetails(evt);
const detailHTML = details ? `${escapeHTML(details)}
` : '';
const expandHTML = details ? 'details ' : '';
return `
${getEventBody(evt)}
${expandHTML}
${detailHTML}
`;
}).join('') : 'No recent activity
';
return `
`;
}).join('');
lanesEl.querySelectorAll('.agent-lane[data-agent-key]').forEach(lane => {
lane.addEventListener('click', () => {
selectAgent(lane.dataset.agentKey || '', 'live');
});
});
lanesEl.querySelectorAll('.timeline-expand-hint').forEach(button => {
button.addEventListener('click', event => {
event.stopPropagation();
button.parentElement.classList.toggle('expanded');
});
});
}
function formatElapsed(seconds) {
if (seconds < 60) return seconds + 's';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's';
return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm';
}
function renderAgentSummary() {
const el = document.getElementById('agents-summary');
if (!el) return;
const s = agentsState.dbStats;
const liveAgents = getSortedAgentKeys().filter(key => isAgentOnline(agentsState.agents[key])).length;
const liveSubagents = getSortedAgentKeys().reduce((count, key) => {
const agent = agentsState.agents[key];
return count + Object.values(agent.operations).filter(op => op.kind === 'agent' || op.subType === 'subagent').length;
}, 0);
el.innerHTML = `
Live Agents ${liveAgents}
Active Subagents ${liveSubagents}
Runs Today ${s.messages}
Tool Calls ${s.tools}
Errors ${s.errors}
`;
}
function getAgentLabel(agent) {
if (!agent) return 'Unknown';
return agent.name || agent.host || agent.framework || agent.key || 'Unknown';
}
function getAgentLiveSummary(agent) {
const recent = agent.events.slice().reverse();
const activeOps = getAgentDisplayOps(agent);
const sessionIDs = Object.keys(agent.sessions);
const live = {
sessionIDs,
activeOps,
activeSubagents: activeOps.filter(op => op.kind === 'agent' || op.subType === 'subagent'),
activeTools: activeOps.filter(op => op.kind === 'tool'),
latestPrompt: '',
latestRunStatus: '',
latestModel: '',
latestError: '',
latestUsage: null,
latestContextWindow: null,
};
for (const evt of recent) {
const eventType = getEnvelopeType(evt);
const payload = getEnvelopePayload(evt);
if (!live.latestPrompt && eventType === 'run.start') {
live.latestPrompt = payload.prompt_preview || payload.message_preview || payload.message || '';
}
if (!live.latestRunStatus && eventType === 'run.end') {
live.latestRunStatus = payload.status || '';
live.latestModel = payload.model || '';
live.latestUsage = payload.usage || null;
live.latestContextWindow = payload.context_window || null;
}
if (!live.latestUsage && eventType === 'metric.snapshot' && payload.metrics) {
live.latestUsage = payload.metrics.usage || null;
live.latestModel = live.latestModel || payload.metrics.model || '';
}
if (!live.latestError && eventType === 'error') {
const errPayload = payload.error || {};
live.latestError = errPayload.message || payload.message || '';
}
if (live.latestPrompt && live.latestRunStatus && live.latestError) {
break;
}
}
return live;
}
function formatCount(value) {
if (value === undefined || value === null || value === '') return '-';
return String(value);
}
function formatCost(value) {
if (value === undefined || value === null || value === '') return '-';
const num = Number(value);
if (!Number.isFinite(num)) return String(value);
return '$' + num.toFixed(4);
}
function buildLiveEventContext(evt) {
const eventType = getEnvelopeType(evt);
const payload = getEnvelopePayload(evt);
const attrs = getEnvelopeAttributes(evt);
const correlation = getEnvelopeCorrelation(evt);
const parts = [];
if ((eventType === 'span.start' || eventType === 'span.end') && attrs.span_kind === 'tool') {
if (payload.input) {
parts.push(`input ${escapeHTML(typeof payload.input === 'string' ? payload.input : JSON.stringify(payload.input))}
`);
}
if (payload.result_preview) {
parts.push(`result ${escapeHTML(String(payload.result_preview))}
`);
}
}
if ((eventType === 'span.start' || eventType === 'span.end') && (attrs.span_kind === 'agent' || attrs.type === 'subagent')) {
if (payload.prompt_preview) {
parts.push(`prompt ${escapeHTML(String(payload.prompt_preview))}
`);
}
if (payload.usage && payload.usage.total_tokens !== undefined) {
parts.push(`tokens ${escapeHTML(formatCount(payload.usage.total_tokens))}
`);
}
if (payload.usage && payload.usage.total_cost !== undefined) {
parts.push(`cost ${escapeHTML(formatCost(payload.usage.total_cost))}
`);
}
}
if (eventType === 'run.start') {
const preview = payload.prompt_preview || payload.message_preview || payload.message || '';
if (preview) {
parts.push(`prompt ${escapeHTML(String(preview))}
`);
}
}
if (eventType === 'run.end') {
if (payload.model) {
parts.push(`model ${escapeHTML(String(payload.model))}
`);
}
if (payload.usage && payload.usage.total_tokens !== undefined) {
parts.push(`tokens ${escapeHTML(formatCount(payload.usage.total_tokens))}
`);
}
if (payload.duration_ms !== undefined) {
parts.push(`duration ${escapeHTML(formatDuration(payload.duration_ms))}
`);
}
}
if (eventType === 'metric.snapshot' && payload.metrics) {
if (payload.metrics.model) {
parts.push(`model ${escapeHTML(String(payload.metrics.model))}
`);
}
if (payload.metrics.usage && payload.metrics.usage.total_tokens !== undefined) {
parts.push(`tokens ${escapeHTML(formatCount(payload.metrics.usage.total_tokens))}
`);
}
if (payload.metrics.usage && payload.metrics.usage.total_cost !== undefined) {
parts.push(`cost ${escapeHTML(formatCost(payload.metrics.usage.total_cost))}
`);
}
}
if (eventType === 'error') {
const errPayload = payload.error || {};
if (errPayload.type) {
parts.push(`type ${escapeHTML(String(errPayload.type))}
`);
}
}
const ids = [];
if (correlation.session_id) ids.push(`session ${correlation.session_id}`);
if (correlation.run_id) ids.push(`run ${correlation.run_id}`);
if (correlation.span_id) ids.push(`span ${correlation.span_id}`);
if (ids.length > 0) {
parts.push(`ids ${escapeHTML(ids.join(' · '))}
`);
}
return parts.join('');
}
function getRunGroupLabel(runID, events) {
const runStart = events.find(evt => getEnvelopeType(evt) === 'run.start');
if (!runStart) {
return runID ? `Run ${runID.slice(0, 12)}...` : 'Session activity';
}
const payload = getEnvelopePayload(runStart);
const preview = payload.prompt_preview || payload.message_preview || payload.message || '';
if (preview) {
return preview.length > 72 ? preview.slice(0, 72) + '...' : preview;
}
return runID ? `Run ${runID.slice(0, 12)}...` : 'Session activity';
}
function groupAgentEventsByRun(events) {
const groups = [];
const byRun = new Map();
for (const evt of events) {
const correlation = getEnvelopeCorrelation(evt);
const runID = correlation.run_id || '';
const key = runID || `session:${correlation.session_id || 'unknown'}`;
if (!byRun.has(key)) {
const group = {
key,
runID,
sessionID: correlation.session_id || '',
events: [],
subagents: new Set(),
tools: new Set(),
};
byRun.set(key, group);
groups.push(group);
}
const group = byRun.get(key);
group.events.push(evt);
const attrs = getEnvelopeAttributes(evt);
if (attrs.span_kind === 'agent' || attrs.type === 'subagent') {
group.subagents.add(attrs.name || 'unknown');
}
if (attrs.span_kind === 'tool' && attrs.name) {
group.tools.add(attrs.name);
}
}
return groups;
}
function renderAgentsLive() {
const contentEl = document.getElementById('agents-content');
if (!contentEl) return;
const agentKeys = getSortedAgentKeys();
const selectedKey = ensureSelectedAgentKey();
if (!selectedKey || agentKeys.length === 0) {
contentEl.innerHTML = 'No recent agent activity
';
return;
}
const selected = agentsState.agents[selectedKey];
const summary = getAgentLiveSummary(selected);
const recent = selected.events.slice(-80).reverse();
const runGroups = groupAgentEventsByRun(recent);
contentEl.innerHTML = `
Current State
Active subagents ${escapeHTML(summary.activeSubagents.map(op => op.name).join(', ') || '-')}
Active tools ${escapeHTML(summary.activeTools.map(op => op.name).join(', ') || '-')}
Latest prompt ${escapeHTML(summary.latestPrompt || '-')}
Run status ${escapeHTML(summary.latestRunStatus || 'in_progress')}
Model ${escapeHTML(summary.latestModel || '-')}
Last error ${escapeHTML(summary.latestError || '-')}
Usage
Total tokens ${escapeHTML(formatCount(summary.latestUsage && summary.latestUsage.total_tokens))}
Input tokens ${escapeHTML(formatCount(summary.latestUsage && summary.latestUsage.input_tokens))}
Output tokens ${escapeHTML(formatCount(summary.latestUsage && summary.latestUsage.output_tokens))}
Total cost ${escapeHTML(formatCost(summary.latestUsage && summary.latestUsage.total_cost))}
Session Context
Session IDs ${escapeHTML(summary.sessionIDs.join(', ') || '-')}
Window input ${escapeHTML(formatCount(summary.latestContextWindow && summary.latestContextWindow.input_tokens))}
Window output ${escapeHTML(formatCount(summary.latestContextWindow && summary.latestContextWindow.output_tokens))}
Remaining ${escapeHTML(formatCount(summary.latestContextWindow && (summary.latestContextWindow.tokens_remaining ?? summary.latestContextWindow.used_tokens)))}
${runGroups.length > 0 ? runGroups.map(group => `
${group.events.map(evt => `
${getEventBody(evt)}
${buildLiveEventContext(evt)}
`).join('')}
`).join('') : '
No recent activity
'}
`;
contentEl.querySelectorAll('[data-agent-key]').forEach(button => {
button.addEventListener('click', () => selectAgent(button.dataset.agentKey || '', 'live'));
});
}
function renderAgentVMStrip() {
// VM online/offline state is shown in each lane header via getVMStatus().
// Re-render lanes to pick up the updated openclawState.
renderAgentsContent();
}
function handleAgentsWS(msg) {
if (msg.type !== 'message') return;
const eventType = getEnvelopeType(msg.data);
if (eventType === 'openclaw.snapshot') {
mergeOpenClawEvents([msg.data]);
renderAgentsContent();
return;
}
if (!isAgentTimelineEvent(msg.data)) return;
if (eventType === 'run.start') agentsState.dbStats.messages++;
else if (eventType === 'span.end') {
const attrs = getEnvelopeAttributes(msg.data);
if (attrs.span_kind === 'tool') agentsState.dbStats.tools++;
} else if (eventType === 'error') agentsState.dbStats.errors++;
addAgentEvents([msg.data]);
renderAgentsContent();
}
function updateAgentTimers() {
document.querySelectorAll('.active-op-time[data-start]').forEach(el => {
const start = parseInt(el.dataset.start, 10);
if (!start) return;
const elapsed = Math.floor((Date.now() - start) / 1000);
el.textContent = formatElapsed(elapsed);
const op = el.closest('.active-op');
if (op && elapsed > 300 && !op.classList.contains('stale')) {
op.classList.add('stale');
if (!op.querySelector('.active-op-stale')) {
op.insertAdjacentHTML('beforeend', '(stale?) ');
}
}
});
}
function addAgentEvents(events) {
let changed = false;
for (const evt of events) {
const id = getRecordID(evt);
const agent = getAgentBucket(evt);
if (!id || !agent || agent.eventIDs.has(id)) continue;
processAgentEvent(evt);
changed = true;
}
if (changed) {
for (const agent of Object.values(agentsState.agents)) {
agent.events.sort((a, b) => new Date(getEnvelopeTS(a)).getTime() - new Date(getEnvelopeTS(b)).getTime());
}
recomputeAgentStats();
}
}
function recomputeAgentStats() {
const stats = { messages: 0, tools: 0, errors: 0, toolCounts: {} };
for (const agent of Object.values(agentsState.agents)) {
for (const evt of agent.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 '↓
';
case 'run.end':
return '↑
';
case 'span.start':
case 'span.end':
return '⚙
';
case 'error':
return '!
';
case 'session.start':
case 'session.end':
return '○
';
default:
return '·
';
}
}
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 getAgentIdentity(evt).name || '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
? ` ${escapeHTML(formatDuration(payload.duration_ms))} `
: '';
const detailClass = attrs.span_kind === 'agent' || attrs.type === 'subagent' ? ' subagent-name' : ' tool-name';
const prefix = attrs.span_kind === 'agent' || attrs.type === 'subagent' ? 'subagent ' : '';
return `${escapeHTML(prefix + name)}${duration}
`;
}
if (eventType === 'run.start') {
const preview = payload.prompt_preview || payload.message_preview || payload.message || '';
if (!preview) {
return '';
}
const trimmed = preview.length > 140 ? preview.slice(0, 140) + '...' : preview;
return `"${escapeHTML(trimmed)}"
`;
}
if (eventType === 'run.end') {
return `${statusIcon(payload.status || 'unknown')}
`;
}
if (eventType === 'error') {
const errPayload = payload.error || {};
const errType = errPayload.type || 'error';
const message = errPayload.message || payload.message || 'unknown';
return `${escapeHTML(errType + ': ' + message)}
`;
}
if (eventType === 'session.start' || eventType === 'session.end') {
return correlation.session_id
? `session ${escapeHTML(correlation.session_id)}
`
: '';
}
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);
}
async function renderDashboard() {
dashboardState = {
summary: null,
timeseries: null,
window: '1h',
recentEvents: [],
recentEventIDs: new Set(),
toolCounts: {},
};
app.innerHTML = `
Infrastructure
`;
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();
// Render cached data immediately while the API call is in-flight
const cachedSummary = tryParseJSON(localStorage.getItem('agentmon:dash:summary'));
const cachedTS = tryParseJSON(localStorage.getItem('agentmon:dash:ts:' + dashboardState.window));
if (cachedSummary) { dashboardState.summary = cachedSummary; renderSummaryCards(); }
if (cachedTS) { dashboardState.timeseries = cachedTS; renderTimeseriesChart(); renderFrameworkBars(); }
try {
const [summaryData, tsData, recentData, snapshots, swarmSnaps, topToolsData] = 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: [] })),
api('/v1/events?event_type=swarm.snapshot&limit=10').catch(() => ({ events: [] })),
api('/v1/stats/top-tools').catch(() => ({ tools: [] })),
]);
if (!isCurrentPath('/')) return;
mergeOpenClawEvents(snapshots.events || []);
renderDashVMStrip();
for (const evt of swarmSnaps.events || []) mergeSwarmSnapshot(evt);
renderSwarmStrip_dash();
dashboardState.summary = summaryData;
dashboardState.timeseries = tsData;
localStorage.setItem('agentmon:dash:summary', JSON.stringify(summaryData));
localStorage.setItem('agentmon:dash:ts:' + dashboardState.window, JSON.stringify(tsData));
renderSummaryCards();
renderTimeseriesChart();
renderFrameworkBars();
// Seed tool counts from the dedicated top-tools endpoint
for (const t of (topToolsData.tools || [])) {
dashboardState.toolCounts[t.name] = t.count;
}
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);
}
}
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 renderSwarmStrip_dash() {
const strip = document.getElementById('dash-swarm-strip');
if (!strip) return;
const services = Object.values(swarmState.services);
if (services.length === 0) return;
strip.innerHTML = services.map(svc => {
const statusClass = svc.status === 'healthy' ? 'active'
: svc.status === 'degraded' ? 'degraded' : 'inactive';
const label = svc.status || 'unknown';
return `
${escapeHTML(svc.name)}
${escapeHTML(label)}
`;
}).join('');
}
function handleDashboardWS(msg) {
if (msg.type !== 'message') return;
const eventType = getEnvelopeType(msg.data);
if (eventType === 'openclaw.snapshot') {
mergeOpenClawEvents([msg.data]);
renderDashVMStrip();
return;
}
if (eventType === 'swarm.snapshot') {
mergeSwarmSnapshot(msg.data);
renderSwarmStrip_dash();
return;
}
if (eventType === 'swarm.service.snapshot') {
mergeSwarmServiceSnapshot(msg.data);
renderSwarmStrip_dash();
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);
// Sub-line: framework breakdown for active sessions
const fws = Object.keys(s.by_framework || {});
if (fws.length > 0) {
const sub = document.getElementById('dash-active-sub');
if (sub) sub.textContent = fws.map(f => `${f} ${(s.by_framework[f].runs || 0)}`).join(' · ');
}
const errEl = document.getElementById('dash-errors');
if (errEl) {
errEl.classList.toggle('has-errors', s.errors_today > 0);
}
}
async function loadTimeseries() {
try {
// Destroy chart so it's recreated with new window scale
if (dashboardChart) {
dashboardChart.destroy();
dashboardChart = null;
}
const cachedWin = tryParseJSON(localStorage.getItem('agentmon:dash:ts:' + dashboardState.window));
if (cachedWin) { dashboardState.timeseries = cachedWin; renderTimeseriesChart(); }
const data = await api('/v1/stats/timeseries?window=' + dashboardState.window);
if (!isCurrentPath('/')) return;
dashboardState.timeseries = data;
localStorage.setItem('agentmon:dash:ts:' + dashboardState.window, JSON.stringify(data));
renderTimeseriesChart();
} catch (e) {
console.error('Failed to load timeseries:', e);
}
}
function buildChartData() {
const ts = dashboardState.timeseries;
if (!ts || !ts.series || ts.series.length === 0) return null;
// Stacked: errors on bottom, then tools, then runs on top
const errors = ts.series.map(b => b.errors);
const tools = ts.series.map((b, i) => b.tools + errors[i]);
const runs = ts.series.map((b, i) => b.runs + tools[i]);
return [
ts.series.map(b => Math.floor(new Date(b.ts).getTime() / 1000)),
runs,
tools,
errors,
];
}
function renderTimeseriesChart() {
const container = document.getElementById('dash-chart');
if (!container || !dashboardState.timeseries) return;
const data = buildChartData();
if (!data) {
container.innerHTML = 'No data for this window
';
return;
}
// If chart already exists, just update the data
if (dashboardChart) {
dashboardChart.setData(data);
return;
}
container.innerHTML = '';
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: 1.5,
fill: 'rgba(52, 211, 153, 0.15)',
},
{
label: 'Tools',
stroke: '#22d3ee',
width: 1.5,
fill: 'rgba(34, 211, 238, 0.15)',
},
{
label: 'Errors',
stroke: '#f87171',
width: 1.5,
fill: 'rgba(248, 113, 113, 0.2)',
},
],
bands: [
{ series: [1, 2], fill: 'rgba(52, 211, 153, 0.15)' },
{ series: [2, 3], fill: 'rgba(34, 211, 238, 0.15)' },
],
};
dashboardChart = new uPlot(opts, data, 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 = '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.toLowerCase().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 `
${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;
}
const maxCount = topTools[0]?.[1] || 1;
list.innerHTML = topTools.map(([name, count]) => {
const pct = (count / maxCount * 100).toFixed(1);
return `
`;
}).join('');
}
route();
})();