3652 lines
139 KiB
JavaScript
3652 lines
139 KiB
JavaScript
(function() {
|
|
|
|
// ── Theme toggle ─────────────────────────────────────────
|
|
const THEME_CYCLE = ['system', 'light', 'dark'];
|
|
const THEME_ICONS = {
|
|
system: '<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="2" width="14" height="10" rx="1.5"/><path d="M5 15h6M8 12v3"/></svg>',
|
|
light: '<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="3"/><line x1="8" y1="1" x2="8" y2="3"/><line x1="8" y1="13" x2="8" y2="15"/><line x1="1" y1="8" x2="3" y2="8"/><line x1="13" y1="8" x2="15" y2="8"/><line x1="3.05" y1="3.05" x2="4.46" y2="4.46"/><line x1="11.54" y1="11.54" x2="12.95" y2="12.95"/><line x1="12.95" y1="3.05" x2="11.54" y2="4.46"/><line x1="4.46" y1="11.54" x2="3.05" y2="12.95"/></svg>',
|
|
dark: '<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 10a5 5 0 1 1-7-7 6 6 0 0 0 7 7z"/></svg>',
|
|
};
|
|
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 wsStatus = 'disconnected';
|
|
let wsReconnectTimeout = null;
|
|
let wsReconnectDelay = 1000;
|
|
const wsCallbacks = new Set();
|
|
|
|
let sessionsState = { sessions: [], cursor: null, activeSessionByBackend: {} };
|
|
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;
|
|
const DASH_RECENT_EVENTS_LIMIT = 10;
|
|
const DASH_RECENT_EVENTS_STORAGE_KEY = 'agentmon:dash:recent-events';
|
|
|
|
function getDashboardChartMode() {
|
|
const mode = localStorage.getItem('agentmon:dash:chart-mode');
|
|
return mode === 'lines' ? 'lines' : 'stacked';
|
|
}
|
|
|
|
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');
|
|
wsStatus = 'connected';
|
|
wsReconnectDelay = 1000;
|
|
updateWSIndicator();
|
|
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');
|
|
wsStatus = 'reconnecting';
|
|
updateWSIndicator();
|
|
wsCallbacks.forEach(cb => cb({ type: 'disconnected' }));
|
|
wsReconnectTimeout = setTimeout(connectWS, wsReconnectDelay);
|
|
wsReconnectDelay = Math.min(wsReconnectDelay * 1.5, 30000);
|
|
};
|
|
|
|
ws.onerror = (err) => {
|
|
console.error('WebSocket error:', err);
|
|
};
|
|
} catch (e) {
|
|
console.error('Failed to connect WebSocket:', e);
|
|
wsReconnectTimeout = setTimeout(connectWS, wsReconnectDelay);
|
|
wsReconnectDelay = Math.min(wsReconnectDelay * 1.5, 30000);
|
|
}
|
|
}
|
|
|
|
function subscribeWS(callback) {
|
|
wsCallbacks.add(callback);
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
connectWS();
|
|
}
|
|
return () => wsCallbacks.delete(callback);
|
|
}
|
|
|
|
function updateWSIndicator() {
|
|
const dot = document.getElementById('ws-dot');
|
|
if (!dot) return;
|
|
dot.className = 'ws-dot ' + wsStatus;
|
|
const labels = {
|
|
connected: 'Live — WebSocket connected',
|
|
reconnecting: 'Reconnecting…',
|
|
disconnected: 'Disconnected',
|
|
};
|
|
dot.title = labels[wsStatus] || 'Unknown';
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (_agentsRenderTimer) {
|
|
cancelAnimationFrame(_agentsRenderTimer);
|
|
_agentsRenderTimer = null;
|
|
}
|
|
if (_dashFeedRenderTimer) {
|
|
cancelAnimationFrame(_dashFeedRenderTimer);
|
|
_dashFeedRenderTimer = 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 = '<div class="not-found"><h2>Page not found</h2><p>The page you\'re looking for doesn\'t exist.</p><a href="/" class="back-link">Go to Dashboard</a></div>';
|
|
}
|
|
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) {
|
|
const body = await resp.json().catch(() => ({}));
|
|
const msg = body.error || 'Request failed (' + resp.status + ')';
|
|
showToast(msg, 'error');
|
|
throw new Error(msg);
|
|
}
|
|
return resp.json();
|
|
}
|
|
|
|
function showToast(message, type) {
|
|
const existing = document.querySelector('.toast');
|
|
if (existing) existing.remove();
|
|
const toast = document.createElement('div');
|
|
toast.className = 'toast toast-' + (type || 'info');
|
|
toast.textContent = message;
|
|
document.body.appendChild(toast);
|
|
requestAnimationFrame(() => toast.classList.add('visible'));
|
|
setTimeout(() => {
|
|
toast.classList.remove('visible');
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, 4000);
|
|
}
|
|
|
|
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, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function relativeTime(ts) {
|
|
if (!ts) {
|
|
return '-';
|
|
}
|
|
|
|
const now = Date.now();
|
|
const then = new Date(ts).getTime();
|
|
const diff = now - then;
|
|
|
|
if (diff < 60000) return 'just now';
|
|
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
|
|
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
|
|
return Math.floor(diff / 86400000) + 'd ago';
|
|
}
|
|
|
|
function formatDuration(ms) {
|
|
if (ms === undefined || ms === null || ms === '') return '-';
|
|
if (ms < 1000) return ms + 'ms';
|
|
if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
|
|
return (ms / 60000).toFixed(1) + 'm';
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (!bytes) return null;
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
let unitIndex = 0;
|
|
let value = bytes;
|
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
value /= 1024;
|
|
unitIndex++;
|
|
}
|
|
return value.toFixed(1) + ' ' + units[unitIndex];
|
|
}
|
|
|
|
function statusIcon(status) {
|
|
if (status === 'success') return '<span class="status-badge status-success"><span class="status-dot"></span>success</span>';
|
|
if (status === 'error') return '<span class="status-badge status-error"><span class="status-dot"></span>error</span>';
|
|
return '<span class="status-badge status-unknown"><span class="status-dot"></span>unknown</span>';
|
|
}
|
|
|
|
function skeletonRows(rows, cols) {
|
|
return Array(rows).fill(0).map(() =>
|
|
'<tr>' + Array(cols).fill('<td><div class="skeleton-line"></div></td>').join('') + '</tr>'
|
|
).join('');
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function groupSessionsByDate(sessions) {
|
|
const now = new Date();
|
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
const yesterdayStart = new Date(todayStart);
|
|
yesterdayStart.setDate(yesterdayStart.getDate() - 1);
|
|
const weekStart = new Date(todayStart);
|
|
weekStart.setDate(weekStart.getDate() - 6);
|
|
const groups = [
|
|
{ label: 'Today', items: [] },
|
|
{ label: 'Yesterday', items: [] },
|
|
{ label: 'This Week', items: [] },
|
|
{ label: 'Older', items: [] },
|
|
];
|
|
for (const s of sessions) {
|
|
const d = new Date(s.started_at);
|
|
if (d >= todayStart) groups[0].items.push(s);
|
|
else if (d >= yesterdayStart) groups[1].items.push(s);
|
|
else if (d >= weekStart) groups[2].items.push(s);
|
|
else groups[3].items.push(s);
|
|
}
|
|
return groups.filter(g => g.items.length > 0);
|
|
}
|
|
|
|
function getSessionBackendKey(s) {
|
|
const framework = s.framework || 'unknown';
|
|
const backendID = s.client_id || s.host || 'unknown';
|
|
return `${framework}|${backendID}`;
|
|
}
|
|
|
|
function sessionActivityTS(s) {
|
|
const raw = s._lastActivityTS || Date.parse(s.started_at);
|
|
return Number.isFinite(raw) ? raw : 0;
|
|
}
|
|
|
|
function recomputeActiveSessionByBackend() {
|
|
const next = {};
|
|
const bestTS = {};
|
|
sessionsState.sessions.forEach(s => {
|
|
if (!isSessionActive(s)) return;
|
|
const key = getSessionBackendKey(s);
|
|
const ts = sessionActivityTS(s);
|
|
if (!next[key] || ts > bestTS[key]) {
|
|
next[key] = s.session_id;
|
|
bestTS[key] = ts;
|
|
}
|
|
});
|
|
sessionsState.activeSessionByBackend = next;
|
|
}
|
|
|
|
function sessionDotState(s) {
|
|
if (!isSessionActive(s)) return 'ended';
|
|
const key = getSessionBackendKey(s);
|
|
const activeSessionID = sessionsState.activeSessionByBackend[key];
|
|
return activeSessionID === s.session_id ? 'active' : 'idle';
|
|
}
|
|
|
|
function touchSessionActivity(sessionID, ts, source) {
|
|
const session = sessionsState.sessions.find(s => s.session_id === sessionID);
|
|
if (!session) return null;
|
|
|
|
const parsedTS = Date.parse(ts || '');
|
|
const activityTS = Number.isFinite(parsedTS) ? parsedTS : Date.now();
|
|
session._lastActivityTS = Math.max(session._lastActivityTS || 0, activityTS);
|
|
|
|
if (source && typeof source === 'object') {
|
|
if (source.framework) session.framework = source.framework;
|
|
if (source.host) session.host = source.host;
|
|
if (source.client_id) session.client_id = source.client_id;
|
|
}
|
|
|
|
const key = getSessionBackendKey(session);
|
|
sessionsState.activeSessionByBackend[key] = session.session_id;
|
|
return session;
|
|
}
|
|
|
|
function refreshSessionsTable() {
|
|
const tbody = document.getElementById('sessions-body');
|
|
if (!tbody) return;
|
|
const groups = groupSessionsByDate(sessionsState.sessions);
|
|
if (groups.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="5" class="empty-state">No sessions found</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = groups.map(group => {
|
|
const rows = group.items.map(s => {
|
|
const fw = s.framework || 'unknown';
|
|
const fwClass = fw.replace(/[^a-z0-9-]/g, '-');
|
|
const active = isSessionActive(s);
|
|
const dotState = sessionDotState(s);
|
|
const dotTitle = dotState === 'active'
|
|
? 'Currently active session'
|
|
: (active ? 'Open session' : 'Session ended');
|
|
return `
|
|
<tr class="clickable ${active ? 'active' : ''}" data-session="${escapeHTML(s.session_id)}">
|
|
<td class="id-cell">${escapeHTML(s.session_id.substring(0, 12))}…</td>
|
|
<td><span class="fw-dot ${escapeHTML(fwClass)} ${dotState}" title="${dotTitle}"></span>${escapeHTML(fw)}</td>
|
|
<td>${escapeHTML(s.host || '-')}</td>
|
|
<td>${s.run_count}</td>
|
|
<td title="${escapeHTML(s.started_at)}">${escapeHTML(relativeTime(s.started_at))}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
return `<tr class="session-date-group"><td colspan="5">${escapeHTML(group.label)}</td></tr>${rows}`;
|
|
}).join('');
|
|
tbody.querySelectorAll('tr.clickable').forEach(row => {
|
|
row.addEventListener('click', () => navigate('/sessions/' + row.dataset.session));
|
|
});
|
|
}
|
|
|
|
async function renderSessions() {
|
|
app.innerHTML = `
|
|
<div class="page-header">
|
|
<h2>Sessions</h2>
|
|
</div>
|
|
<div class="filters">
|
|
<label>From <input type="date" id="filter-from"></label>
|
|
<label>To <input type="date" id="filter-to"></label>
|
|
<label>Framework
|
|
<select id="filter-framework">
|
|
<option value="">All</option>
|
|
</select>
|
|
</label>
|
|
<label>Host <input type="text" id="filter-host" placeholder="hostname"></label>
|
|
</div>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Session</th>
|
|
<th>Framework</th>
|
|
<th>Host</th>
|
|
<th>Runs</th>
|
|
<th>Time</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="sessions-body">${skeletonRows(8, 5)}</tbody>
|
|
</table>
|
|
</div>
|
|
<button id="load-more" class="load-more" style="display:none">Load more</button>
|
|
`;
|
|
|
|
api('/v1/stats/summary').then(data => {
|
|
const sel = document.getElementById('filter-framework');
|
|
if (!sel || !data.by_framework) return;
|
|
for (const fw of Object.keys(data.by_framework).sort()) {
|
|
const opt = document.createElement('option');
|
|
opt.value = fw;
|
|
opt.textContent = fw;
|
|
sel.appendChild(opt);
|
|
}
|
|
}).catch(() => {});
|
|
|
|
// Restore filters from URL
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
if (urlParams.get('from')) document.getElementById('filter-from').value = urlParams.get('from');
|
|
if (urlParams.get('to')) document.getElementById('filter-to').value = urlParams.get('to');
|
|
if (urlParams.get('framework')) document.getElementById('filter-framework').value = urlParams.get('framework');
|
|
if (urlParams.get('host')) document.getElementById('filter-host').value = urlParams.get('host');
|
|
|
|
['from', 'to', 'framework'].forEach(f => {
|
|
document.getElementById('filter-' + f).addEventListener('change', () => {
|
|
sessionsState.sessions = [];
|
|
sessionsState.cursor = null;
|
|
loadSessions();
|
|
});
|
|
});
|
|
let _hostDebounce = null;
|
|
document.getElementById('filter-host').addEventListener('input', () => {
|
|
clearTimeout(_hostDebounce);
|
|
_hostDebounce = setTimeout(() => {
|
|
sessionsState.sessions = [];
|
|
sessionsState.cursor = null;
|
|
loadSessions();
|
|
}, 400);
|
|
});
|
|
|
|
document.getElementById('load-more').addEventListener('click', loadSessions);
|
|
|
|
sessionsState = { sessions: [], cursor: null, timerInterval: null, activeSessionByBackend: {} };
|
|
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 = sessionDotState(s);
|
|
const dotTitle = dotState === 'active'
|
|
? 'Currently active session'
|
|
: (active ? 'Open session' : 'Session ended');
|
|
return `
|
|
<td class="id-cell">${escapeHTML(s.session_id.substring(0, 12))}…</td>
|
|
<td><span class="fw-dot ${escapeHTML(fwClass)} ${dotState}" title="${dotTitle}"></span>${escapeHTML(fw)}</td>
|
|
<td>${escapeHTML(s.host || '-')}</td>
|
|
<td>${s.run_count}</td>
|
|
<td title="${escapeHTML(s.started_at)}">${escapeHTML(relativeTime(s.started_at))}</td>
|
|
`;
|
|
}
|
|
|
|
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 source = getEnvelopeSource(msg.data);
|
|
const ts = getEnvelopeTS(msg.data);
|
|
const sessionId = correlation?.session_id || msg.data.event?.id;
|
|
|
|
if (eventType === 'session.start') {
|
|
const newSession = {
|
|
session_id: sessionId,
|
|
started_at: ts || new Date().toISOString(),
|
|
framework: source.framework || 'unknown',
|
|
client_id: source.client_id || '',
|
|
host: source.host || '-',
|
|
run_count: 1,
|
|
_lastActivityTS: Date.parse(ts || '') || Date.now(),
|
|
};
|
|
sessionsState.sessions.unshift(newSession);
|
|
const backendKey = getSessionBackendKey(newSession);
|
|
sessionsState.activeSessionByBackend[backendKey] = newSession.session_id;
|
|
refreshSessionsTable();
|
|
return;
|
|
}
|
|
|
|
const tbody = document.getElementById('sessions-body');
|
|
if (!tbody) return;
|
|
|
|
if (sessionId) {
|
|
touchSessionActivity(sessionId, ts, source);
|
|
}
|
|
|
|
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 row = tbody.querySelector(`[data-session="${sessionId}"]`);
|
|
if (row && row.cells[3]) 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();
|
|
recomputeActiveSessionByBackend();
|
|
}
|
|
}
|
|
|
|
refreshSessionsTable();
|
|
}
|
|
|
|
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;
|
|
|
|
// Sync filters to URL
|
|
const filterParams = new URLSearchParams();
|
|
if (from) filterParams.set('from', from);
|
|
if (to) filterParams.set('to', to);
|
|
if (framework) filterParams.set('framework', framework);
|
|
if (host) filterParams.set('host', host);
|
|
const filterQS = filterParams.toString();
|
|
const newURL = '/sessions' + (filterQS ? '?' + filterQS : '');
|
|
if (window.location.pathname + window.location.search !== newURL) {
|
|
history.replaceState(null, '', newURL);
|
|
}
|
|
|
|
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());
|
|
const incoming = (data.sessions || []).map(s => ({
|
|
...s,
|
|
_lastActivityTS: Date.parse(s.started_at || '') || Date.now(),
|
|
}));
|
|
sessionsState.sessions = sessionsState.sessions.concat(incoming);
|
|
sessionsState.cursor = data.next_cursor;
|
|
recomputeActiveSessionByBackend();
|
|
|
|
refreshSessionsTable();
|
|
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 = `
|
|
<a href="/sessions" class="back-link">← Back to Sessions</a>
|
|
<div class="page-header">
|
|
<h2>Session <span style="font-family:var(--font-mono);font-size:1.1rem;color:var(--accent)">${escapeHTML(sessionID.substring(0, 16))}...</span></h2>
|
|
<div class="session-status-line">
|
|
<span class="fw-dot ${escapeHTML((s.framework || 'unknown').replace(/[^a-z0-9-]/g, '-'))} ${active ? 'active' : 'ended'}"></span>
|
|
<span class="session-status-text">${active ? 'Active' : 'Ended'}</span>
|
|
</div>
|
|
<div class="meta-tiles">
|
|
<div class="meta-tile">
|
|
<div class="meta-tile-label">Started</div>
|
|
<div class="meta-tile-value">${escapeHTML(new Date(s.started_at).toLocaleString())}</div>
|
|
</div>
|
|
<div class="meta-tile">
|
|
<div class="meta-tile-label">Framework</div>
|
|
<div class="meta-tile-value">${escapeHTML(s.framework || '-')}</div>
|
|
</div>
|
|
<div class="meta-tile">
|
|
<div class="meta-tile-label">Host</div>
|
|
<div class="meta-tile-value">${escapeHTML(s.host || '-')}</div>
|
|
</div>
|
|
<div class="meta-tile">
|
|
<div class="meta-tile-label">Duration</div>
|
|
<div class="meta-tile-value">${escapeHTML(duration)}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="section-title">Runs <span class="count">${runs.length}</span></div>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Run ID</th>
|
|
<th>Status</th>
|
|
<th>Model</th>
|
|
<th>Tools</th>
|
|
<th>Spans</th>
|
|
<th>Duration</th>
|
|
<th>Started</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="session-runs-body">
|
|
${renderSessionRunsRows(runs)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
|
|
bindSessionRunRows();
|
|
|
|
document.querySelector('.back-link').addEventListener('click', e => {
|
|
e.preventDefault();
|
|
navigate('/sessions');
|
|
});
|
|
|
|
sessionsUnsubscribe = subscribeWS((msg) => handleSessionWS(sessionID, msg));
|
|
}
|
|
|
|
let _sessionReloadTimer = null;
|
|
function handleSessionWS(sessionID, msg) {
|
|
if (msg.type !== 'message') return;
|
|
const correlation = getEnvelopeCorrelation(msg.data);
|
|
if (correlation?.session_id !== sessionID) return;
|
|
const eventType = getEnvelopeType(msg.data);
|
|
if (!['run.start', 'run.end', 'session.end', 'error'].includes(eventType)) return;
|
|
clearTimeout(_sessionReloadTimer);
|
|
_sessionReloadTimer = setTimeout(() => loadSessionData(sessionID), 300);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function renderSpanPayload(sp) {
|
|
const outer = sp.payload || {};
|
|
const inner = outer.payload || {};
|
|
const parts = [];
|
|
|
|
if (sp.kind === 'tool') {
|
|
if (inner.input !== undefined) {
|
|
const inputStr = typeof inner.input === 'object'
|
|
? JSON.stringify(inner.input, null, 2)
|
|
: String(inner.input);
|
|
parts.push(`<div class="span-kv"><span class="span-kv-key">Input</span><pre class="span-kv-val span-kv-raw">${escapeHTML(inputStr)}</pre></div>`);
|
|
}
|
|
if (inner.result_preview !== undefined) {
|
|
parts.push(`<div class="span-kv"><span class="span-kv-key">Result</span><pre class="span-kv-val span-kv-raw">${escapeHTML(String(inner.result_preview))}</pre></div>`);
|
|
}
|
|
} else if (sp.kind === 'agent') {
|
|
if (inner.prompt_preview) {
|
|
parts.push(`<div class="span-kv"><span class="span-kv-key">Prompt</span><pre class="span-kv-val span-kv-raw">${escapeHTML(String(inner.prompt_preview))}</pre></div>`);
|
|
}
|
|
if (inner.usage) {
|
|
const u = inner.usage;
|
|
const tokens = [
|
|
u.total_tokens != null ? `${u.total_tokens} total` : null,
|
|
u.input_tokens != null ? `${u.input_tokens} in` : null,
|
|
u.output_tokens != null ? `${u.output_tokens} out` : null,
|
|
].filter(Boolean).join(' · ');
|
|
if (tokens) parts.push(`<div class="span-kv"><span class="span-kv-key">Tokens</span><span class="span-kv-val">${escapeHTML(tokens)}</span></div>`);
|
|
if (u.total_cost != null) {
|
|
parts.push(`<div class="span-kv"><span class="span-kv-key">Cost</span><span class="span-kv-val">${escapeHTML(formatCost(u.total_cost))}</span></div>`);
|
|
}
|
|
}
|
|
if (inner.model) {
|
|
parts.push(`<div class="span-kv"><span class="span-kv-key">Model</span><span class="span-kv-val">${escapeHTML(String(inner.model))}</span></div>`);
|
|
}
|
|
} else {
|
|
const raw = Object.keys(inner).length > 0 ? inner : (Object.keys(outer).length > 0 ? outer : null);
|
|
if (raw) {
|
|
parts.push(`<pre class="span-kv-raw">${escapeHTML(JSON.stringify(raw, null, 2))}</pre>`);
|
|
}
|
|
}
|
|
|
|
if (sp.duration_ms != null) {
|
|
parts.push(`<div class="span-kv"><span class="span-kv-key">Duration</span><span class="span-kv-val">${escapeHTML(formatDuration(sp.duration_ms))}</span></div>`);
|
|
}
|
|
|
|
return parts.length > 0
|
|
? parts.join('')
|
|
: '<span style="font-size:0.75rem;color:var(--text-dim)">No payload data</span>';
|
|
}
|
|
|
|
function renderRunSpansRows(spans) {
|
|
if (!spans || spans.length === 0) {
|
|
return '<tr><td colspan="4" class="empty-state">No spans</td></tr>';
|
|
}
|
|
return spans.map((sp, i) => {
|
|
const kindClass = sp.kind || 'unknown';
|
|
return `
|
|
<tr class="expandable run-span-row" data-index="${i}">
|
|
<td>
|
|
<span class="expand-icon"></span>
|
|
<span class="span-kind-badge ${escapeHTML(kindClass)}">${escapeHTML(sp.kind || '?')}</span>
|
|
${escapeHTML(sp.name || '(unnamed)')}
|
|
</td>
|
|
<td>${escapeHTML(sp.kind || '-')}</td>
|
|
<td>${statusIcon(sp.status)}</td>
|
|
<td>${escapeHTML(formatDuration(sp.duration_ms))}</td>
|
|
</tr>
|
|
<tr class="span-detail-row" data-index="${i}" style="display:none">
|
|
<td colspan="4">
|
|
<div class="span-details-structured">${renderSpanPayload(sp)}</div>
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
function bindRunSpanRows() {
|
|
document.querySelectorAll('tr.run-span-row').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) return;
|
|
const isOpen = detailRow.style.display !== 'none';
|
|
detailRow.style.display = isOpen ? 'none' : 'table-row';
|
|
if (icon) icon.style.transform = isOpen ? '' : 'rotate(45deg)';
|
|
});
|
|
row.setAttribute('tabindex', '0');
|
|
row.setAttribute('role', 'button');
|
|
row.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
row.click();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Track active spans for the run detail live ops panel
|
|
let runLiveOps = {}; // spanID → { name, kind, startedAt, promptPreview, inputPreview }
|
|
let _runReloadTimer = null;
|
|
let _dashFeedRenderTimer = null;
|
|
|
|
async function renderRun(runID) {
|
|
app.innerHTML = '<div style="padding:2rem"><div class="skeleton-line" style="width:40%;height:1.5rem;margin-bottom:1rem"></div><div class="skeleton-line" style="width:60%;margin-bottom:2rem"></div>' + '<div class="table-container"><table><thead><tr><th>Name</th><th>Kind</th><th>Status</th><th>Duration</th></tr></thead><tbody>' + skeletonRows(5, 4) + '</tbody></table></div></div>';
|
|
runLiveOps = {};
|
|
let data;
|
|
try {
|
|
data = await api('/v1/runs/' + runID);
|
|
} catch (e) {
|
|
app.innerHTML = `<p class="empty-state">Error loading run: ${escapeHTML(e.message)}</p>`;
|
|
return;
|
|
}
|
|
|
|
const r = data.run;
|
|
const spans = data.spans || [];
|
|
const duration = r.ended_at
|
|
? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
|
|
: 'ongoing';
|
|
|
|
app.innerHTML = `
|
|
<a href="/sessions/${escapeHTML(r.session_id)}" class="back-link">← Back to Session</a>
|
|
<div class="page-header">
|
|
<h2>Run <span style="font-family:var(--font-mono);font-size:1.1rem;color:var(--accent)">${escapeHTML(runID.substring(0, 16))}…</span> ${statusIcon(r.status)}</h2>
|
|
<div class="meta-tiles">
|
|
<div class="meta-tile">
|
|
<div class="meta-tile-label">Started</div>
|
|
<div class="meta-tile-value">${escapeHTML(new Date(r.started_at).toLocaleString())}</div>
|
|
</div>
|
|
<div class="meta-tile">
|
|
<div class="meta-tile-label">Duration</div>
|
|
<div class="meta-tile-value" id="run-detail-duration">${escapeHTML(duration)}</div>
|
|
</div>
|
|
${r.model ? `<div class="meta-tile"><div class="meta-tile-label">Model</div><div class="meta-tile-value" style="font-size:0.78rem">${escapeHTML(r.model.replace(/^claude-/, ''))}</div></div>` : ''}
|
|
${r.tool_count ? `<div class="meta-tile"><div class="meta-tile-label">Tool Calls</div><div class="meta-tile-value">${r.tool_count}</div></div>` : ''}
|
|
</div>
|
|
</div>
|
|
${!r.ended_at ? '<div class="run-live-ops" id="run-live-ops"></div>' : ''}
|
|
<div class="section-title">
|
|
Spans <span class="count" id="run-detail-span-count">${spans.length}</span>
|
|
${!r.ended_at ? '<span class="live-indicator" style="margin-left:0.5rem"><span class="live-dot"></span>Live</span>' : ''}
|
|
</div>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Kind</th>
|
|
<th>Status</th>
|
|
<th>Duration</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="spans-body">
|
|
${renderRunSpansRows(spans)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
|
|
bindRunSpanRows();
|
|
|
|
document.querySelector('.back-link').addEventListener('click', e => {
|
|
e.preventDefault();
|
|
navigate('/sessions/' + r.session_id);
|
|
});
|
|
|
|
if (!r.ended_at) {
|
|
sessionsUnsubscribe = subscribeWS((msg) => handleRunWS(runID, msg));
|
|
}
|
|
}
|
|
|
|
function renderRunLiveOps() {
|
|
const el = document.getElementById('run-live-ops');
|
|
if (!el) return;
|
|
const ops = Object.values(runLiveOps);
|
|
if (ops.length === 0) {
|
|
el.innerHTML = '';
|
|
return;
|
|
}
|
|
el.innerHTML = `<div class="run-live-ops-inner">${ops.map(op => {
|
|
const elapsed = Math.floor((Date.now() - op.startedAt) / 1000);
|
|
const isSubagent = op.kind === 'agent' || op.subType === 'subagent';
|
|
const icon = isSubagent ? '◎' : op.kind === 'run' ? '◌' : '▸';
|
|
const label = isSubagent ? 'subagent' : op.kind === 'run' ? 'thinking' : 'tool';
|
|
const preview = op.promptPreview || op.inputPreview || '';
|
|
return `
|
|
<div class="run-live-op-pill ${label}">
|
|
<span class="run-live-op-spin">${icon}</span>
|
|
<span class="run-live-op-name">${escapeHTML(op.name)}</span>
|
|
${preview ? `<span class="run-live-op-preview">${escapeHTML(preview.length > 60 ? preview.slice(0, 60) + '…' : preview)}</span>` : ''}
|
|
<span class="run-live-op-time active-op-time" data-start="${op.startedAt}">${formatElapsed(elapsed)}</span>
|
|
</div>`;
|
|
}).join('')}</div>`;
|
|
}
|
|
|
|
function handleRunWS(runID, msg) {
|
|
if (msg.type !== 'message') return;
|
|
const correlation = getEnvelopeCorrelation(msg.data);
|
|
if (correlation?.run_id !== runID) return;
|
|
|
|
// Track live ops from WS without full reload
|
|
const eventType = getEnvelopeType(msg.data);
|
|
const attrs = getEnvelopeAttributes(msg.data);
|
|
const payload = getEnvelopePayload(msg.data);
|
|
const spanID = correlation.span_id;
|
|
|
|
if (eventType === 'span.start' && spanID) {
|
|
runLiveOps[spanID] = {
|
|
name: attrs.name || attrs.span_kind || 'span',
|
|
kind: attrs.span_kind || '',
|
|
subType: attrs.type || '',
|
|
startedAt: Date.now(),
|
|
promptPreview: payload.prompt_preview || '',
|
|
inputPreview: payload.input ? (typeof payload.input === 'string' ? payload.input.slice(0, 100) : '') : '',
|
|
};
|
|
renderRunLiveOps();
|
|
}
|
|
if (eventType === 'span.end' && spanID) {
|
|
delete runLiveOps[spanID];
|
|
renderRunLiveOps();
|
|
}
|
|
if (eventType === 'run.start') {
|
|
runLiveOps['__run__'] = {
|
|
name: 'Thinking…',
|
|
kind: 'run',
|
|
startedAt: Date.now(),
|
|
promptPreview: payload.prompt_preview || payload.message_preview || payload.message || '',
|
|
inputPreview: '',
|
|
};
|
|
renderRunLiveOps();
|
|
}
|
|
if (eventType === 'run.end') {
|
|
delete runLiveOps['__run__'];
|
|
runLiveOps = {};
|
|
renderRunLiveOps();
|
|
}
|
|
|
|
clearTimeout(_runReloadTimer);
|
|
_runReloadTimer = setTimeout(() => loadRunDetailData(runID), 500);
|
|
}
|
|
|
|
async function loadRunDetailData(runID) {
|
|
if (!isCurrentPath('/runs/' + runID)) return;
|
|
try {
|
|
const data = await api('/v1/runs/' + runID);
|
|
const spans = data.spans || [];
|
|
const r = data.run;
|
|
const tbody = document.getElementById('spans-body');
|
|
if (!tbody) return;
|
|
|
|
// Preserve expanded rows
|
|
const openIndices = new Set();
|
|
document.querySelectorAll('tr.span-detail-row').forEach(row => {
|
|
if (row.style.display !== 'none') openIndices.add(row.dataset.index);
|
|
});
|
|
|
|
tbody.innerHTML = renderRunSpansRows(spans);
|
|
|
|
// Restore expanded rows
|
|
document.querySelectorAll('tr.span-detail-row').forEach(row => {
|
|
if (openIndices.has(row.dataset.index)) {
|
|
row.style.display = 'table-row';
|
|
const hdr = document.querySelector(`tr.run-span-row[data-index="${row.dataset.index}"]`);
|
|
if (hdr) {
|
|
const icon = hdr.querySelector('.expand-icon');
|
|
if (icon) icon.style.transform = 'rotate(45deg)';
|
|
}
|
|
}
|
|
});
|
|
|
|
bindRunSpanRows();
|
|
|
|
const countEl = document.getElementById('run-detail-span-count');
|
|
if (countEl) countEl.textContent = spans.length;
|
|
|
|
if (r.ended_at) {
|
|
const durEl = document.getElementById('run-detail-duration');
|
|
if (durEl) durEl.textContent = formatDuration(new Date(r.ended_at) - new Date(r.started_at));
|
|
if (sessionsUnsubscribe) { sessionsUnsubscribe(); sessionsUnsubscribe = null; }
|
|
const liveSpan = document.querySelector('.section-title .live-indicator');
|
|
if (liveSpan) liveSpan.remove();
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to reload run detail:', e);
|
|
}
|
|
}
|
|
|
|
function renderSessionRunsRows(runs) {
|
|
if (!runs || runs.length === 0) {
|
|
return '<tr><td colspan="7" class="empty-state">No runs</td></tr>';
|
|
}
|
|
|
|
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 ? `
|
|
<div class="session-run-spans">
|
|
${spans.map(sp => {
|
|
const body = getSessionSpanSummary(sp);
|
|
return `
|
|
<div class="session-span-pill ${escapeHTML(sp.kind || 'unknown')}">
|
|
<span class="session-span-name">${escapeHTML(sp.name || sp.kind || 'span')}</span>
|
|
<span class="session-span-meta">${escapeHTML(body)}</span>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
` : '<div class="empty-state" style="padding:0.5rem 0">No spans yet</div>';
|
|
|
|
return `
|
|
<tr class="clickable expandable-run" data-run="${escapeHTML(r.run_id)}" data-index="${i}">
|
|
<td class="id-cell"><span class="expand-icon"></span>${escapeHTML(r.run_id.substring(0, 12))}...</td>
|
|
<td>${statusIcon(r.status)}</td>
|
|
<td><span class="model-badge">${modelLabel}</span></td>
|
|
<td>${r.tool_count || 0}</td>
|
|
<td>${r.span_count}</td>
|
|
<td>${escapeHTML(runDuration)}</td>
|
|
<td>${escapeHTML(new Date(r.started_at).toLocaleTimeString())}</td>
|
|
</tr>
|
|
<tr class="span-detail-row" data-index="${i}" style="display:none">
|
|
<td colspan="7">
|
|
<div class="session-run-detail">
|
|
<div class="section-title" style="margin-bottom:0.5rem">Spans <span class="count">${spans.length}</span></div>
|
|
${spansHTML}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).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));
|
|
row.setAttribute('tabindex', '0');
|
|
row.setAttribute('role', 'button');
|
|
row.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
row.click();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function renderInfrastructure() {
|
|
app.innerHTML = '<div class="page-header"><h2>Infrastructure</h2></div><p class="empty-state">Loading...</p>';
|
|
|
|
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 = `<div class="page-header"><h2>Infrastructure</h2></div><p class="empty-state">Error: ${escapeHTML(e.message)}</p>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
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();
|
|
return;
|
|
}
|
|
|
|
if (eventType === 'swarm.service.snapshot') {
|
|
mergeSwarmServiceSnapshot(msg.data);
|
|
if (isCurrentPath('/infrastructure')) renderInfraGrid();
|
|
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');
|
|
const homelabServices = getK8sHomelabServices();
|
|
|
|
app.innerHTML = `
|
|
<div class="page-header">
|
|
<h2>Infrastructure <span class="live-indicator"><span class="live-dot"></span>Live</span></h2>
|
|
</div>
|
|
|
|
<div class="infra-section">
|
|
<p class="infra-section-title">VMs</p>
|
|
${vmNames.length === 0
|
|
? '<p class="empty-state">No VM data</p>'
|
|
: `<div class="vm-grid">${vmNames.map(name => renderVMCard(name)).join('')}</div>`
|
|
}
|
|
</div>
|
|
|
|
<div class="infra-section">
|
|
<p class="infra-section-title">Swarm Services</p>
|
|
${swarmServices.length === 0
|
|
? '<p class="empty-state">No swarm service data</p>'
|
|
: `<div class="service-grid">${swarmServices.map(svc => renderServiceCard(svc)).join('')}</div>`
|
|
}
|
|
</div>
|
|
|
|
<div class="infra-section">
|
|
<p class="infra-section-title">K8s Homelab</p>
|
|
${homelabServices.length === 0
|
|
? '<p class="empty-state">No k8s homelab service data</p>'
|
|
: `<div class="service-grid">${homelabServices.map(svc => renderHomelabServiceCard(svc)).join('')}</div>`
|
|
}
|
|
</div>
|
|
|
|
<div class="infra-section">
|
|
<p class="infra-section-title">Agentmon</p>
|
|
${agentmonServices.length === 0
|
|
? '<p class="empty-state">No agentmon service data</p>'
|
|
: `<div class="service-grid">${agentmonServices.map(svc => renderServiceCard(svc)).join('')}</div>`
|
|
}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 `
|
|
<div class="vm-card">
|
|
<div class="vm-card-header">
|
|
<h3>${escapeHTML(inst.name || name)}</h3>
|
|
<div class="vm-status ${host.state === 'running' ? 'running' : 'stopped'}">
|
|
${host.state === 'running' ? 'Running' : 'Stopped'}
|
|
</div>
|
|
</div>
|
|
<div class="vm-updated">Updated ${escapeHTML(relativeTime(getEnvelopeTS(evt)))}</div>
|
|
<table class="vm-stats">
|
|
<tr><td>Host</td><td>${escapeHTML(inst.host || '-')}</td></tr>
|
|
<tr><td>Domain</td><td>${escapeHTML(inst.domain || '-')}</td></tr>
|
|
<tr><td>vCPUs</td><td>${host.vcpus || '-'}</td></tr>
|
|
<tr><td>Memory</td><td>${escapeHTML(formatBytes(host.memory_kib ? host.memory_kib * 1024 : 0) || '-')}</td></tr>
|
|
<tr><td>Disk</td><td>${escapeHTML(formatBytes(host.disk_actual_bytes) || '-')}</td></tr>
|
|
<tr><td>Autostart</td><td>${host.autostart ? 'Yes' : 'No'}</td></tr>
|
|
</table>
|
|
${guest ? `
|
|
<div class="vm-card-divider"></div>
|
|
<table class="vm-stats">
|
|
<tr><td>Gateway</td><td style="${guest.service_active ? 'color:var(--success)' : 'color:var(--error)'}">${guest.service_active ? 'Active' : 'Inactive'}</td></tr>
|
|
<tr><td>HTTP</td><td style="${guest.http_status === 200 ? 'color:var(--success)' : 'color:var(--error)'}">${guest.http_status || 'N/A'}</td></tr>
|
|
<tr><td>Version</td><td>${escapeHTML(guest.version || '-')}</td></tr>
|
|
<tr><td>Guest Mem</td><td>${guest.memory_percent !== undefined ? guest.memory_percent.toFixed(1) : '-'}%</td></tr>
|
|
<tr><td>Guest Disk</td><td>${guest.disk_percent !== undefined ? guest.disk_percent.toFixed(1) : '-'}%</td></tr>
|
|
<tr><td>Load</td><td>${guest.load_average !== undefined ? guest.load_average.toFixed(2) : '-'}</td></tr>
|
|
<tr><td>Uptime</td><td>${escapeHTML(guest.service_uptime || '-')}</td></tr>
|
|
</table>
|
|
` : ''}
|
|
${issues && Object.values(issues).some(Boolean) ? `
|
|
<div class="vm-card-divider"></div>
|
|
<div class="vm-issues-label">Issues</div>
|
|
<div class="vm-issues">
|
|
${Object.entries(issues).filter(([, value]) => value).map(([key]) => `
|
|
<span class="issue ${escapeHTML(key)}">${escapeHTML(key.replace(/_/g, ' '))}</span>
|
|
`).join('')}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 `
|
|
<div class="service-card-header">
|
|
<div>
|
|
<div class="service-card-name">${escapeHTML(svc.name)}</div>
|
|
<div class="service-role-tag">${escapeHTML(svc.role || '')}</div>
|
|
</div>
|
|
<span class="service-badge ${escapeHTML(svc.status || 'down')}">${escapeHTML(svc.status || 'down')}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function serviceStatRow(label, value, valueClass) {
|
|
return `
|
|
<div class="service-stat-row">
|
|
<span class="service-stat-label">${escapeHTML(label)}</span>
|
|
<span class="service-stat-value${valueClass ? ' ' + valueClass : ''}">${value}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 `
|
|
<div class="service-card">
|
|
${serviceCardHeader(svc)}
|
|
<div style="display:flex;align-items:baseline;gap:0.5rem">
|
|
<span class="llm-model-count">${modelCount !== undefined ? modelCount : '-'}</span>
|
|
<span class="llm-model-label">models</span>
|
|
</div>
|
|
${cooldowns > 0 ? `<div class="llm-cooldown-banner">⚠ ${cooldowns} model${cooldowns > 1 ? 's' : ''} in cooldown</div>` : ''}
|
|
<div class="service-stats">
|
|
${serviceStatRow('HTTP', httpStatus ? String(httpStatus) : '-', httpClass)}
|
|
${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
|
|
${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderDBCard(svc) {
|
|
const healthClass = svc.health_state === 'healthy' ? 'ok' : svc.health_state === 'unhealthy' ? 'bad' : '';
|
|
return `
|
|
<div class="service-card">
|
|
${serviceCardHeader(svc)}
|
|
<div class="service-stats">
|
|
${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')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 `
|
|
<div class="service-card">
|
|
${serviceCardHeader(svc)}
|
|
<div class="service-stats">
|
|
${serviceStatRow('HTTP', httpStatus ? String(httpStatus) : '-', httpClass)}
|
|
${ms !== undefined ? serviceStatRow('Response', ms + 'ms', ms < 500 ? 'ok' : 'warn') : ''}
|
|
${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderMCPCard(svc) {
|
|
const extra = svc.extra || {};
|
|
const reachable = extra.port_reachable;
|
|
return `
|
|
<div class="service-card">
|
|
${serviceCardHeader(svc)}
|
|
<div class="service-stats">
|
|
${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), '')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderVoiceCard(svc) {
|
|
const healthClass = svc.health_state === 'healthy' ? 'ok' : svc.health_state === 'unhealthy' ? 'bad' : '';
|
|
return `
|
|
<div class="service-card">
|
|
${serviceCardHeader(svc)}
|
|
<div class="service-stats">
|
|
${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), '')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderAutomationCard(svc) {
|
|
const healthClass = svc.health_state === 'healthy' ? 'ok' : svc.health_state === 'unhealthy' ? 'bad' : '';
|
|
return `
|
|
<div class="service-card">
|
|
${serviceCardHeader(svc)}
|
|
<div class="service-stats">
|
|
${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), '')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderAPICard(svc) {
|
|
const httpStatus = svc.http_status;
|
|
const httpClass = httpStatus === 200 ? 'ok' : httpStatus ? 'bad' : '';
|
|
return `
|
|
<div class="service-card">
|
|
${serviceCardHeader(svc)}
|
|
<div class="service-stats">
|
|
${serviceStatRow('HTTP', httpStatus ? String(httpStatus) : '-', httpClass)}
|
|
${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
|
|
${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderWorkerCard(svc) {
|
|
return `
|
|
<div class="service-card">
|
|
${serviceCardHeader(svc)}
|
|
<div class="service-stats">
|
|
${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')}
|
|
${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderGenericServiceCard(svc) {
|
|
return `
|
|
<div class="service-card">
|
|
${serviceCardHeader(svc)}
|
|
<div class="service-stats">
|
|
${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')}
|
|
${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function getK8sHomelabServices() {
|
|
const services = [];
|
|
for (const [name, evt] of Object.entries(openclawState.instances)) {
|
|
const payload = getEnvelopePayload(evt);
|
|
if (!payload.minio) continue;
|
|
|
|
const minio = payload.minio;
|
|
services.push({
|
|
name: 'minio-storage',
|
|
role: 'storage',
|
|
category: 'k8s homelab',
|
|
sourceInstance: name,
|
|
status: minio.reachable ? 'healthy' : 'down',
|
|
endpoint: minio.endpoint || '',
|
|
bucket: minio.bucket || '',
|
|
prefix: minio.prefix || '',
|
|
objectCount: minio.object_count,
|
|
totalBytes: minio.total_bytes,
|
|
latestBackup: minio.latest_backup || '',
|
|
httpStatus: minio.http_status,
|
|
error: minio.error || '',
|
|
});
|
|
}
|
|
return services;
|
|
}
|
|
|
|
function renderHomelabServiceCard(svc) {
|
|
const httpClass = svc.httpStatus === 200 ? 'ok' : svc.httpStatus ? 'bad' : '';
|
|
return `
|
|
<div class="service-card">
|
|
${serviceCardHeader(svc)}
|
|
<div class="service-stats">
|
|
${serviceStatRow('Endpoint', escapeHTML(svc.endpoint || '-'), '')}
|
|
${serviceStatRow('Bucket', escapeHTML(svc.bucket ? `${svc.bucket}/${svc.prefix || ''}` : '-'), '')}
|
|
${serviceStatRow('Usage', escapeHTML(formatBytes(svc.totalBytes) || '-'), '')}
|
|
${serviceStatRow('Objects', escapeHTML(svc.objectCount !== undefined ? String(svc.objectCount) : '-'), '')}
|
|
${serviceStatRow('HTTP', svc.httpStatus ? String(svc.httpStatus) : '-', httpClass)}
|
|
${serviceStatRow('Source', escapeHTML(svc.sourceInstance || '-'), '')}
|
|
${serviceStatRow('Latest', escapeHTML(svc.latestBackup ? relativeTime(svc.latestBackup) : '-'), '')}
|
|
${svc.error ? serviceStatRow('Error', escapeHTML(svc.error), 'bad') : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 = Object.keys(openclawState.instances).sort();
|
|
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 !!openclawState.instances[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) {
|
|
const payload = getEnvelopePayload(evt);
|
|
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(),
|
|
promptPreview: payload.prompt_preview || '',
|
|
inputPreview: payload.input ? (typeof payload.input === 'string' ? payload.input : JSON.stringify(payload.input)) : '',
|
|
spanID: correlation.span_id,
|
|
runID: correlation.run_id || '',
|
|
};
|
|
}
|
|
if (eventType === 'span.end' && correlation.span_id) {
|
|
const op = agent.operations['s:' + correlation.span_id];
|
|
if (op) {
|
|
const payload = getEnvelopePayload(evt);
|
|
op.resultPreview = payload.result_preview || '';
|
|
op.status = payload.status || '';
|
|
op.durationMS = payload.duration_ms || 0;
|
|
op.endedAt = new Date(getEnvelopeTS(evt)).getTime() || Date.now();
|
|
op.usage = payload.usage || null;
|
|
// Keep completed ops briefly for display, then remove and refresh stream
|
|
setTimeout(() => {
|
|
delete agent.operations['s:' + correlation.span_id];
|
|
refreshThinkingStream(agent);
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
if (eventType === 'run.start' && correlation.run_id) {
|
|
const payload = getEnvelopePayload(evt);
|
|
agent.operations['r:' + correlation.run_id] = {
|
|
type: 'run',
|
|
name: 'Thinking…',
|
|
kind: 'run',
|
|
startedAt: new Date(getEnvelopeTS(evt)).getTime() || Date.now(),
|
|
promptPreview: payload.prompt_preview || payload.message_preview || payload.message || '',
|
|
runID: correlation.run_id,
|
|
};
|
|
}
|
|
if (eventType === 'run.end' && correlation.run_id) {
|
|
const op = agent.operations['r:' + correlation.run_id];
|
|
if (op) {
|
|
const payload = getEnvelopePayload(evt);
|
|
op.endedAt = new Date(getEnvelopeTS(evt)).getTime() || Date.now();
|
|
op.status = payload.status || '';
|
|
op.usage = payload.usage || null;
|
|
op.model = payload.model || '';
|
|
op.thinkingTokens = (payload.usage && payload.usage.thinking_tokens) || 0;
|
|
setTimeout(() => {
|
|
delete agent.operations['r:' + correlation.run_id];
|
|
refreshThinkingStream(agent);
|
|
}, 2000);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function isDashboardFeedEvent(evt) {
|
|
const eventType = getEnvelopeType(evt);
|
|
return isAgentTimelineEvent(evt) || eventType === 'metric.snapshot';
|
|
}
|
|
|
|
function getDashboardInfraPill() {
|
|
const services = Object.values(swarmState.services);
|
|
if (services.length === 0) {
|
|
return {
|
|
className: 'inactive',
|
|
name: 'infra',
|
|
label: 'unknown',
|
|
};
|
|
}
|
|
|
|
const unhealthy = services.filter(svc => svc.status !== 'healthy');
|
|
if (unhealthy.length === 0) {
|
|
return {
|
|
className: 'active',
|
|
name: 'infra',
|
|
label: 'all running',
|
|
};
|
|
}
|
|
|
|
const degradedOnly = unhealthy.every(svc => svc.status === 'degraded');
|
|
return {
|
|
className: degradedOnly ? 'degraded' : 'inactive',
|
|
name: 'infra',
|
|
label: degradedOnly ? 'degraded' : `${unhealthy.length} issue${unhealthy.length === 1 ? '' : 's'}`,
|
|
};
|
|
}
|
|
|
|
async function renderAgents() {
|
|
agentsState = createAgentsState();
|
|
|
|
app.innerHTML = `
|
|
<div class="page-header">
|
|
<h2>Agents <span class="live-indicator"><span class="live-dot"></span>Live</span></h2>
|
|
</div>
|
|
<div class="agents-toolbar">
|
|
<div class="view-toggle" id="agents-view-toggle">
|
|
<button class="view-toggle-btn active" data-mode="overview" type="button">Overview</button>
|
|
<button class="view-toggle-btn" data-mode="live" type="button">Live</button>
|
|
</div>
|
|
</div>
|
|
<div class="agents-summary-row" id="agents-summary"></div>
|
|
<div id="agents-content"><p class="empty-state">Loading...</p></div>
|
|
`;
|
|
|
|
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 =
|
|
`<p class="empty-state">Error loading agent activity: ${escapeHTML(e.message)}</p>`;
|
|
}
|
|
|
|
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 = '<div class="agent-lanes" id="agents-lanes"></div>';
|
|
|
|
const lanesEl = document.getElementById('agents-lanes');
|
|
if (!lanesEl) return;
|
|
|
|
const agentKeys = getSortedAgentKeys();
|
|
|
|
if (agentKeys.length === 0) {
|
|
lanesEl.innerHTML = '<p class="empty-state">No recent agent activity</p>';
|
|
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 ? `<div class="active-ops">${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 `
|
|
<div class="active-op${stale ? ' stale' : ''}${kindClass}">
|
|
<span class="active-op-dot"></span>
|
|
<span class="active-op-name">${escapeHTML(op.name)}</span>
|
|
<span class="active-op-time" data-start="${op.startedAt}">${formatElapsed(elapsed)}</span>
|
|
${stale ? '<span class="active-op-stale">(stale?)</span>' : ''}
|
|
</div>`;
|
|
}).join('')}</div>` : '';
|
|
|
|
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 ? `<div class="timeline-detail">${escapeHTML(details)}</div>` : '';
|
|
const expandHTML = details ? '<button class="timeline-expand-hint" type="button">details</button>' : '';
|
|
|
|
return `
|
|
<div class="timeline-event">
|
|
<div class="timeline-event-header">
|
|
${getEventIcon(eventType)}
|
|
<span class="timeline-event-type">${escapeHTML(getEventLabel(eventType))}</span>
|
|
<span class="timeline-event-time">${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())}</span>
|
|
</div>
|
|
${getEventBody(evt)}
|
|
${expandHTML}
|
|
${detailHTML}
|
|
</div>`;
|
|
}).join('') : '<p class="empty-state">No recent activity</p>';
|
|
|
|
return `
|
|
<div class="agent-lane" data-agent-key="${escapeHTML(key)}">
|
|
<div class="agent-lane-header">
|
|
<div>
|
|
<div class="agent-lane-name">
|
|
<span class="agent-lane-dot ${isOnline ? 'online' : 'offline'}"></span>
|
|
${escapeHTML(agent.name || key)}
|
|
</div>
|
|
<div class="agent-lane-meta">${escapeHTML(agent.framework || 'unknown')}${agent.host && agent.host !== agent.name ? ' · ' + escapeHTML(agent.host) : ''}</div>
|
|
</div>
|
|
<span class="agent-lane-status${statusClass}">${statusText}</span>
|
|
</div>
|
|
${opsHTML}
|
|
<div class="agent-lane-events">${eventsHTML}</div>
|
|
</div>`;
|
|
}).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 = `
|
|
<div class="agents-summary-stat">Live Agents <span class="value">${liveAgents}</span></div>
|
|
<div class="agents-summary-stat">Active Subagents <span class="value">${liveSubagents}</span></div>
|
|
<div class="agents-summary-stat">Runs Today <span class="value">${s.messages}</span></div>
|
|
<div class="agents-summary-stat">Tool Calls <span class="value">${s.tools}</span></div>
|
|
<div class="agents-summary-stat">Errors <span class="value">${s.errors}</span></div>
|
|
`;
|
|
}
|
|
|
|
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 formatTokenCount(value) {
|
|
if (value === undefined || value === null || value === '') return '-';
|
|
const n = Number(value);
|
|
if (!Number.isFinite(n)) return String(value);
|
|
if (n === 0) return '0';
|
|
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
|
|
return String(n);
|
|
}
|
|
|
|
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(`<div class="live-detail-row"><span class="k">input</span><span class="v">${escapeHTML(typeof payload.input === 'string' ? payload.input : JSON.stringify(payload.input))}</span></div>`);
|
|
}
|
|
if (payload.result_preview) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">result</span><span class="v">${escapeHTML(String(payload.result_preview))}</span></div>`);
|
|
}
|
|
}
|
|
if ((eventType === 'span.start' || eventType === 'span.end') && (attrs.span_kind === 'agent' || attrs.type === 'subagent')) {
|
|
if (payload.prompt_preview) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">prompt</span><span class="v">${escapeHTML(String(payload.prompt_preview))}</span></div>`);
|
|
}
|
|
if (payload.usage && payload.usage.total_tokens !== undefined) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">tokens</span><span class="v">${escapeHTML(formatCount(payload.usage.total_tokens))}</span></div>`);
|
|
}
|
|
if (payload.usage && payload.usage.total_cost !== undefined) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">cost</span><span class="v">${escapeHTML(formatCost(payload.usage.total_cost))}</span></div>`);
|
|
}
|
|
}
|
|
if (eventType === 'run.start') {
|
|
const preview = payload.prompt_preview || payload.message_preview || payload.message || '';
|
|
if (preview) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">prompt</span><span class="v">${escapeHTML(String(preview))}</span></div>`);
|
|
}
|
|
}
|
|
if (eventType === 'run.end') {
|
|
if (payload.model) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">model</span><span class="v">${escapeHTML(String(payload.model))}</span></div>`);
|
|
}
|
|
if (payload.usage && payload.usage.total_tokens !== undefined) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">tokens</span><span class="v">${escapeHTML(formatCount(payload.usage.total_tokens))}</span></div>`);
|
|
}
|
|
if (payload.duration_ms !== undefined) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">duration</span><span class="v">${escapeHTML(formatDuration(payload.duration_ms))}</span></div>`);
|
|
}
|
|
}
|
|
if (eventType === 'metric.snapshot' && payload.metrics) {
|
|
if (payload.metrics.model) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">model</span><span class="v">${escapeHTML(String(payload.metrics.model))}</span></div>`);
|
|
}
|
|
if (payload.metrics.usage && payload.metrics.usage.total_tokens !== undefined) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">tokens</span><span class="v">${escapeHTML(formatCount(payload.metrics.usage.total_tokens))}</span></div>`);
|
|
}
|
|
if (payload.metrics.usage && payload.metrics.usage.total_cost !== undefined) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">cost</span><span class="v">${escapeHTML(formatCost(payload.metrics.usage.total_cost))}</span></div>`);
|
|
}
|
|
}
|
|
if (eventType === 'error') {
|
|
const errPayload = payload.error || {};
|
|
if (errPayload.type) {
|
|
parts.push(`<div class="live-detail-row"><span class="k">type</span><span class="v">${escapeHTML(String(errPayload.type))}</span></div>`);
|
|
}
|
|
}
|
|
|
|
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(`<div class="live-detail-row ids"><span class="k">ids</span><span class="v">${escapeHTML(ids.join(' · '))}</span></div>`);
|
|
}
|
|
|
|
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 refreshThinkingStream(agent) {
|
|
if (!agent) return;
|
|
const selectedKey = agentsState.selectedAgentKey;
|
|
if (agent.key !== selectedKey) return;
|
|
const streamEl = document.getElementById('thinking-stream-' + selectedKey);
|
|
if (streamEl) {
|
|
streamEl.innerHTML = renderThinkingStream(agent);
|
|
}
|
|
}
|
|
|
|
function renderThinkingStream(agent) {
|
|
const now = Date.now();
|
|
const ops = Object.values(agent.operations).filter(op => {
|
|
// Show ended ops only if recently ended (within 3s)
|
|
if (op.endedAt) return (now - op.endedAt) < 3000;
|
|
return (now - op.startedAt) < 300000;
|
|
});
|
|
|
|
if (ops.length === 0) {
|
|
return '<div class="thinking-stream-empty">Idle — waiting for activity</div>';
|
|
}
|
|
|
|
return ops.map(op => {
|
|
const elapsed = op.endedAt
|
|
? Math.floor((op.endedAt - op.startedAt) / 1000)
|
|
: Math.floor((now - op.startedAt) / 1000);
|
|
const isEnded = !!op.endedAt;
|
|
const isSubagent = op.kind === 'agent' || op.subType === 'subagent';
|
|
const isRun = op.kind === 'run';
|
|
const isTool = op.kind === 'tool';
|
|
|
|
let icon, kindLabel, kindClass;
|
|
if (isRun) {
|
|
icon = isEnded ? '✓' : '◌';
|
|
kindLabel = isEnded ? (op.status === 'success' ? 'Done' : op.status || 'Done') : 'Thinking';
|
|
kindClass = 'thinking-op-run' + (isEnded ? ' ended' : ' active');
|
|
} else if (isSubagent) {
|
|
icon = isEnded ? '✓' : '◎';
|
|
kindLabel = isEnded ? (op.status === 'success' ? 'Subagent done' : 'Subagent ' + (op.status || 'done')) : 'Subagent';
|
|
kindClass = 'thinking-op-subagent' + (isEnded ? ' ended' : ' active');
|
|
} else if (isTool) {
|
|
icon = isEnded ? '✓' : '▸';
|
|
kindLabel = isEnded ? (op.status === 'success' ? 'Tool done' : 'Tool ' + (op.status || 'done')) : 'Tool';
|
|
kindClass = 'thinking-op-tool' + (isEnded ? ' ended' : ' active');
|
|
} else {
|
|
icon = '·';
|
|
kindLabel = op.name;
|
|
kindClass = 'thinking-op-other' + (isEnded ? ' ended' : ' active');
|
|
}
|
|
|
|
const preview = op.promptPreview || op.inputPreview || '';
|
|
const result = op.resultPreview || '';
|
|
const usage = op.usage || {};
|
|
const thinkingToks = op.thinkingTokens || usage.thinking_tokens || 0;
|
|
const totalToks = usage.total_tokens || 0;
|
|
|
|
const navigableRunID = isRun ? op.runID : (isSubagent ? op.runID : '');
|
|
const clickable = navigableRunID ? ` clickable" data-run-id="${escapeHTML(navigableRunID)}` : '';
|
|
|
|
return `
|
|
<div class="thinking-op ${kindClass}${clickable ? ' thinking-op-link' : ''}"${clickable ? ` data-run-id="${escapeHTML(navigableRunID)}"` : ''}>
|
|
<div class="thinking-op-header">
|
|
<span class="thinking-op-icon${isRun && !isEnded ? ' spin' : ''}">${icon}</span>
|
|
<span class="thinking-op-kind">${escapeHTML(kindLabel)}</span>
|
|
<span class="thinking-op-name">${escapeHTML(op.name)}</span>
|
|
<span class="thinking-op-elapsed${isEnded ? '' : ' live'}" data-start="${op.startedAt}" data-ended="${op.endedAt || ''}">
|
|
${isEnded ? formatElapsed(elapsed) : `<span class="active-op-time" data-start="${op.startedAt}">${formatElapsed(elapsed)}</span>`}
|
|
</span>
|
|
${navigableRunID ? '<span class="thinking-op-arrow">→</span>' : ''}
|
|
</div>
|
|
${preview ? `<div class="thinking-op-preview">${escapeHTML(preview.length > 180 ? preview.slice(0, 180) + '…' : preview)}</div>` : ''}
|
|
${result ? `<div class="thinking-op-result">${escapeHTML(result.length > 180 ? result.slice(0, 180) + '…' : result)}</div>` : ''}
|
|
${(thinkingToks || totalToks) ? `<div class="thinking-op-tokens">${thinkingToks ? `<span class="thinking-tok-badge">🧠 ${formatTokenCount(thinkingToks)} thinking</span>` : ''}${totalToks ? `<span class="thinking-tok-badge">⚡ ${formatTokenCount(totalToks)} total</span>` : ''}</div>` : ''}
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderAgentsLive() {
|
|
const contentEl = document.getElementById('agents-content');
|
|
if (!contentEl) return;
|
|
|
|
const agentKeys = getSortedAgentKeys();
|
|
const selectedKey = ensureSelectedAgentKey();
|
|
if (!selectedKey || agentKeys.length === 0) {
|
|
contentEl.innerHTML = '<p class="empty-state">No recent agent activity</p>';
|
|
return;
|
|
}
|
|
|
|
const selected = agentsState.agents[selectedKey];
|
|
const summary = getAgentLiveSummary(selected);
|
|
const recent = selected.events.slice(-80).reverse();
|
|
const runGroups = groupAgentEventsByRun(recent);
|
|
|
|
contentEl.innerHTML = `
|
|
<div class="agents-live-layout">
|
|
<aside class="agents-live-sidebar">
|
|
<div class="section-title">Agents</div>
|
|
<div class="agent-picker" id="agent-picker">
|
|
${agentKeys.map(key => {
|
|
const agent = agentsState.agents[key];
|
|
const active = key === selectedKey ? ' active' : '';
|
|
const online = isAgentOnline(agent) ? 'online' : 'offline';
|
|
const sessions = Object.keys(agent.sessions).length;
|
|
const ops = getAgentDisplayOps(agent).length;
|
|
return `
|
|
<button class="agent-picker-item${active}" data-agent-key="${escapeHTML(key)}" type="button">
|
|
<span class="agent-picker-dot ${online}"></span>
|
|
<span class="agent-picker-main">
|
|
<span class="agent-picker-name">${escapeHTML(getAgentLabel(agent))}</span>
|
|
<span class="agent-picker-meta">${escapeHTML(agent.framework || 'unknown')} · ${sessions} sessions · ${ops} ops</span>
|
|
</span>
|
|
</button>`;
|
|
}).join('')}
|
|
</div>
|
|
</aside>
|
|
<section class="agents-live-main">
|
|
<div class="agents-live-header">
|
|
<div>
|
|
<div class="agent-lane-name"><span class="agent-lane-dot ${isAgentOnline(selected) ? 'online' : 'offline'}"></span>${escapeHTML(getAgentLabel(selected))}</div>
|
|
<div class="agent-lane-meta">${escapeHTML(selected.framework || 'unknown')}${selected.host && selected.host !== selected.name ? ' · ' + escapeHTML(selected.host) : ''}</div>
|
|
</div>
|
|
<div class="agents-live-badges">
|
|
<span class="agents-live-badge">${summary.sessionIDs.length} sessions</span>
|
|
<span class="agents-live-badge">${summary.activeSubagents.length} subagents</span>
|
|
<span class="agents-live-badge">${summary.activeTools.length} tools</span>
|
|
</div>
|
|
</div>
|
|
<div class="agents-live-cards">
|
|
<div class="agents-live-card thinking-stream-card">
|
|
<div class="agents-live-card-title">
|
|
Live Operations
|
|
${summary.activeOps.length > 0 ? `<span class="live-indicator" style="margin-left:0.5rem"><span class="live-dot"></span>${summary.activeOps.length} active</span>` : ''}
|
|
</div>
|
|
<div class="thinking-stream" id="thinking-stream-${escapeHTML(selectedKey)}">
|
|
${renderThinkingStream(selected)}
|
|
</div>
|
|
</div>
|
|
<div class="agents-live-card">
|
|
<div class="agents-live-card-title">Last Run</div>
|
|
<div class="live-kv"><span>Status</span><strong>${escapeHTML(summary.latestRunStatus || '—')}</strong></div>
|
|
<div class="live-kv"><span>Model</span><strong>${escapeHTML(summary.latestModel || '—')}</strong></div>
|
|
<div class="live-kv"><span>Tokens</span><strong>${escapeHTML(formatTokenCount(summary.latestUsage ? summary.latestUsage.total_tokens : null))}</strong></div>
|
|
<div class="live-kv"><span>Thinking</span><strong class="thinking-toks">${escapeHTML(formatTokenCount(summary.latestUsage ? summary.latestUsage.thinking_tokens : null))}</strong></div>
|
|
<div class="live-kv"><span>Cost</span><strong>${escapeHTML(formatCost(summary.latestUsage ? summary.latestUsage.total_cost : null))}</strong></div>
|
|
${summary.latestError ? `<div class="live-kv error-kv"><span>Error</span><strong>${escapeHTML(summary.latestError)}</strong></div>` : ''}
|
|
</div>
|
|
<div class="agents-live-card">
|
|
<div class="agents-live-card-title">Context Window</div>
|
|
<div class="live-kv"><span>Input</span><strong>${escapeHTML(formatTokenCount(summary.latestContextWindow ? summary.latestContextWindow.input_tokens : null))}</strong></div>
|
|
<div class="live-kv"><span>Output</span><strong>${escapeHTML(formatTokenCount(summary.latestContextWindow ? summary.latestContextWindow.output_tokens : null))}</strong></div>
|
|
<div class="live-kv"><span>Used</span><strong>${escapeHTML(formatTokenCount(summary.latestContextWindow ? summary.latestContextWindow.used_tokens : null))}</strong></div>
|
|
<div class="live-kv"><span>Remaining</span><strong>${escapeHTML(formatTokenCount(summary.latestContextWindow ? summary.latestContextWindow.tokens_remaining : null))}</strong></div>
|
|
${summary.latestContextWindow && summary.latestContextWindow.max_tokens ? `
|
|
<div class="context-bar">
|
|
<div class="context-bar-fill" style="width:${Math.min(100, ((summary.latestContextWindow.used_tokens || 0) / summary.latestContextWindow.max_tokens * 100)).toFixed(1)}%"></div>
|
|
</div>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="agents-live-timeline">
|
|
${runGroups.length > 0 ? runGroups.map(group => `
|
|
<section class="live-run-group">
|
|
<div class="live-run-group-header">
|
|
<div class="live-run-group-title">${escapeHTML(getRunGroupLabel(group.runID, group.events))}</div>
|
|
<div class="live-run-group-meta">
|
|
<span>${escapeHTML(group.runID ? `run ${group.runID.slice(0, 12)}...` : 'session-only')}</span>
|
|
<span>${escapeHTML(group.subagents.size > 0 ? `${group.subagents.size} subagents` : '0 subagents')}</span>
|
|
<span>${escapeHTML(group.tools.size > 0 ? `${group.tools.size} tools` : '0 tools')}</span>
|
|
</div>
|
|
</div>
|
|
<div class="live-run-events">
|
|
${group.events.map(evt => `
|
|
<div class="timeline-event live-event">
|
|
<div class="timeline-event-header">
|
|
${getEventIcon(getEnvelopeType(evt))}
|
|
<span class="timeline-event-type">${escapeHTML(getEventLabel(getEnvelopeType(evt)))}</span>
|
|
<span class="timeline-event-time">${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())}</span>
|
|
</div>
|
|
${getEventBody(evt)}
|
|
<div class="live-detail-grid">${buildLiveEventContext(evt)}</div>
|
|
</div>`).join('')}
|
|
</div>
|
|
</section>
|
|
`).join('') : '<p class="empty-state">No recent activity</p>'}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
`;
|
|
|
|
contentEl.querySelectorAll('[data-agent-key]').forEach(button => {
|
|
button.addEventListener('click', () => selectAgent(button.dataset.agentKey || '', 'live'));
|
|
});
|
|
|
|
// Delegate thinking-op clicks — bound once on stable container, survives 1s stream refresh
|
|
const mainSection = contentEl.querySelector('.agents-live-main');
|
|
if (mainSection) {
|
|
mainSection.addEventListener('click', e => {
|
|
const op = e.target.closest('.thinking-op[data-run-id]');
|
|
if (op && op.dataset.runId) {
|
|
navigate('/runs/' + op.dataset.runId);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function renderAgentVMStrip() {
|
|
// VM online/offline state is shown in each lane header via getVMStatus().
|
|
// Re-render lanes to pick up the updated openclawState.
|
|
renderAgentsContent();
|
|
}
|
|
|
|
let _agentsRenderTimer = null;
|
|
|
|
function scheduleAgentsRender() {
|
|
if (_agentsRenderTimer) return;
|
|
_agentsRenderTimer = requestAnimationFrame(() => {
|
|
_agentsRenderTimer = null;
|
|
renderAgentsContent();
|
|
});
|
|
}
|
|
|
|
function handleAgentsWS(msg) {
|
|
if (msg.type !== 'message') return;
|
|
|
|
const eventType = getEnvelopeType(msg.data);
|
|
if (eventType === 'openclaw.snapshot') {
|
|
mergeOpenClawEvents([msg.data]);
|
|
scheduleAgentsRender();
|
|
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]);
|
|
scheduleAgentsRender();
|
|
}
|
|
|
|
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', '<span class="active-op-stale">(stale?)</span>');
|
|
}
|
|
}
|
|
});
|
|
|
|
}
|
|
|
|
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 '<div class="event-icon message-in">↓</div>';
|
|
case 'run.end':
|
|
return '<div class="event-icon message-out">↑</div>';
|
|
case 'span.start':
|
|
case 'span.end':
|
|
return '<div class="event-icon tool">⚙</div>';
|
|
case 'error':
|
|
return '<div class="event-icon error">!</div>';
|
|
case 'session.start':
|
|
case 'session.end':
|
|
return '<div class="event-icon session">○</div>';
|
|
default:
|
|
return '<div class="event-icon internal">·</div>';
|
|
}
|
|
}
|
|
|
|
function getEventLabel(eventType) {
|
|
const labels = {
|
|
'session.start': 'Session Started',
|
|
'session.end': 'Session Ended',
|
|
'run.start': 'Message Received',
|
|
'run.end': 'Response Sent',
|
|
'span.start': 'Span Started',
|
|
'span.end': 'Span Completed',
|
|
'error': 'Error',
|
|
'metric.snapshot': 'Metric',
|
|
};
|
|
return labels[eventType] || eventType;
|
|
}
|
|
|
|
function getVMName(evt) {
|
|
return 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
|
|
? ` <span class="timeline-duration">${escapeHTML(formatDuration(payload.duration_ms))}</span>`
|
|
: '';
|
|
const detailClass = attrs.span_kind === 'agent' || attrs.type === 'subagent' ? ' subagent-name' : ' tool-name';
|
|
const prefix = attrs.span_kind === 'agent' || attrs.type === 'subagent' ? 'subagent ' : '';
|
|
return `<div class="timeline-event-body${detailClass}">${escapeHTML(prefix + name)}${duration}</div>`;
|
|
}
|
|
|
|
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 `<div class="timeline-event-body message-preview">"${escapeHTML(trimmed)}"</div>`;
|
|
}
|
|
|
|
if (eventType === 'run.end') {
|
|
return `<div class="timeline-event-body">${statusIcon(payload.status || 'unknown')}</div>`;
|
|
}
|
|
|
|
if (eventType === 'error') {
|
|
const errPayload = payload.error || {};
|
|
const errType = errPayload.type || 'error';
|
|
const message = errPayload.message || payload.message || 'unknown';
|
|
return `<div class="timeline-event-body error-message">${escapeHTML(errType + ': ' + message)}</div>`;
|
|
}
|
|
|
|
if (eventType === 'session.start' || eventType === 'session.end') {
|
|
return correlation.session_id
|
|
? `<div class="timeline-event-body">session ${escapeHTML(correlation.session_id)}</div>`
|
|
: '';
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function getEventDetails(evt) {
|
|
const details = {};
|
|
const correlation = getEnvelopeCorrelation(evt);
|
|
const attributes = getEnvelopeAttributes(evt);
|
|
const payload = getEnvelopePayload(evt);
|
|
|
|
if (Object.keys(correlation).length > 0) {
|
|
details.correlation = correlation;
|
|
}
|
|
if (Object.keys(attributes).length > 0) {
|
|
details.attributes = attributes;
|
|
}
|
|
if (Object.keys(payload).length > 0) {
|
|
details.payload = payload;
|
|
}
|
|
|
|
if (Object.keys(details).length === 0) {
|
|
return '';
|
|
}
|
|
|
|
return JSON.stringify(details, null, 2);
|
|
}
|
|
|
|
function persistDashboardRecentEvents() {
|
|
if (!dashboardState) return;
|
|
localStorage.setItem(
|
|
DASH_RECENT_EVENTS_STORAGE_KEY,
|
|
JSON.stringify(dashboardState.recentEvents.slice(-DASH_RECENT_EVENTS_LIMIT)),
|
|
);
|
|
}
|
|
|
|
function addDashboardRecentEvent(evt) {
|
|
if (!dashboardState || !isDashboardFeedEvent(evt)) {
|
|
return false;
|
|
}
|
|
|
|
const id = getRecordID(evt);
|
|
if (id && dashboardState.recentEventIDs.has(id)) {
|
|
return false;
|
|
}
|
|
|
|
if (id) {
|
|
dashboardState.recentEventIDs.add(id);
|
|
}
|
|
dashboardState.recentEvents.push(evt);
|
|
|
|
while (dashboardState.recentEvents.length > DASH_RECENT_EVENTS_LIMIT) {
|
|
const removed = dashboardState.recentEvents.shift();
|
|
const removedID = getRecordID(removed);
|
|
if (removedID) {
|
|
dashboardState.recentEventIDs.delete(removedID);
|
|
}
|
|
}
|
|
|
|
persistDashboardRecentEvents();
|
|
return true;
|
|
}
|
|
|
|
async function renderDashboard() {
|
|
dashboardState = {
|
|
summary: null,
|
|
timeseries: null,
|
|
window: '1h',
|
|
chartMode: getDashboardChartMode(),
|
|
chartCursorIndex: null,
|
|
recentEvents: [],
|
|
recentEventIDs: new Set(),
|
|
toolCounts: {},
|
|
modelCounts: {},
|
|
rightPanelMode: localStorage.getItem('agentmon:dash:right-panel') || 'framework',
|
|
};
|
|
|
|
app.innerHTML = `
|
|
<div class="page-header">
|
|
<h2>Dashboard <span class="live-indicator"><span class="live-dot"></span>Live</span></h2>
|
|
</div>
|
|
<div class="dashboard-summary">
|
|
<div class="summary-card" style="--card-accent:var(--accent)">
|
|
<div class="summary-card-label"><span class="summary-card-icon">◉</span>Active Sessions</div>
|
|
<div class="summary-card-value" id="dash-active">-</div>
|
|
<div class="summary-card-sub" id="dash-active-sub"> </div>
|
|
</div>
|
|
<div class="summary-card" style="--card-accent:var(--success)">
|
|
<div class="summary-card-label"><span class="summary-card-icon">▶</span>Runs Today</div>
|
|
<div class="summary-card-value" id="dash-runs">-</div>
|
|
<div class="summary-card-sub" id="dash-runs-sub"> </div>
|
|
</div>
|
|
<div class="summary-card" style="--card-accent:var(--purple)">
|
|
<div class="summary-card-label"><span class="summary-card-icon">⚡</span>Tool Calls</div>
|
|
<div class="summary-card-value" id="dash-tools">-</div>
|
|
<div class="summary-card-sub" id="dash-tools-sub"> </div>
|
|
</div>
|
|
<div class="summary-card" style="--card-accent:var(--error)">
|
|
<div class="summary-card-label"><span class="summary-card-icon">⚠</span>Errors</div>
|
|
<div class="summary-card-value" id="dash-errors">-</div>
|
|
<div class="summary-card-sub" id="dash-errors-sub"> </div>
|
|
</div>
|
|
</div>
|
|
<div class="metrics-strip">
|
|
<div class="metric-pill">
|
|
<span class="metric-pill-label">Tokens today</span>
|
|
<span class="metric-pill-value" id="dash-tokens-today">-</span>
|
|
</div>
|
|
<div class="metric-pill">
|
|
<span class="metric-pill-label">Cost today</span>
|
|
<span class="metric-pill-value" id="dash-cost-today">-</span>
|
|
</div>
|
|
<div class="metric-pill">
|
|
<span class="metric-pill-label">Avg run duration</span>
|
|
<span class="metric-pill-value" id="dash-avg-duration">-</span>
|
|
</div>
|
|
<div class="metric-pill">
|
|
<span class="metric-pill-label">Error rate</span>
|
|
<span class="metric-pill-value metric-pill-alert" id="dash-error-rate">-</span>
|
|
</div>
|
|
</div>
|
|
<div class="section-title" style="margin-bottom:0.75rem">Infrastructure</div>
|
|
<div class="vm-strip" id="dash-vm-strip"></div>
|
|
<div class="charts-row">
|
|
<div class="chart-panel">
|
|
<div class="chart-header">
|
|
<div class="chart-title-group">
|
|
<span class="chart-title">Event Rate</span>
|
|
<span class="chart-subtitle">Runs, tool spans, and errors over time</span>
|
|
</div>
|
|
<div class="chart-header-controls">
|
|
<div class="chart-legend">
|
|
<span class="chart-legend-item"><span class="chart-legend-dot total"></span>total</span>
|
|
<span class="chart-legend-item"><span class="chart-legend-dot" style="background:#34d399"></span>runs</span>
|
|
<span class="chart-legend-item"><span class="chart-legend-dot" style="background:#22d3ee"></span>tools</span>
|
|
<span class="chart-legend-item"><span class="chart-legend-dot" style="background:#f87171"></span>errors</span>
|
|
</div>
|
|
<div class="mode-selector" id="dash-mode-selector">
|
|
<button class="mode-btn ${dashboardState.chartMode === 'stacked' ? 'active' : ''}" data-mode="stacked">stacked</button>
|
|
<button class="mode-btn ${dashboardState.chartMode === 'lines' ? 'active' : ''}" data-mode="lines">lines</button>
|
|
</div>
|
|
<div class="window-selector">
|
|
<button class="window-btn active" data-w="1h">1h</button>
|
|
<button class="window-btn" data-w="6h">6h</button>
|
|
<button class="window-btn" data-w="24h">24h</button>
|
|
<button class="window-btn" data-w="7d">7d</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="chart-insights" id="dash-chart-insights"></div>
|
|
<div class="chart-container" id="dash-chart"></div>
|
|
<div class="chart-hover-panel" id="dash-chart-hover"></div>
|
|
</div>
|
|
<div class="chart-panel right-panel">
|
|
<div class="chart-header">
|
|
<div class="right-panel-tabs" id="dash-right-tabs">
|
|
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'framework' ? 'active' : ''}" data-panel="framework">Framework</button>
|
|
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'tokens' ? 'active' : ''}" data-panel="tokens">Tokens</button>
|
|
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'latency' ? 'active' : ''}" data-panel="latency">Latency</button>
|
|
</div>
|
|
</div>
|
|
<div class="right-panel-body" id="dash-right-panel">
|
|
<p class="empty-state" style="padding:1rem">Loading...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="bottom-panels">
|
|
<div class="feed-panel">
|
|
<div class="chart-header">
|
|
<span class="chart-title">Recent Activity</span>
|
|
</div>
|
|
<div class="timeline" id="dash-feed">
|
|
<p class="empty-state" style="padding:1rem">Loading...</p>
|
|
</div>
|
|
</div>
|
|
<div class="tools-panel">
|
|
<div class="chart-header">
|
|
<span class="chart-title">Top Usage</span>
|
|
</div>
|
|
<div class="usage-rank-group">
|
|
<div class="usage-rank-header">Tools</div>
|
|
<ul class="stat-list" id="dash-top-tools">
|
|
<li style="color:var(--text-dim);font-size:0.8rem">Loading...</li>
|
|
</ul>
|
|
</div>
|
|
<div class="usage-rank-group">
|
|
<div class="usage-rank-header">Models</div>
|
|
<ul class="stat-list" id="dash-top-models">
|
|
<li style="color:var(--text-dim);font-size:0.8rem">Loading...</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.querySelectorAll('.window-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.window-btn').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
dashboardState.window = btn.dataset.w;
|
|
loadTimeseries();
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.mode-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const nextMode = btn.dataset.mode;
|
|
if (dashboardState.chartMode === nextMode) return;
|
|
document.querySelectorAll('.mode-btn').forEach(b => b.classList.toggle('active', b === btn));
|
|
dashboardState.chartMode = nextMode;
|
|
localStorage.setItem('agentmon:dash:chart-mode', nextMode);
|
|
if (dashboardChart) {
|
|
dashboardChart.destroy();
|
|
dashboardChart = null;
|
|
}
|
|
renderTimeseriesChart();
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.right-panel-tab').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const panel = btn.dataset.panel;
|
|
if (dashboardState.rightPanelMode === panel) return;
|
|
document.querySelectorAll('.right-panel-tab').forEach(b => b.classList.toggle('active', b === btn));
|
|
dashboardState.rightPanelMode = panel;
|
|
localStorage.setItem('agentmon:dash:right-panel', panel);
|
|
renderRightPanel();
|
|
});
|
|
});
|
|
|
|
renderDashVMStrip();
|
|
|
|
const cachedRecentEvents = tryParseJSON(localStorage.getItem(DASH_RECENT_EVENTS_STORAGE_KEY));
|
|
if (Array.isArray(cachedRecentEvents)) {
|
|
for (const evt of cachedRecentEvents) {
|
|
addDashboardRecentEvent(evt);
|
|
}
|
|
renderDashFeed();
|
|
}
|
|
|
|
// 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(); renderRightPanel(); }
|
|
|
|
try {
|
|
const [summaryData, tsData, recentData, snapshots, swarmSnaps, topToolsData, topModelsData] = await Promise.all([
|
|
api('/v1/stats/summary'),
|
|
api('/v1/stats/timeseries?window=1h'),
|
|
api('/v1/events?limit=10'),
|
|
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: [] })),
|
|
api('/v1/stats/top-models').catch(() => ({ models: [] })),
|
|
]);
|
|
|
|
if (!isCurrentPath('/')) return;
|
|
|
|
mergeOpenClawEvents(snapshots.events || []);
|
|
for (const evt of swarmSnaps.events || []) mergeSwarmSnapshot(evt);
|
|
renderDashVMStrip();
|
|
|
|
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();
|
|
renderRightPanel();
|
|
|
|
// Seed tool counts from the dedicated top-tools endpoint
|
|
for (const t of (topToolsData.tools || [])) {
|
|
dashboardState.toolCounts[t.name] = t.count;
|
|
}
|
|
for (const m of (topModelsData.models || [])) {
|
|
dashboardState.modelCounts[m.name] = m.count;
|
|
}
|
|
|
|
const events = (recentData.events || [])
|
|
.filter(isDashboardFeedEvent)
|
|
.slice()
|
|
.reverse();
|
|
for (const evt of events) {
|
|
addDashboardRecentEvent(evt);
|
|
}
|
|
renderDashFeed();
|
|
renderDashTopTools();
|
|
renderDashTopModels();
|
|
} 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();
|
|
const infra = getDashboardInfraPill();
|
|
strip.innerHTML = [
|
|
...vms.map(vm => `
|
|
<div class="vm-pill ${vm.active ? 'active' : 'inactive'}">
|
|
<span class="vm-pill-dot"></span>
|
|
<span class="vm-pill-name">${escapeHTML(vm.name)}</span>
|
|
<span class="vm-pill-label">${vm.active ? 'online' : 'offline'}</span>
|
|
</div>
|
|
`),
|
|
`
|
|
<div class="vm-pill ${infra.className}">
|
|
<span class="vm-pill-dot"></span>
|
|
<span class="vm-pill-name">${escapeHTML(infra.name)}</span>
|
|
<span class="vm-pill-label">${escapeHTML(infra.label)}</span>
|
|
</div>
|
|
`,
|
|
].join('');
|
|
}
|
|
|
|
function handleDashboardWS(msg) {
|
|
if (msg.type !== 'message') return;
|
|
|
|
const eventType = getEnvelopeType(msg.data);
|
|
|
|
if (eventType === 'openclaw.snapshot') {
|
|
mergeOpenClawEvents([msg.data]);
|
|
renderDashVMStrip();
|
|
return;
|
|
}
|
|
if (eventType === 'swarm.snapshot') {
|
|
mergeSwarmSnapshot(msg.data);
|
|
renderDashVMStrip();
|
|
return;
|
|
}
|
|
if (eventType === 'swarm.service.snapshot') {
|
|
mergeSwarmServiceSnapshot(msg.data);
|
|
renderDashVMStrip();
|
|
return;
|
|
}
|
|
|
|
if (dashboardState.summary) {
|
|
if (eventType === 'session.start') dashboardState.summary.active_sessions++;
|
|
if (eventType === 'session.end') dashboardState.summary.active_sessions = Math.max(0, dashboardState.summary.active_sessions - 1);
|
|
if (eventType === 'run.start') dashboardState.summary.runs_today++;
|
|
if (eventType === 'error') dashboardState.summary.errors_today++;
|
|
if (eventType === 'span.end') {
|
|
const attrs = getEnvelopeAttributes(msg.data);
|
|
if (attrs.span_kind === 'tool') dashboardState.summary.tool_calls_today++;
|
|
}
|
|
if (eventType === 'run.end') {
|
|
const payload = getEnvelopePayload(msg.data);
|
|
const usage = payload.usage || {};
|
|
dashboardState.summary.tokens_today = (dashboardState.summary.tokens_today || 0) + (usage.total_tokens || 0);
|
|
dashboardState.summary.cost_today = (dashboardState.summary.cost_today || 0) + (usage.total_cost || 0);
|
|
// Update rolling avg duration
|
|
if (payload.duration_ms) {
|
|
const runs = dashboardState.summary.runs_today || 1;
|
|
const prev = dashboardState.summary.avg_duration_ms || 0;
|
|
dashboardState.summary.avg_duration_ms = prev + (payload.duration_ms - prev) / runs;
|
|
}
|
|
}
|
|
renderSummaryCards();
|
|
}
|
|
|
|
if (!isDashboardFeedEvent(msg.data)) {
|
|
if (dashboardState.timeseries && dashboardState.window === '1h') {
|
|
appendToCurrentBucket(msg.data);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (addDashboardRecentEvent(msg.data)) {
|
|
tallyTool(msg.data);
|
|
tallyModel(msg.data);
|
|
|
|
if (!_dashFeedRenderTimer) {
|
|
_dashFeedRenderTimer = requestAnimationFrame(() => {
|
|
_dashFeedRenderTimer = null;
|
|
renderDashFeed();
|
|
renderDashTopTools();
|
|
renderDashTopModels();
|
|
});
|
|
}
|
|
}
|
|
|
|
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 tallyModel(evt) {
|
|
const eventType = getEnvelopeType(evt);
|
|
const payload = getEnvelopePayload(evt);
|
|
|
|
if (eventType === 'run.end' && payload.model) {
|
|
const name = String(payload.model);
|
|
dashboardState.modelCounts[name] = (dashboardState.modelCounts[name] || 0) + 1;
|
|
return;
|
|
}
|
|
|
|
if (eventType === 'metric.snapshot' && payload.metrics && payload.metrics.model) {
|
|
const name = String(payload.metrics.model);
|
|
if (!dashboardState.modelCounts[name]) {
|
|
dashboardState.modelCounts[name] = 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);
|
|
}
|
|
|
|
// Metrics strip
|
|
el('dash-tokens-today', formatTokenCount(s.tokens_today || 0));
|
|
el('dash-cost-today', s.cost_today ? formatCost(s.cost_today) : '$0.0000');
|
|
el('dash-avg-duration', s.avg_duration_ms ? formatDuration(s.avg_duration_ms) : '-');
|
|
|
|
const errorRateEl = document.getElementById('dash-error-rate');
|
|
if (errorRateEl) {
|
|
const totalOps = (s.runs_today || 0) + (s.tool_calls_today || 0);
|
|
const rate = totalOps > 0 ? ((s.errors_today || 0) / totalOps * 100) : 0;
|
|
errorRateEl.textContent = rate.toFixed(1) + '%';
|
|
errorRateEl.classList.toggle('alert', rate > 5);
|
|
}
|
|
}
|
|
|
|
async function loadTimeseries() {
|
|
try {
|
|
// Destroy chart so it's recreated with new window scale
|
|
if (dashboardChart) {
|
|
dashboardChart.destroy();
|
|
dashboardChart = null;
|
|
}
|
|
dashboardState.chartCursorIndex = null;
|
|
const cachedWin = tryParseJSON(localStorage.getItem('agentmon:dash:ts:' + dashboardState.window));
|
|
if (cachedWin) { dashboardState.timeseries = cachedWin; renderTimeseriesChart(); renderRightPanel(); }
|
|
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();
|
|
renderRightPanel();
|
|
} catch (e) {
|
|
console.error('Failed to load timeseries:', e);
|
|
}
|
|
}
|
|
|
|
function getDashboardBucketIntervalMS() {
|
|
const bucket = dashboardState && dashboardState.timeseries ? dashboardState.timeseries.bucket : '';
|
|
switch (bucket) {
|
|
case '1m': return 60 * 1000;
|
|
case '5m': return 5 * 60 * 1000;
|
|
case '15m': return 15 * 60 * 1000;
|
|
case '1h': return 60 * 60 * 1000;
|
|
default: return 60 * 1000;
|
|
}
|
|
}
|
|
|
|
function formatBucketLabel(ts) {
|
|
const start = new Date(ts);
|
|
if (Number.isNaN(start.getTime())) return '-';
|
|
const end = new Date(start.getTime() + getDashboardBucketIntervalMS());
|
|
const sameDay = start.toLocaleDateString() === end.toLocaleDateString();
|
|
const startLabel = start.toLocaleString([], {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
});
|
|
const endLabel = end.toLocaleString([], sameDay
|
|
? { hour: 'numeric', minute: '2-digit' }
|
|
: { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
|
return startLabel + ' to ' + endLabel;
|
|
}
|
|
|
|
function getDashboardChartStats() {
|
|
const ts = dashboardState.timeseries;
|
|
if (!ts || !ts.series || ts.series.length === 0) return null;
|
|
|
|
const totals = ts.series.map(b => (b.runs || 0) + (b.tools || 0) + (b.errors || 0));
|
|
const sum = values => values.reduce((acc, value) => acc + value, 0);
|
|
|
|
let peakIndex = 0;
|
|
for (let i = 1; i < totals.length; i++) {
|
|
if (totals[i] > totals[peakIndex]) peakIndex = i;
|
|
}
|
|
|
|
return {
|
|
totalRuns: sum(ts.series.map(b => b.runs || 0)),
|
|
totalTools: sum(ts.series.map(b => b.tools || 0)),
|
|
totalErrors: sum(ts.series.map(b => b.errors || 0)),
|
|
totalEvents: sum(totals),
|
|
peakIndex,
|
|
peakTotal: totals[peakIndex] || 0,
|
|
bucketCount: ts.series.length,
|
|
};
|
|
}
|
|
|
|
function buildChartData() {
|
|
const ts = dashboardState.timeseries;
|
|
if (!ts || !ts.series || ts.series.length === 0) return null;
|
|
|
|
const timestamps = ts.series.map(b => Math.floor(new Date(b.ts).getTime() / 1000));
|
|
const runs = ts.series.map(b => b.runs || 0);
|
|
const tools = ts.series.map(b => b.tools || 0);
|
|
const errors = ts.series.map(b => b.errors || 0);
|
|
const totals = ts.series.map((b, i) => runs[i] + tools[i] + errors[i]);
|
|
|
|
if (dashboardState.chartMode === 'lines') {
|
|
return [timestamps, totals, runs, tools, errors];
|
|
}
|
|
|
|
const stackedTools = tools.map((value, i) => value + errors[i]);
|
|
return [timestamps, totals, stackedTools, errors];
|
|
}
|
|
|
|
function renderDashboardChartInsights() {
|
|
const container = document.getElementById('dash-chart-insights');
|
|
if (!container) return;
|
|
|
|
const stats = getDashboardChartStats();
|
|
if (!stats) {
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
const peakBucket = dashboardState.timeseries.series[stats.peakIndex];
|
|
container.innerHTML = `
|
|
<div class="chart-insight-pill"><span class="chart-insight-label">window total</span><strong>${escapeHTML(formatCount(stats.totalEvents))}</strong></div>
|
|
<div class="chart-insight-pill"><span class="chart-insight-label">peak bucket</span><strong>${escapeHTML(formatCount(stats.peakTotal))}</strong><span class="chart-insight-meta">${escapeHTML(formatBucketLabel(peakBucket.ts))}</span></div>
|
|
<div class="chart-insight-pill"><span class="chart-insight-label">mix</span><strong>${escapeHTML(formatCount(stats.totalRuns))}r / ${escapeHTML(formatCount(stats.totalTools))}t / ${escapeHTML(formatCount(stats.totalErrors))}e</strong></div>
|
|
<div class="chart-insight-pill"><span class="chart-insight-label">bucket</span><strong>${escapeHTML(dashboardState.timeseries.bucket || '-')}</strong><span class="chart-insight-meta">${escapeHTML(String(stats.bucketCount))} points</span></div>
|
|
`;
|
|
}
|
|
|
|
function renderDashboardChartHover(idx) {
|
|
const container = document.getElementById('dash-chart-hover');
|
|
if (!container) return;
|
|
|
|
const ts = dashboardState.timeseries;
|
|
if (!ts || !ts.series || ts.series.length === 0) {
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
const safeIdx = Number.isInteger(idx) && idx >= 0 && idx < ts.series.length ? idx : ts.series.length - 1;
|
|
const bucket = ts.series[safeIdx];
|
|
const prev = safeIdx > 0 ? ts.series[safeIdx - 1] : null;
|
|
const total = (bucket.runs || 0) + (bucket.tools || 0) + (bucket.errors || 0);
|
|
const prevTotal = prev ? (prev.runs || 0) + (prev.tools || 0) + (prev.errors || 0) : 0;
|
|
const delta = total - prevTotal;
|
|
const deltaLabel = (delta > 0 ? '+' : '') + delta;
|
|
const bucketLabel = safeIdx === ts.series.length - 1 ? 'Latest bucket' : 'Selected bucket';
|
|
|
|
container.innerHTML = `
|
|
<div class="chart-hover-head">
|
|
<div>
|
|
<div class="chart-hover-label">${escapeHTML(bucketLabel)}</div>
|
|
<div class="chart-hover-time">${escapeHTML(formatBucketLabel(bucket.ts))}</div>
|
|
</div>
|
|
<div class="chart-hover-total">
|
|
<span>Total</span>
|
|
<strong>${escapeHTML(formatCount(total))}</strong>
|
|
</div>
|
|
</div>
|
|
<div class="chart-hover-grid">
|
|
<div class="chart-hover-metric runs"><span>Runs</span><strong>${escapeHTML(formatCount(bucket.runs || 0))}</strong></div>
|
|
<div class="chart-hover-metric tools"><span>Tools</span><strong>${escapeHTML(formatCount(bucket.tools || 0))}</strong></div>
|
|
<div class="chart-hover-metric errors"><span>Errors</span><strong>${escapeHTML(formatCount(bucket.errors || 0))}</strong></div>
|
|
<div class="chart-hover-metric delta"><span>Delta</span><strong>${escapeHTML(deltaLabel)}</strong></div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderTimeseriesChart() {
|
|
const container = document.getElementById('dash-chart');
|
|
if (!container || !dashboardState.timeseries) return;
|
|
|
|
const data = buildChartData();
|
|
renderDashboardChartInsights();
|
|
renderDashboardChartHover(dashboardState.chartCursorIndex);
|
|
if (!data) {
|
|
container.innerHTML = '<p class="empty-state" style="padding:2rem">No data for this window</p>';
|
|
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 commonSeries = [
|
|
{},
|
|
{
|
|
label: 'Total',
|
|
stroke: '#f8fafc',
|
|
width: 1.5,
|
|
dash: [6, 4],
|
|
points: { show: false },
|
|
},
|
|
];
|
|
|
|
const lineSeries = [
|
|
...commonSeries,
|
|
{
|
|
label: 'Runs',
|
|
stroke: '#34d399',
|
|
width: 1.75,
|
|
fill: 'rgba(52, 211, 153, 0.08)',
|
|
},
|
|
{
|
|
label: 'Tools',
|
|
stroke: '#22d3ee',
|
|
width: 1.75,
|
|
fill: 'rgba(34, 211, 238, 0.08)',
|
|
},
|
|
{
|
|
label: 'Errors',
|
|
stroke: '#f87171',
|
|
width: 1.75,
|
|
fill: 'rgba(248, 113, 113, 0.08)',
|
|
},
|
|
];
|
|
|
|
const stackedSeries = [
|
|
...commonSeries,
|
|
{
|
|
label: 'Tools+Errors',
|
|
stroke: 'rgba(34, 211, 238, 0.85)',
|
|
width: 1.25,
|
|
points: { show: false },
|
|
},
|
|
{
|
|
label: 'Errors',
|
|
stroke: '#f87171',
|
|
width: 1.25,
|
|
points: { show: false },
|
|
fill: 'rgba(248, 113, 113, 0.18)',
|
|
},
|
|
];
|
|
|
|
const opts = {
|
|
width,
|
|
height,
|
|
cursor: { show: true },
|
|
hooks: {
|
|
setCursor: [
|
|
u => {
|
|
dashboardState.chartCursorIndex = Number.isInteger(u.cursor.idx) ? u.cursor.idx : null;
|
|
renderDashboardChartHover(dashboardState.chartCursorIndex);
|
|
},
|
|
],
|
|
},
|
|
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: dashboardState.chartMode === 'lines' ? lineSeries : stackedSeries,
|
|
bands: dashboardState.chartMode === 'lines'
|
|
? []
|
|
: [
|
|
{ series: [1, 2], fill: 'rgba(52, 211, 153, 0.18)' },
|
|
{ series: [2, 3], fill: 'rgba(34, 211, 238, 0.18)' },
|
|
],
|
|
};
|
|
|
|
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, tokens: 0, input_tokens: 0, output_tokens: 0, cost: 0, avg_duration_ms: 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++;
|
|
}
|
|
if (eventType === 'run.end') {
|
|
const payload = getEnvelopePayload(evt);
|
|
const usage = payload.usage || {};
|
|
bucket.tokens = (bucket.tokens || 0) + (usage.total_tokens || 0);
|
|
bucket.input_tokens = (bucket.input_tokens || 0) + (usage.input_tokens || 0);
|
|
bucket.output_tokens = (bucket.output_tokens || 0) + (usage.output_tokens || 0);
|
|
bucket.cost = (bucket.cost || 0) + (usage.total_cost || 0);
|
|
if (payload.duration_ms) {
|
|
const runCount = bucket.runs || 1;
|
|
const prev = bucket.avg_duration_ms || 0;
|
|
bucket.avg_duration_ms = prev + (payload.duration_ms - prev) / runCount;
|
|
}
|
|
}
|
|
|
|
dashboardState.chartCursorIndex = ts.series.length - 1;
|
|
renderTimeseriesChart();
|
|
}
|
|
|
|
function renderRightPanel() {
|
|
const mode = dashboardState && dashboardState.rightPanelMode;
|
|
if (mode === 'tokens') {
|
|
renderTokenPanel();
|
|
} else if (mode === 'latency') {
|
|
renderLatencyPanel();
|
|
} else {
|
|
renderFrameworkBars();
|
|
}
|
|
}
|
|
|
|
function renderTokenPanel() {
|
|
const container = document.getElementById('dash-right-panel');
|
|
if (!container) return;
|
|
const s = dashboardState.summary;
|
|
const ts = dashboardState.timeseries;
|
|
|
|
const totalTokens = s ? (s.tokens_today || 0) : 0;
|
|
const inputTokens = ts && ts.series ? ts.series.reduce((acc, b) => acc + (b.input_tokens || 0), 0) : 0;
|
|
const outputTokens = ts && ts.series ? ts.series.reduce((acc, b) => acc + (b.output_tokens || 0), 0) : 0;
|
|
const totalCost = s ? (s.cost_today || 0) : 0;
|
|
const maxIO = Math.max(inputTokens, outputTokens, 1);
|
|
|
|
container.innerHTML = `
|
|
<div class="token-panel">
|
|
<div class="token-stat-big">
|
|
<div class="token-stat-label">Total tokens today</div>
|
|
<div class="token-stat-value">${escapeHTML(formatTokenCount(totalTokens))}</div>
|
|
</div>
|
|
<div class="token-io-bars">
|
|
<div class="token-bar-row">
|
|
<span class="token-bar-label">Input</span>
|
|
<div class="token-bar-track">
|
|
<div class="token-bar-fill input" style="width:${(inputTokens / maxIO * 100).toFixed(1)}%"></div>
|
|
</div>
|
|
<span class="token-bar-count">${escapeHTML(formatTokenCount(inputTokens))}</span>
|
|
</div>
|
|
<div class="token-bar-row">
|
|
<span class="token-bar-label">Output</span>
|
|
<div class="token-bar-track">
|
|
<div class="token-bar-fill output" style="width:${(outputTokens / maxIO * 100).toFixed(1)}%"></div>
|
|
</div>
|
|
<span class="token-bar-count">${escapeHTML(formatTokenCount(outputTokens))}</span>
|
|
</div>
|
|
</div>
|
|
<div class="token-cost-display">
|
|
<span class="token-bar-label">Est. cost today</span>
|
|
<strong>${escapeHTML(totalCost ? formatCost(totalCost) : '$0.0000')}</strong>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderLatencyPanel() {
|
|
const container = document.getElementById('dash-right-panel');
|
|
if (!container) return;
|
|
const ts = dashboardState.timeseries;
|
|
|
|
if (!ts || !ts.series || ts.series.length === 0) {
|
|
container.innerHTML = '<p class="empty-state" style="padding:1rem">No latency data</p>';
|
|
return;
|
|
}
|
|
|
|
const durSeries = ts.series.map(b => b.avg_duration_ms || 0).filter(v => v > 0);
|
|
if (durSeries.length === 0) {
|
|
container.innerHTML = '<p class="empty-state" style="padding:1rem">No run latency recorded yet</p>';
|
|
return;
|
|
}
|
|
|
|
const avg = durSeries.reduce((a, b) => a + b, 0) / durSeries.length;
|
|
const min = Math.min(...durSeries);
|
|
const max = Math.max(...durSeries);
|
|
const maxBar = max || 1;
|
|
|
|
container.innerHTML = `
|
|
<div class="latency-panel">
|
|
<div class="latency-range">
|
|
<div class="latency-range-item">
|
|
<span class="latency-range-label">Min</span>
|
|
<span class="latency-range-val">${escapeHTML(formatDuration(min))}</span>
|
|
</div>
|
|
<div class="latency-range-item">
|
|
<span class="latency-range-label">Avg</span>
|
|
<span class="latency-range-val">${escapeHTML(formatDuration(avg))}</span>
|
|
</div>
|
|
<div class="latency-range-item">
|
|
<span class="latency-range-label">Max</span>
|
|
<span class="latency-range-val">${escapeHTML(formatDuration(max))}</span>
|
|
</div>
|
|
</div>
|
|
<div class="latency-mini-bars">
|
|
${durSeries.map((v, i) => {
|
|
const pct = (v / maxBar * 100).toFixed(1);
|
|
const label = ts.series.filter(b => b.avg_duration_ms > 0)[i];
|
|
const title = label ? formatBucketLabel(label.ts) + ': ' + formatDuration(v) : formatDuration(v);
|
|
return `<div class="latency-mini-bar" style="height:${pct}%" title="${escapeHTML(title)}"></div>`;
|
|
}).join('')}
|
|
</div>
|
|
<div class="latency-range-label" style="margin-top:0.5rem;font-size:0.7rem;color:var(--text-dim)">Avg run duration per bucket (${escapeHTML(ts.bucket || '-')})</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderFrameworkBars() {
|
|
const container = document.getElementById('dash-right-panel');
|
|
if (!container || !dashboardState.summary) return;
|
|
|
|
const byFw = dashboardState.summary.by_framework || {};
|
|
const entries = Object.entries(byFw).sort((a, b) => {
|
|
const totalA = a[1].runs + a[1].tools + a[1].errors;
|
|
const totalB = b[1].runs + b[1].tools + b[1].errors;
|
|
return totalB - totalA;
|
|
});
|
|
|
|
if (entries.length === 0) {
|
|
container.innerHTML = '<p class="empty-state" style="padding:1rem">No framework data</p>';
|
|
return;
|
|
}
|
|
|
|
const maxTotal = Math.max(...entries.map(([, s]) => s.runs + s.tools + s.errors));
|
|
|
|
container.innerHTML = '<div class="fw-bars">' + entries.map(([name, stats]) => {
|
|
const total = stats.runs + stats.tools + stats.errors;
|
|
const pct = maxTotal > 0 ? (total / maxTotal * 100) : 0;
|
|
const cssClass = name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
return `
|
|
<div class="fw-bar-row">
|
|
<div class="fw-bar-label">
|
|
<span class="fw-bar-name">${escapeHTML(name)}</span>
|
|
<span class="fw-bar-count">${total} events</span>
|
|
</div>
|
|
<div class="fw-bar-track">
|
|
<div class="fw-bar-fill ${escapeHTML(cssClass)}" style="width:${pct}%"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('') + '</div>';
|
|
}
|
|
|
|
function renderDashFeedItem(evt) {
|
|
const eventType = getEnvelopeType(evt);
|
|
const correlation = getEnvelopeCorrelation(evt);
|
|
const vmName = getVMName(evt);
|
|
const vmClass = getVMClassName(vmName);
|
|
const source = getEnvelopeSource(evt);
|
|
const framework = source.framework || '';
|
|
const tag = framework
|
|
? `<span class="timeline-vm-tag ${vmClass}">${escapeHTML(framework)}</span>`
|
|
: '';
|
|
const sessionID = correlation.session_id || '';
|
|
const clickableClass = sessionID ? ' timeline-event-link' : '';
|
|
const attrs = sessionID
|
|
? ` role="link" tabindex="0" data-session-id="${escapeHTML(sessionID)}"`
|
|
: '';
|
|
|
|
return `
|
|
<div class="timeline-event${clickableClass}"${attrs}>
|
|
<div class="timeline-event-header">
|
|
${getEventIcon(eventType)}
|
|
${tag}
|
|
<span class="timeline-event-type">${escapeHTML(getEventLabel(eventType))}</span>
|
|
<span class="timeline-event-time">${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())}</span>
|
|
</div>
|
|
${getEventBody(evt)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderDashFeed() {
|
|
const feed = document.getElementById('dash-feed');
|
|
if (!feed) return;
|
|
|
|
const recent = dashboardState.recentEvents.slice(-DASH_RECENT_EVENTS_LIMIT).reverse();
|
|
if (recent.length === 0) {
|
|
feed.innerHTML = '<p class="empty-state" style="padding:1rem">Waiting for events...</p>';
|
|
return;
|
|
}
|
|
feed.innerHTML = recent.map(renderDashFeedItem).join('');
|
|
feed.querySelectorAll('.timeline-event-link').forEach(item => {
|
|
const sessionID = item.dataset.sessionId || '';
|
|
if (!sessionID) return;
|
|
item.addEventListener('click', () => navigate('/sessions/' + sessionID));
|
|
item.addEventListener('keydown', event => {
|
|
if (event.key !== 'Enter' && event.key !== ' ') {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
navigate('/sessions/' + sessionID);
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderDashTopTools() {
|
|
const list = document.getElementById('dash-top-tools');
|
|
if (!list) return;
|
|
|
|
const topTools = Object.entries(dashboardState.toolCounts)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 10);
|
|
|
|
if (topTools.length === 0) {
|
|
list.innerHTML = '<li style="color:var(--text-dim);font-size:0.8rem">No tool data yet</li>';
|
|
return;
|
|
}
|
|
|
|
const maxCount = topTools[0]?.[1] || 1;
|
|
list.innerHTML = topTools.map(([name, count]) => {
|
|
const pct = (count / maxCount * 100).toFixed(1);
|
|
return `
|
|
<li>
|
|
<div class="stat-list-header">
|
|
<span class="stat-list-name">${escapeHTML(name)}</span>
|
|
<span class="stat-list-count">${count}</span>
|
|
</div>
|
|
<div class="stat-list-bar-track">
|
|
<div class="stat-list-bar-fill" style="width:${pct}%"></div>
|
|
</div>
|
|
</li>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderDashTopModels() {
|
|
const list = document.getElementById('dash-top-models');
|
|
if (!list) return;
|
|
|
|
const topModels = Object.entries(dashboardState.modelCounts)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 10);
|
|
|
|
if (topModels.length === 0) {
|
|
list.innerHTML = '<li style="color:var(--text-dim);font-size:0.8rem">No model data yet</li>';
|
|
return;
|
|
}
|
|
|
|
const maxCount = topModels[0]?.[1] || 1;
|
|
list.innerHTML = topModels.map(([name, count]) => {
|
|
const pct = (count / maxCount * 100).toFixed(1);
|
|
return `
|
|
<li>
|
|
<div class="stat-list-header">
|
|
<span class="stat-list-name">${escapeHTML(name)}</span>
|
|
<span class="stat-list-count">${count}</span>
|
|
</div>
|
|
<div class="stat-list-bar-track">
|
|
<div class="stat-list-bar-fill model" style="width:${pct}%"></div>
|
|
</div>
|
|
</li>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
route();
|
|
})();
|