(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 = `
Session Framework Host Runs Time
`; ['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 duration = s.ended_at ? formatDuration(new Date(s.ended_at) - new Date(s.started_at)) : 'ongoing'; app.innerHTML = ` ← Back to Sessions
Runs ${runs.length}
${runs.map(r => { 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-/, '')) : '-'; return ` `; }).join('') || ''}
Run ID Status Model Tools Spans Duration Started
${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())}
No runs
`; 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'); }); 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.querySelector('#app table tbody'); if (!tbody) return; tbody.innerHTML = runs.map(r => { 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-/, '')) : '-'; 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())} `; }).join('') || 'No runs'; tbody.querySelectorAll('tr.clickable').forEach(row => { row.addEventListener('click', () => navigate('/runs/' + row.dataset.run)); }); 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}
${spans.map((sp, i) => ` `).join('') || ''}
Name Kind Status Duration
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); }); } 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 `

${escapeHTML(inst.name || name)}

${host.state === 'running' ? 'Running' : 'Stopped'}
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 `
${escapeHTML(svc.name)}
${escapeHTML(svc.role || '')}
${escapeHTML(svc.status || 'down')}
`; } 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() { function agentBucket() { return { sessions: {}, operations: {}, events: [], eventIDs: new Set() }; } return { agents: { zap: agentBucket(), orb: agentBucket(), sun: agentBucket() }, stats: { messages: 0, tools: 0, errors: 0, toolCounts: {} }, dbStats: { messages: 0, tools: 0, errors: 0 }, 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 getAgentBucket(evt) { const name = getVMName(evt).toLowerCase(); return agentsState.agents[name] || null; } function processAgentEvent(evt) { const agent = getAgentBucket(evt); if (!agent) return; const eventType = getEnvelopeType(evt); const correlation = getEnvelopeCorrelation(evt); const attrs = getEnvelopeAttributes(evt); 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 || '', 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 hasTools = ops.some(op => op.kind === 'tool'); return hasTools ? ops.filter(op => op.kind === 'tool') : ops; } async function renderAgents() { agentsState = createAgentsState(); app.innerHTML = `
ZAP

Loading...

ORB

Loading...

SUN

Loading...

`; try { const [snapshots, events, summaryData] = await Promise.all([ api('/v1/events?event_type=openclaw.snapshot&limit=100').catch(() => ({ events: [] })), api('/v1/events?framework=openclaw&limit=200'), api('/v1/stats/summary').catch(() => null), ]); if (!isCurrentPath('/agents')) return; if (summaryData) { const fw = (summaryData.by_framework || {}).openclaw || {}; agentsState.dbStats.messages = fw.runs || 0; agentsState.dbStats.tools = fw.tools || 0; agentsState.dbStats.errors = fw.errors || 0; } mergeOpenClawEvents(snapshots.events || []); addAgentEvents((events.events || []).slice().reverse()); renderAgentLanes(); renderAgentSummary(); } catch (e) { document.getElementById('agents-lanes').innerHTML = `

Error loading agent activity: ${escapeHTML(e.message)}

`; } agentsState.timerInterval = setInterval(updateAgentTimers, 1000); agentsUnsubscribe = subscribeWS(handleAgentsWS); } function renderAgentLanes() { const lanesEl = document.getElementById('agents-lanes'); if (!lanesEl) return; const vmNames = ['zap', 'orb', 'sun']; lanesEl.innerHTML = vmNames.map(name => { const agent = agentsState.agents[name]; const vmStatus = getVMStatus().find(v => v.name === name); const isOnline = vmStatus && vmStatus.active; const sessionCount = Object.keys(agent.sessions).length; const ops = getAgentDisplayOps(agent); const statusClass = sessionCount > 0 ? ' has-sessions' : ''; const statusText = !isOnline ? 'offline' : 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; 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 vmClass = getVMClassName(name); const details = getEventDetails(evt); const detailHTML = details ? `
${escapeHTML(details)}
` : ''; const expandHTML = details ? '' : ''; return `
${getEventIcon(eventType)} ${escapeHTML(getEventLabel(eventType))} ${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())}
${getEventBody(evt)} ${expandHTML} ${detailHTML}
`; }).join('') : '

No recent activity

'; return `
${escapeHTML(name.toUpperCase())}
${statusText}
${opsHTML}
${eventsHTML}
`; }).join(''); lanesEl.querySelectorAll('.timeline-expand-hint').forEach(button => { button.addEventListener('click', () => { 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; el.innerHTML = `
Runs Today ${s.messages}
Tool Calls ${s.tools}
Errors ${s.errors}
`; } function renderAgentVMStrip() { // VM online/offline state is shown in each lane header via getVMStatus(). // Re-render lanes to pick up the updated openclawState. renderAgentLanes(); } function handleAgentsWS(msg) { if (msg.type !== 'message') return; const eventType = getEnvelopeType(msg.data); if (eventType === 'openclaw.snapshot') { mergeOpenClawEvents([msg.data]); renderAgentLanes(); return; } const framework = getEnvelopeSource(msg.data).framework || msg.data.source_framework; if (framework !== 'openclaw') 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]); renderAgentLanes(); renderAgentSummary(); } 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 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 ? ` ${escapeHTML(formatDuration(payload.duration_ms))}` : ''; return `
${escapeHTML(name)}${duration}
`; } 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 `
"${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 = `
Active Sessions
-
 
Runs Today
-
 
Tool Calls
-
 
Errors
-
 
Infrastructure
Event Rate
runs tools errors
By Framework

Loading...

Recent Activity

Loading...

Top Tools
`; 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 `
${getEventIcon(eventType)} ${tag} ${escapeHTML(getEventLabel(eventType))} ${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())}
${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 `
  • ${escapeHTML(name)} ${count}
  • `; }).join(''); } route(); })();