diff --git a/cmd/web-ui/main.go b/cmd/web-ui/main.go index c0ab8d4..a968aa9 100644 --- a/cmd/web-ui/main.go +++ b/cmd/web-ui/main.go @@ -10,6 +10,7 @@ import ( "net/url" "os" "strings" + "sync" "github.com/gorilla/websocket" ) @@ -58,24 +59,38 @@ func main() { } defer uiConn.Close() - // Bidirectional copy + var uiMu, upstreamMu sync.Mutex + + // upstream → UI done := make(chan struct{}) go func() { + defer close(done) for { _, msg, err := conn.ReadMessage() if err != nil { break } - uiConn.WriteMessage(websocket.TextMessage, msg) + uiMu.Lock() + err = uiConn.WriteMessage(websocket.TextMessage, msg) + uiMu.Unlock() + if err != nil { + break + } } - close(done) }() + + // UI → upstream for { _, msg, err := uiConn.ReadMessage() if err != nil { break } - conn.WriteMessage(websocket.TextMessage, msg) + upstreamMu.Lock() + err = conn.WriteMessage(websocket.TextMessage, msg) + upstreamMu.Unlock() + if err != nil { + break + } } <-done }) @@ -89,23 +104,23 @@ func main() { fileServer.ServeHTTP(w, r) }) - // SPA catch-all: serve index.html for all other routes + // SPA catch-all: serve index.html for routes without file extensions mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - // Serve index.html for SPA routes - if r.URL.Path == "/" || strings.HasPrefix(r.URL.Path, "/sessions") || strings.HasPrefix(r.URL.Path, "/runs") || strings.HasPrefix(r.URL.Path, "/infrastructure") || strings.HasPrefix(r.URL.Path, "/agents") { - f, err := staticFiles.Open("static/index.html") - if err != nil { - http.Error(w, "index.html not found", http.StatusInternalServerError) - return - } - defer f.Close() - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = io.Copy(w, f) + // If the path contains a dot, it's likely a missing static asset + if strings.Contains(r.URL.Path, ".") { + http.NotFound(w, r) return } - http.NotFound(w, r) + f, err := staticFiles.Open("static/index.html") + if err != nil { + http.Error(w, "index.html not found", http.StatusInternalServerError) + return + } + defer f.Close() + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.Copy(w, f) }) log.Printf("web-ui listening on %s", addr) diff --git a/cmd/web-ui/static/app.js b/cmd/web-ui/static/app.js index 57c900e..f463f3f 100644 --- a/cmd/web-ui/static/app.js +++ b/cmd/web-ui/static/app.js @@ -41,10 +41,12 @@ 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 }; + let sessionsState = { sessions: [], cursor: null, activeSessionByBackend: {} }; let sessionsUnsubscribe = null; let openclawState = { instances: {} }; let openclawUnsubscribe = null; @@ -56,6 +58,13 @@ 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:'; @@ -72,6 +81,9 @@ ws.onopen = () => { console.log('WebSocket connected'); + wsStatus = 'connected'; + wsReconnectDelay = 1000; + updateWSIndicator(); wsCallbacks.forEach(cb => cb({ type: 'connected' })); }; @@ -86,8 +98,11 @@ ws.onclose = () => { console.log('WebSocket disconnected'); + wsStatus = 'reconnecting'; + updateWSIndicator(); wsCallbacks.forEach(cb => cb({ type: 'disconnected' })); - wsReconnectTimeout = setTimeout(connectWS, 5000); + wsReconnectTimeout = setTimeout(connectWS, wsReconnectDelay); + wsReconnectDelay = Math.min(wsReconnectDelay * 1.5, 30000); }; ws.onerror = (err) => { @@ -95,7 +110,8 @@ }; } catch (e) { console.error('Failed to connect WebSocket:', e); - wsReconnectTimeout = setTimeout(connectWS, 5000); + wsReconnectTimeout = setTimeout(connectWS, wsReconnectDelay); + wsReconnectDelay = Math.min(wsReconnectDelay * 1.5, 30000); } } @@ -107,6 +123,18 @@ 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(); @@ -144,6 +172,14 @@ clearInterval(agentsState.timerInterval); agentsState.timerInterval = null; } + if (_agentsRenderTimer) { + cancelAnimationFrame(_agentsRenderTimer); + _agentsRenderTimer = null; + } + if (_dashFeedRenderTimer) { + cancelAnimationFrame(_dashFeedRenderTimer); + _dashFeedRenderTimer = null; + } } function route() { @@ -163,7 +199,7 @@ } else if (path.startsWith('/runs/')) { renderRun(path.split('/runs/')[1]); } else { - app.innerHTML = '

Page not found

'; + app.innerHTML = '

Page not found

The page you\'re looking for doesn\'t exist.

Go to Dashboard
'; } updateActiveNav(); } @@ -189,11 +225,28 @@ async function api(path) { const resp = await fetch('/api' + path); if (!resp.ok) { - throw new Error('API error'); + 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) { @@ -245,6 +298,12 @@ return 'unknown'; } + function skeletonRows(rows, cols) { + return Array(rows).fill(0).map(() => + '' + Array(cols).fill('
').join('') + '' + ).join(''); + } + function extractEnvelope(record) { if (record && typeof record === 'object' && record.payload && record.payload.event && record.payload.schema) { return record.payload; @@ -292,6 +351,114 @@ 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 = 'No sessions found'; + 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 ` + + ${escapeHTML(s.session_id.substring(0, 12))}… + ${escapeHTML(fw)} + ${escapeHTML(s.host || '-')} + ${s.run_count} + ${escapeHTML(relativeTime(s.started_at))} + `; + }).join(''); + return `${escapeHTML(group.label)}${rows}`; + }).join(''); + tbody.querySelectorAll('tr.clickable').forEach(row => { + row.addEventListener('click', () => navigate('/sessions/' + row.dataset.session)); + }); + } + async function renderSessions() { app.innerHTML = ` `; - ['from', 'to', 'framework', 'host'].forEach(f => { + 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 }; + sessionsState = { sessions: [], cursor: null, timerInterval: null, activeSessionByBackend: {} }; await loadSessions(); sessionsState.timerInterval = setInterval(updateSessionTimers, 30000); @@ -350,8 +541,10 @@ const fw = s.framework || 'unknown'; const fwClass = fw.replace(/[^a-z0-9-]/g, '-'); const active = isSessionActive(s); - const dotState = active ? 'active' : 'ended'; - const dotTitle = active ? 'Active session' : 'Session ended'; + const dotState = sessionDotState(s); + const dotTitle = dotState === 'active' + ? 'Currently active session' + : (active ? 'Open session' : 'Session ended'); return ` ${escapeHTML(s.session_id.substring(0, 12))}… ${escapeHTML(fw)} @@ -377,38 +570,40 @@ 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 source = msg.data.event?.source; const newSession = { session_id: sessionId, - started_at: msg.data.event?.ts, - framework: source?.framework || 'unknown', - host: source?.host || '-', + started_at: ts || new Date().toISOString(), + framework: source.framework || 'unknown', + client_id: source.client_id || '', + host: source.host || '-', run_count: 1, - active: true, + _lastActivityTS: Date.parse(ts || '') || Date.now(), }; sessionsState.sessions.unshift(newSession); - const tbody = document.getElementById('sessions-body'); - if (tbody) { - const row = tbody.insertRow(0); - row.className = 'clickable active'; - row.dataset.session = newSession.session_id; - row.innerHTML = renderSessionRow(newSession); - row.addEventListener('click', () => navigate('/sessions/' + row.dataset.session)); - } + 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 tbody = document.getElementById('sessions-body'); - if (tbody) { - const row = tbody.querySelector(`[data-session="${sessionId}"]`); - if (row) row.cells[3].textContent = session.run_count; - } + const row = tbody.querySelector(`[data-session="${sessionId}"]`); + if (row && row.cells[3]) row.cells[3].textContent = session.run_count; } } @@ -416,17 +611,11 @@ const session = sessionsState.sessions.find(s => s.session_id === sessionId); if (session) { session.ended_at = new Date().toISOString(); - const tbody = document.getElementById('sessions-body'); - if (tbody) { - const row = tbody.querySelector(`[data-session="${sessionId}"]`); - if (row) { - const dot = row.querySelector('.fw-dot'); - dot.classList.remove('active'); - dot.classList.add('ended'); - } - } + recomputeActiveSessionByBackend(); } } + + refreshSessionsTable(); } async function loadSessions() { @@ -436,6 +625,18 @@ 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); @@ -443,31 +644,15 @@ if (sessionsState.cursor) params.set('cursor', sessionsState.cursor); const data = await api('/v1/sessions?' + params.toString()); - sessionsState.sessions = sessionsState.sessions.concat(data.sessions || []); + 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(); - const tbody = document.getElementById('sessions-body'); - tbody.innerHTML = sessionsState.sessions.map(s => { - const fw = s.framework || 'unknown'; - const fwClass = fw.replace(/[^a-z0-9-]/g, '-'); - const active = isSessionActive(s); - const dotState = active ? 'active' : 'ended'; - const dotTitle = active ? 'Active session' : 'Session ended'; - return ` - - ${escapeHTML(s.session_id.substring(0, 12))}… - ${escapeHTML(fw)} - ${escapeHTML(s.host || '-')} - ${s.run_count} - ${escapeHTML(relativeTime(s.started_at))} - - `; - }).join('') || 'No sessions found'; - - tbody.querySelectorAll('tr.clickable').forEach(row => { - row.addEventListener('click', () => navigate('/sessions/' + row.dataset.session)); - }); - + refreshSessionsTable(); document.getElementById('load-more').style.display = sessionsState.cursor ? 'block' : 'none'; } @@ -538,12 +723,15 @@ 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; - - loadSessionData(sessionID); + 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) { @@ -561,8 +749,119 @@ 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(`
Input
${escapeHTML(inputStr)}
`); + } + if (inner.result_preview !== undefined) { + parts.push(`
Result
${escapeHTML(String(inner.result_preview))}
`); + } + } else if (sp.kind === 'agent') { + if (inner.prompt_preview) { + parts.push(`
Prompt
${escapeHTML(String(inner.prompt_preview))}
`); + } + 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(`
Tokens${escapeHTML(tokens)}
`); + if (u.total_cost != null) { + parts.push(`
Cost${escapeHTML(formatCost(u.total_cost))}
`); + } + } + if (inner.model) { + parts.push(`
Model${escapeHTML(String(inner.model))}
`); + } + } else { + const raw = Object.keys(inner).length > 0 ? inner : (Object.keys(outer).length > 0 ? outer : null); + if (raw) { + parts.push(`
${escapeHTML(JSON.stringify(raw, null, 2))}
`); + } + } + + if (sp.duration_ms != null) { + parts.push(`
Duration${escapeHTML(formatDuration(sp.duration_ms))}
`); + } + + return parts.length > 0 + ? parts.join('') + : 'No payload data'; + } + + function renderRunSpansRows(spans) { + if (!spans || spans.length === 0) { + return 'No spans'; + } + return spans.map((sp, i) => { + const kindClass = sp.kind || 'unknown'; + return ` + + + + ${escapeHTML(sp.kind || '?')} + ${escapeHTML(sp.name || '(unnamed)')} + + ${escapeHTML(sp.kind || '-')} + ${statusIcon(sp.status)} + ${escapeHTML(formatDuration(sp.duration_ms))} + + + +
${renderSpanPayload(sp)}
+ + `; + }).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) { - const data = await api('/v1/runs/' + runID); + app.innerHTML = '
' + '
' + skeletonRows(5, 4) + '
NameKindStatusDuration
'; + runLiveOps = {}; + let data; + try { + data = await api('/v1/runs/' + runID); + } catch (e) { + app.innerHTML = `

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

`; + return; + } + const r = data.run; const spans = data.spans || []; const duration = r.ended_at @@ -572,7 +871,7 @@ app.innerHTML = ` ← Back to Session -
Spans ${spans.length}
+ ${!r.ended_at ? '
' : ''} +
+ Spans ${spans.length} + ${!r.ended_at ? 'Live' : ''} +
@@ -596,43 +901,138 @@ - ${spans.map((sp, i) => ` - - - - - - - - - - `).join('') || ''} + ${renderRunSpansRows(spans)}
No spans
`; - document.querySelectorAll('tr.expandable').forEach(row => { - row.addEventListener('click', () => { - const idx = row.dataset.index; - const detailRow = document.querySelector(`tr.span-detail-row[data-index="${idx}"]`); - const icon = row.querySelector('.expand-icon'); - if (detailRow.style.display === 'none') { - detailRow.style.display = 'table-row'; - icon.style.transform = 'rotate(45deg)'; - } else { - detailRow.style.display = 'none'; - icon.style.transform = ''; - } - }); - }); + 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 = `
${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 ` +
+ ${icon} + ${escapeHTML(op.name)} + ${preview ? `${escapeHTML(preview.length > 60 ? preview.slice(0, 60) + '…' : preview)}` : ''} + ${formatElapsed(elapsed)} +
`; + }).join('')}
`; + } + + 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) { @@ -722,6 +1122,14 @@ }); 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(); + } + }); }); } @@ -764,14 +1172,12 @@ if (eventType === 'swarm.snapshot') { mergeSwarmSnapshot(msg.data); if (isCurrentPath('/infrastructure')) renderInfraGrid(); - renderSwarmStrip_dash(); return; } if (eventType === 'swarm.service.snapshot') { mergeSwarmServiceSnapshot(msg.data); if (isCurrentPath('/infrastructure')) renderInfraGrid(); - renderSwarmStrip_dash(); return; } } @@ -812,6 +1218,7 @@ 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 = ` +
+

K8s Homelab

+ ${homelabServices.length === 0 + ? '

No k8s homelab service data

' + : `
${homelabServices.map(svc => renderHomelabServiceCard(svc)).join('')}
` + } +
+

Agentmon

${agentmonServices.length === 0 @@ -1077,6 +1492,51 @@ `; } + 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 ` +
+ ${serviceCardHeader(svc)} +
+ ${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') : ''} +
+
+ `; + } + function createAgentsState() { return { agents: {}, @@ -1089,7 +1549,7 @@ } function getVMStatus() { - const names = ['zap', 'orb', 'sun']; + const names = Object.keys(openclawState.instances).sort(); return names.map(name => { const snapshot = openclawState.instances[name]; const payload = snapshot ? getEnvelopePayload(snapshot) : {}; @@ -1206,7 +1666,7 @@ function isOpenClawVM(agent) { const key = normalizeAgentKey(agent && agent.name); - return ['zap', 'orb', 'sun'].includes(key); + return !!openclawState.instances[key]; } function isAgentOnline(agent) { @@ -1249,28 +1709,61 @@ } 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) { - delete agent.operations['s:' + 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) { - delete agent.operations['r:' + 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); @@ -1304,6 +1797,38 @@ ].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(); @@ -1583,6 +2108,16 @@ 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); @@ -1703,6 +2238,83 @@ 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 '
Idle — waiting for activity
'; + } + + 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 ` +
+
+ ${icon} + ${escapeHTML(kindLabel)} + ${escapeHTML(op.name)} + + ${isEnded ? formatElapsed(elapsed) : `${formatElapsed(elapsed)}`} + + ${navigableRunID ? '' : ''} +
+ ${preview ? `
${escapeHTML(preview.length > 180 ? preview.slice(0, 180) + '…' : preview)}
` : ''} + ${result ? `
${escapeHTML(result.length > 180 ? result.slice(0, 180) + '…' : result)}
` : ''} + ${(thinkingToks || totalToks) ? `
${thinkingToks ? `🧠 ${formatTokenCount(thinkingToks)} thinking` : ''}${totalToks ? `⚡ ${formatTokenCount(totalToks)} total` : ''}
` : ''} +
`; + }).join(''); + } + function renderAgentsLive() { const contentEl = document.getElementById('agents-content'); if (!contentEl) return; @@ -1754,28 +2366,34 @@
-
-
Current State
-
Active subagents${escapeHTML(summary.activeSubagents.map(op => op.name).join(', ') || '-')}
-
Active tools${escapeHTML(summary.activeTools.map(op => op.name).join(', ') || '-')}
-
Latest prompt${escapeHTML(summary.latestPrompt || '-')}
-
Run status${escapeHTML(summary.latestRunStatus || 'in_progress')}
-
Model${escapeHTML(summary.latestModel || '-')}
-
Last error${escapeHTML(summary.latestError || '-')}
+
+
+ Live Operations + ${summary.activeOps.length > 0 ? `${summary.activeOps.length} active` : ''} +
+
+ ${renderThinkingStream(selected)} +
-
Usage
-
Total tokens${escapeHTML(formatCount(summary.latestUsage && summary.latestUsage.total_tokens))}
-
Input tokens${escapeHTML(formatCount(summary.latestUsage && summary.latestUsage.input_tokens))}
-
Output tokens${escapeHTML(formatCount(summary.latestUsage && summary.latestUsage.output_tokens))}
-
Total cost${escapeHTML(formatCost(summary.latestUsage && summary.latestUsage.total_cost))}
+
Last Run
+
Status${escapeHTML(summary.latestRunStatus || '—')}
+
Model${escapeHTML(summary.latestModel || '—')}
+
Tokens${escapeHTML(formatTokenCount(summary.latestUsage ? summary.latestUsage.total_tokens : null))}
+
Thinking${escapeHTML(formatTokenCount(summary.latestUsage ? summary.latestUsage.thinking_tokens : null))}
+
Cost${escapeHTML(formatCost(summary.latestUsage ? summary.latestUsage.total_cost : null))}
+ ${summary.latestError ? `
Error${escapeHTML(summary.latestError)}
` : ''}
-
Session Context
-
Session IDs${escapeHTML(summary.sessionIDs.join(', ') || '-')}
-
Window input${escapeHTML(formatCount(summary.latestContextWindow && summary.latestContextWindow.input_tokens))}
-
Window output${escapeHTML(formatCount(summary.latestContextWindow && summary.latestContextWindow.output_tokens))}
-
Remaining${escapeHTML(formatCount(summary.latestContextWindow && (summary.latestContextWindow.tokens_remaining ?? summary.latestContextWindow.used_tokens)))}
+
Context Window
+
Input${escapeHTML(formatTokenCount(summary.latestContextWindow ? summary.latestContextWindow.input_tokens : null))}
+
Output${escapeHTML(formatTokenCount(summary.latestContextWindow ? summary.latestContextWindow.output_tokens : null))}
+
Used${escapeHTML(formatTokenCount(summary.latestContextWindow ? summary.latestContextWindow.used_tokens : null))}
+
Remaining${escapeHTML(formatTokenCount(summary.latestContextWindow ? summary.latestContextWindow.tokens_remaining : null))}
+ ${summary.latestContextWindow && summary.latestContextWindow.max_tokens ? ` +
+
+
` : ''}
@@ -1811,6 +2429,17 @@ 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() { @@ -1819,13 +2448,23 @@ 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]); - renderAgentsContent(); + scheduleAgentsRender(); return; } if (!isAgentTimelineEvent(msg.data)) return; @@ -1837,7 +2476,7 @@ } else if (eventType === 'error') agentsState.dbStats.errors++; addAgentEvents([msg.data]); - renderAgentsContent(); + scheduleAgentsRender(); } function updateAgentTimers() { @@ -1855,6 +2494,7 @@ } } }); + } function addAgentEvents(events) { @@ -2007,14 +2647,53 @@ 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 = ` @@ -2043,19 +2722,44 @@
 
+
+
+ Tokens today + - +
+
+ Cost today + - +
+
+ Avg run duration + - +
+
+ Error rate + - +
+
Infrastructure
-
- Event Rate -
+
+ Event Rate + Runs, tool spans, and errors over time +
+
+ total runs tools errors
+
+ + +
@@ -2064,13 +2768,19 @@
+
+
-
+
- By Framework +
+ + + +
-
+

Loading...

@@ -2086,11 +2796,20 @@
- Top Tools + Top Usage +
+
+
Tools
+
    +
  • Loading...
  • +
+
+
+
Models
+
    +
  • Loading...
  • +
-
    -
  • Loading...
  • -
`; @@ -2104,30 +2823,64 @@ }); }); + 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(); renderFrameworkBars(); } + if (cachedTS) { dashboardState.timeseries = cachedTS; renderTimeseriesChart(); renderRightPanel(); } try { - const [summaryData, tsData, recentData, snapshots, swarmSnaps, topToolsData] = await Promise.all([ + 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=20'), + 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 || []); - renderDashVMStrip(); for (const evt of swarmSnaps.events || []) mergeSwarmSnapshot(evt); - renderSwarmStrip_dash(); + renderDashVMStrip(); dashboardState.summary = summaryData; dashboardState.timeseries = tsData; @@ -2135,26 +2888,26 @@ localStorage.setItem('agentmon:dash:ts:' + dashboardState.window, JSON.stringify(tsData)); renderSummaryCards(); renderTimeseriesChart(); - renderFrameworkBars(); + 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(isAgentTimelineEvent) + .filter(isDashboardFeedEvent) .slice() .reverse(); for (const evt of events) { - const id = getRecordID(evt); - if (id && !dashboardState.recentEventIDs.has(id)) { - dashboardState.recentEventIDs.add(id); - dashboardState.recentEvents.push(evt); - } + addDashboardRecentEvent(evt); } renderDashFeed(); renderDashTopTools(); + renderDashTopModels(); } catch (e) { console.error('Dashboard load error:', e); } @@ -2166,32 +2919,23 @@ const strip = document.getElementById('dash-vm-strip'); if (!strip) return; const vms = getVMStatus(); - strip.innerHTML = vms.map(vm => ` + const infra = getDashboardInfraPill(); + strip.innerHTML = [ + ...vms.map(vm => `
${escapeHTML(vm.name)} ${vm.active ? 'online' : 'offline'}
- `).join(''); - } - - function renderSwarmStrip_dash() { - const strip = document.getElementById('dash-swarm-strip'); - if (!strip) return; - const services = Object.values(swarmState.services); - if (services.length === 0) return; - strip.innerHTML = services.map(svc => { - const statusClass = svc.status === 'healthy' ? 'active' - : svc.status === 'degraded' ? 'degraded' : 'inactive'; - const label = svc.status || 'unknown'; - return ` -
- - ${escapeHTML(svc.name)} - ${escapeHTML(label)} -
- `; - }).join(''); + `), + ` +
+ + ${escapeHTML(infra.name)} + ${escapeHTML(infra.label)} +
+ `, + ].join(''); } function handleDashboardWS(msg) { @@ -2206,12 +2950,12 @@ } if (eventType === 'swarm.snapshot') { mergeSwarmSnapshot(msg.data); - renderSwarmStrip_dash(); + renderDashVMStrip(); return; } if (eventType === 'swarm.service.snapshot') { mergeSwarmServiceSnapshot(msg.data); - renderSwarmStrip_dash(); + renderDashVMStrip(); return; } @@ -2224,29 +2968,40 @@ 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 (!isAgentTimelineEvent(msg.data)) { + if (!isDashboardFeedEvent(msg.data)) { if (dashboardState.timeseries && dashboardState.window === '1h') { appendToCurrentBucket(msg.data); } return; } - const id = getRecordID(msg.data); - if (id && !dashboardState.recentEventIDs.has(id)) { - dashboardState.recentEventIDs.add(id); - dashboardState.recentEvents.push(msg.data); + if (addDashboardRecentEvent(msg.data)) { tallyTool(msg.data); + tallyModel(msg.data); - while (dashboardState.recentEvents.length > 50) { - const removed = dashboardState.recentEvents.shift(); - dashboardState.recentEventIDs.delete(getRecordID(removed)); + if (!_dashFeedRenderTimer) { + _dashFeedRenderTimer = requestAnimationFrame(() => { + _dashFeedRenderTimer = null; + renderDashFeed(); + renderDashTopTools(); + renderDashTopModels(); + }); } - - renderDashFeed(); - renderDashTopTools(); } if (dashboardState.timeseries && dashboardState.window === '1h') { @@ -2265,6 +3020,24 @@ } } + 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; @@ -2290,6 +3063,19 @@ 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() { @@ -2299,31 +3085,145 @@ dashboardChart.destroy(); dashboardChart = null; } + dashboardState.chartCursorIndex = null; const cachedWin = tryParseJSON(localStorage.getItem('agentmon:dash:ts:' + dashboardState.window)); - if (cachedWin) { dashboardState.timeseries = cachedWin; renderTimeseriesChart(); } + 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; - // Stacked: errors on bottom, then tools, then runs on top - const errors = ts.series.map(b => b.errors); - const tools = ts.series.map((b, i) => b.tools + errors[i]); - const runs = ts.series.map((b, i) => b.runs + tools[i]); - return [ - ts.series.map(b => Math.floor(new Date(b.ts).getTime() / 1000)), - runs, - tools, - errors, - ]; + + 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 = ` +
window total${escapeHTML(formatCount(stats.totalEvents))}
+
peak bucket${escapeHTML(formatCount(stats.peakTotal))}${escapeHTML(formatBucketLabel(peakBucket.ts))}
+
mix${escapeHTML(formatCount(stats.totalRuns))}r / ${escapeHTML(formatCount(stats.totalTools))}t / ${escapeHTML(formatCount(stats.totalErrors))}e
+
bucket${escapeHTML(dashboardState.timeseries.bucket || '-')}${escapeHTML(String(stats.bucketCount))} points
+ `; + } + + 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 = ` +
+
+
${escapeHTML(bucketLabel)}
+
${escapeHTML(formatBucketLabel(bucket.ts))}
+
+
+ Total + ${escapeHTML(formatCount(total))} +
+
+
+
Runs${escapeHTML(formatCount(bucket.runs || 0))}
+
Tools${escapeHTML(formatCount(bucket.tools || 0))}
+
Errors${escapeHTML(formatCount(bucket.errors || 0))}
+
Delta${escapeHTML(deltaLabel)}
+
+ `; } function renderTimeseriesChart() { @@ -2331,6 +3231,8 @@ if (!container || !dashboardState.timeseries) return; const data = buildChartData(); + renderDashboardChartInsights(); + renderDashboardChartHover(dashboardState.chartCursorIndex); if (!data) { container.innerHTML = '

No data for this window

'; return; @@ -2347,10 +3249,68 @@ 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 }, @@ -2370,31 +3330,13 @@ size: 50, }, ], - series: [ - {}, - { - label: 'Runs', - stroke: '#34d399', - width: 1.5, - fill: 'rgba(52, 211, 153, 0.15)', - }, - { - label: 'Tools', - stroke: '#22d3ee', - width: 1.5, - fill: 'rgba(34, 211, 238, 0.15)', - }, - { - label: 'Errors', - stroke: '#f87171', - width: 1.5, - fill: 'rgba(248, 113, 113, 0.2)', - }, - ], - bands: [ - { series: [1, 2], fill: 'rgba(52, 211, 153, 0.15)' }, - { series: [2, 3], fill: 'rgba(34, 211, 238, 0.15)' }, - ], + 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); @@ -2424,7 +3366,7 @@ if (Math.abs(now - lastTs) < 60000) { bucket = last; } else { - bucket = { ts: new Date(now).toISOString(), runs: 0, tools: 0, errors: 0 }; + 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); } @@ -2435,12 +3377,129 @@ 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 = ` +
+
+
Total tokens today
+
${escapeHTML(formatTokenCount(totalTokens))}
+
+
+
+ Input +
+
+
+ ${escapeHTML(formatTokenCount(inputTokens))} +
+
+ Output +
+
+
+ ${escapeHTML(formatTokenCount(outputTokens))} +
+
+
+ Est. cost today + ${escapeHTML(totalCost ? formatCost(totalCost) : '$0.0000')} +
+
+ `; + } + + 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 = '

No latency data

'; + return; + } + + const durSeries = ts.series.map(b => b.avg_duration_ms || 0).filter(v => v > 0); + if (durSeries.length === 0) { + container.innerHTML = '

No run latency recorded yet

'; + 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 = ` +
+
+
+ Min + ${escapeHTML(formatDuration(min))} +
+
+ Avg + ${escapeHTML(formatDuration(avg))} +
+
+ Max + ${escapeHTML(formatDuration(max))} +
+
+
+ ${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 `
`; + }).join('')} +
+
Avg run duration per bucket (${escapeHTML(ts.bucket || '-')})
+
+ `; + } + function renderFrameworkBars() { - const container = document.getElementById('dash-fw-bars'); + const container = document.getElementById('dash-right-panel'); if (!container || !dashboardState.summary) return; const byFw = dashboardState.summary.by_framework || {}; @@ -2457,7 +3516,7 @@ const maxTotal = Math.max(...entries.map(([, s]) => s.runs + s.tools + s.errors)); - container.innerHTML = entries.map(([name, stats]) => { + container.innerHTML = '
' + entries.map(([name, stats]) => { const total = stats.runs + stats.tools + stats.errors; const pct = maxTotal > 0 ? (total / maxTotal * 100) : 0; const cssClass = name.toLowerCase().replace(/[^a-z0-9-]/g, '-'); @@ -2472,41 +3531,60 @@
`; - }).join(''); + }).join('') + '
'; + } + + 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 + ? `${escapeHTML(framework)}` + : ''; + const sessionID = correlation.session_id || ''; + const clickableClass = sessionID ? ' timeline-event-link' : ''; + const attrs = sessionID + ? ` role="link" tabindex="0" data-session-id="${escapeHTML(sessionID)}"` + : ''; + + return ` +
+
+ ${getEventIcon(eventType)} + ${tag} + ${escapeHTML(getEventLabel(eventType))} + ${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())} +
+ ${getEventBody(evt)} +
+ `; } function renderDashFeed() { const feed = document.getElementById('dash-feed'); if (!feed) return; - const recent = dashboardState.recentEvents.slice(-20).reverse(); + const recent = dashboardState.recentEvents.slice(-DASH_RECENT_EVENTS_LIMIT).reverse(); if (recent.length === 0) { feed.innerHTML = '

Waiting for events...

'; return; } - - feed.innerHTML = recent.map(evt => { - const eventType = getEnvelopeType(evt); - const vmName = getVMName(evt); - const vmClass = getVMClassName(vmName); - const source = getEnvelopeSource(evt); - const framework = source.framework || ''; - const tag = framework - ? `${escapeHTML(framework)}` - : ''; - - return ` -
-
- ${getEventIcon(eventType)} - ${tag} - ${escapeHTML(getEventLabel(eventType))} - ${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())} -
- ${getEventBody(evt)} -
- `; - }).join(''); + 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() { @@ -2539,5 +3617,35 @@ }).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 = '
  • No model data yet
  • '; + return; + } + + const maxCount = topModels[0]?.[1] || 1; + list.innerHTML = topModels.map(([name, count]) => { + const pct = (count / maxCount * 100).toFixed(1); + return ` +
  • +
    + ${escapeHTML(name)} + ${count} +
    +
    +
    +
    +
  • + `; + }).join(''); + } + route(); })(); diff --git a/cmd/web-ui/static/index.html b/cmd/web-ui/static/index.html index 0ef823b..af1f19e 100644 --- a/cmd/web-ui/static/index.html +++ b/cmd/web-ui/static/index.html @@ -17,7 +17,10 @@

    agentmon

    - +
    + + +

    Loading...

    diff --git a/cmd/web-ui/static/style.css b/cmd/web-ui/static/style.css index 13b82d9..3a1d256 100644 --- a/cmd/web-ui/static/style.css +++ b/cmd/web-ui/static/style.css @@ -832,6 +832,19 @@ tr.expandable:hover .expand-icon::before { border-color: rgba(34, 211, 238, 0.15); } +.timeline-event-link { + cursor: pointer; +} + +.timeline-event-link:hover { + border-color: rgba(34, 211, 238, 0.3); +} + +.timeline-event-link:focus-visible { + outline: 2px solid rgba(34, 211, 238, 0.45); + outline-offset: 2px; +} + .timeline-event-header { display: flex; align-items: center; @@ -1192,6 +1205,8 @@ tr.expandable:hover .expand-icon::before { align-items: center; justify-content: space-between; margin-bottom: 1rem; + gap: 1rem; + flex-wrap: wrap; } .chart-title { @@ -1202,6 +1217,27 @@ tr.expandable:hover .expand-icon::before { letter-spacing: 0.01em; } +.chart-title-group { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.chart-subtitle { + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--text-dim); + letter-spacing: 0.02em; +} + +.chart-header-controls { + display: flex; + align-items: center; + gap: 0.85rem; + flex-wrap: wrap; + justify-content: flex-end; +} + .window-selector { display: flex; gap: 0; @@ -1238,11 +1274,166 @@ tr.expandable:hover .expand-icon::before { background: var(--accent-dim); } +.mode-selector { + display: flex; + gap: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.mode-btn { + background: transparent; + border: none; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 0.68rem; + font-weight: 700; + padding: 0.3rem 0.7rem; + cursor: pointer; + letter-spacing: 0.05em; + text-transform: uppercase; + border-right: 1px solid var(--border); + transition: background 0.15s, color 0.15s; +} + +.mode-btn:last-child { + border-right: none; +} + +.mode-btn:hover { + color: var(--text-bright); + background: var(--surface-2); +} + +.mode-btn.active { + color: var(--success); + background: rgba(52, 211, 153, 0.12); +} + +.chart-insights { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.75rem; + margin-bottom: 0.9rem; +} + +@media (max-width: 900px) { + .chart-insights { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 560px) { + .chart-insights { + grid-template-columns: 1fr; + } +} + +.chart-insight-pill { + min-height: 62px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0)); + padding: 0.75rem 0.85rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.chart-insight-label, +.chart-insight-meta { + font-family: var(--font-mono); + font-size: 0.68rem; + color: var(--text-dim); + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.chart-insight-pill strong { + font-size: 0.95rem; + color: var(--text-bright); +} + .chart-container { width: 100%; min-height: 200px; } +.chart-hover-panel { + margin-top: 0.9rem; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--surface-2); + padding: 0.9rem 1rem; +} + +.chart-hover-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.8rem; +} + +.chart-hover-label, +.chart-hover-total span, +.chart-hover-metric span { + font-family: var(--font-mono); + font-size: 0.68rem; + color: var(--text-dim); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.chart-hover-time { + color: var(--text-bright); + font-size: 0.85rem; + margin-top: 0.25rem; +} + +.chart-hover-total { + text-align: right; +} + +.chart-hover-total strong { + display: block; + color: var(--text-bright); + font-size: 1.15rem; + margin-top: 0.15rem; +} + +.chart-hover-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.75rem; +} + +@media (max-width: 720px) { + .chart-hover-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.chart-hover-metric { + border-radius: var(--radius); + padding: 0.7rem 0.8rem; + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.04); +} + +.chart-hover-metric strong { + display: block; + margin-top: 0.2rem; + color: var(--text-bright); + font-size: 1rem; +} + +.chart-hover-metric.runs strong { color: #34d399; } +.chart-hover-metric.tools strong { color: #22d3ee; } +.chart-hover-metric.errors strong { color: #f87171; } +.chart-hover-metric.delta strong { color: var(--accent); } + .fw-bars { display: flex; flex-direction: column; @@ -1341,6 +1532,22 @@ tr.expandable:hover .expand-icon::before { padding: 1.25rem; } +.usage-rank-group + .usage-rank-group { + margin-top: 1.15rem; + padding-top: 1rem; + border-top: 1px solid var(--border-soft); +} + +.usage-rank-header { + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 700; + color: var(--text-dim); + letter-spacing: 0.06em; + text-transform: uppercase; + margin-bottom: 0.45rem; +} + .uplot .u-legend { display: none; } /* ── Chart legend ─────────────────────────────────────────── */ @@ -1366,6 +1573,15 @@ tr.expandable:hover .expand-icon::before { flex-shrink: 0; } +.chart-legend-dot.total { + background: #f8fafc; + box-shadow: 0 0 0 1px rgba(248, 250, 252, 0.2); +} + +.stat-list-bar-fill.model { + background: var(--success); +} + /* ── Framework dots ───────────────────────────────────────── */ .fw-dot { display: inline-block; @@ -2216,3 +2432,757 @@ tr.expandable:hover .expand-icon::before { text-transform: uppercase; letter-spacing: 0.08em; } + +/* ── Header right cluster ─────────────────────────────────── */ +.header-right { + display: flex; + align-items: center; + gap: 0; + flex-shrink: 0; +} + +/* ── WebSocket status dot ─────────────────────────────────── */ +.ws-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--text-dim); + opacity: 0.3; + flex-shrink: 0; + margin-right: 0.6rem; + cursor: default; + transition: background 0.4s, opacity 0.4s, box-shadow 0.4s; +} + +.ws-dot.connected { + background: var(--success); + opacity: 1; + box-shadow: 0 0 5px rgba(52, 211, 153, 0.5); + animation: livePulse 2.5s ease-in-out infinite; +} + +.ws-dot.reconnecting { + background: var(--warning); + opacity: 1; + animation: livePulse 0.9s ease-in-out infinite; +} + +/* ── Session date group header row ────────────────────────── */ +tr.session-date-group > td { + padding: 0.85rem 1.25rem 0.35rem; + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text-dim); + background: var(--surface-2); + border-bottom: 1px solid var(--border-soft); + pointer-events: none; + user-select: none; +} + +tr.session-date-group:first-child > td { + padding-top: 0.5rem; +} + +/* ── Span kind badge ──────────────────────────────────────── */ +.span-kind-badge { + display: inline-flex; + align-items: center; + padding: 0.08rem 0.4rem; + border-radius: 3px; + font-size: 0.6rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-right: 0.4rem; + vertical-align: middle; + font-family: var(--font-mono); +} + +.span-kind-badge.tool { + background: rgba(34, 211, 238, 0.1); + color: var(--accent); + border: 1px solid rgba(34, 211, 238, 0.18); +} + +.span-kind-badge.agent { + background: rgba(167, 139, 250, 0.1); + color: var(--purple); + border: 1px solid rgba(167, 139, 250, 0.18); +} + +.span-kind-badge.unknown, +.span-kind-badge.other { + background: var(--surface-2); + color: var(--text-dim); + border: 1px solid var(--border); +} + +/* ── Structured span detail panel ─────────────────────────── */ +.span-details-structured { + padding: 1rem 1.25rem; + border-top: 1px solid var(--border); + background: var(--bg); + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.span-kv { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 0.8rem; + align-items: start; + font-size: 0.8rem; +} + +.span-kv.ids { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid var(--border-soft); +} + +.span-kv-key { + font-family: var(--font-mono); + font-size: 0.65rem; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.07em; + padding-top: 0.15rem; +} + +.span-kv-val { + color: var(--text); + word-break: break-word; + line-height: 1.55; + margin: 0; +} + +.span-kv-val.span-kv-raw, +.span-kv-raw { + font-family: var(--font-mono); + font-size: 0.73rem; + color: var(--code-text); + white-space: pre-wrap; + word-break: break-all; + line-height: 1.65; + margin: 0; +} + +/* ── Metrics strip ────────────────────────────────────────── */ +.metrics-strip { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1.25rem; +} + +.metric-pill { + display: flex; + flex-direction: column; + gap: 0.15rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.5rem 0.85rem; + min-width: 130px; + flex: 1; +} + +.metric-pill-label { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-dim); +} + +.metric-pill-value { + font-size: 1.05rem; + font-weight: 600; + font-family: 'Fira Code', monospace; + color: var(--text); +} + +.metric-pill-alert.alert { + color: var(--error); +} + +/* ── Right panel tabs ─────────────────────────────────────── */ +.right-panel-tabs { + display: flex; + gap: 0.25rem; +} + +.right-panel-tab { + background: none; + border: 1px solid transparent; + border-radius: 6px; + color: var(--text-dim); + cursor: pointer; + font-size: 0.78rem; + font-family: inherit; + padding: 0.25rem 0.6rem; + transition: color 0.15s, border-color 0.15s, background 0.15s; +} + +.right-panel-tab:hover { + color: var(--text); + border-color: var(--border); +} + +.right-panel-tab.active { + color: var(--accent); + border-color: var(--accent); + background: rgba(var(--accent-rgb, 99, 102, 241), 0.08); +} + +.right-panel-body { + flex: 1; + overflow: auto; +} + +/* ── Token panel ──────────────────────────────────────────── */ +.token-panel { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 0.75rem 1rem; +} + +.token-stat-big { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.token-stat-label { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-dim); +} + +.token-stat-value { + font-size: 2rem; + font-weight: 700; + font-family: 'Fira Code', monospace; + color: var(--text); + line-height: 1.1; +} + +.token-io-bars { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.token-bar-row { + display: grid; + grid-template-columns: 3.5rem 1fr 3rem; + align-items: center; + gap: 0.5rem; +} + +.token-bar-label { + font-size: 0.72rem; + color: var(--text-dim); + text-align: right; +} + +.token-bar-track { + height: 6px; + background: var(--border); + border-radius: 3px; + overflow: hidden; +} + +.token-bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s ease; +} + +.token-bar-fill.input { + background: var(--accent); +} + +.token-bar-fill.output { + background: var(--purple, #a78bfa); +} + +.token-bar-count { + font-size: 0.72rem; + font-family: 'Fira Code', monospace; + color: var(--text-muted, var(--text-dim)); + text-align: right; +} + +.token-cost-display { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 0.5rem 0; + border-top: 1px solid var(--border); + font-size: 0.82rem; +} + +.token-cost-display strong { + font-family: 'Fira Code', monospace; + font-size: 1rem; + color: var(--success, #34d399); +} + +/* ── Latency panel ────────────────────────────────────────── */ +.latency-panel { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.75rem 1rem; +} + +.latency-range { + display: flex; + justify-content: space-between; +} + +.latency-range-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.1rem; +} + +.latency-range-label { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-dim); +} + +.latency-range-val { + font-size: 1rem; + font-weight: 600; + font-family: 'Fira Code', monospace; + color: var(--text); +} + +.latency-mini-bars { + display: flex; + align-items: flex-end; + gap: 2px; + height: 80px; + padding: 4px 0; +} + +.latency-mini-bar { + flex: 1; + min-height: 2px; + background: var(--accent); + border-radius: 2px 2px 0 0; + opacity: 0.7; + transition: opacity 0.15s; + cursor: pointer; +} + +.latency-mini-bar:hover { + opacity: 1; +} + +/* ── Thinking stream (agents live view) ───────────────────── */ +.thinking-stream-card { + min-width: 0; +} + +.thinking-stream { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.25rem 0; + min-height: 3rem; +} + +.thinking-stream-empty { + color: var(--text-dim); + font-size: 0.8rem; + padding: 0.5rem 0; +} + +.thinking-op { + border-radius: 8px; + padding: 0.55rem 0.75rem; + border-left: 3px solid var(--border); + background: var(--bg); + transition: border-color 0.2s, opacity 0.3s; +} + +.thinking-op.active { + border-left-color: var(--accent); +} + +.thinking-op.ended { + border-left-color: var(--success, #34d399); + opacity: 0.6; +} + +.thinking-op-run.active { + border-left-color: var(--accent); + background: rgba(99, 102, 241, 0.06); +} + +.thinking-op-subagent.active { + border-left-color: var(--purple, #a78bfa); + background: rgba(167, 139, 250, 0.06); +} + +.thinking-op-tool.active { + border-left-color: #22d3ee; + background: rgba(34, 211, 238, 0.04); +} + +.thinking-op-link { + cursor: pointer; +} + +.thinking-op-link:hover { + border-left-width: 3px; + filter: brightness(1.1); +} + +.thinking-op-link:hover .thinking-op-arrow { + opacity: 1; + transform: translateX(2px); +} + +.thinking-op-arrow { + margin-left: auto; + font-size: 0.8rem; + color: var(--text-dim); + opacity: 0.4; + transition: opacity 0.15s, transform 0.15s; + flex-shrink: 0; +} + +.thinking-op-header { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; +} + +.thinking-op-icon { + font-size: 0.9rem; + line-height: 1; + flex-shrink: 0; + color: var(--text-dim); + width: 1rem; + text-align: center; +} + +.thinking-op-icon.spin { + animation: thinkSpin 1.4s linear infinite; +} + +@keyframes thinkSpin { + 0% { content: '◌'; opacity: 1; } + 25% { opacity: 0.4; } + 50% { opacity: 1; } + 75% { opacity: 0.4; } + 100% { opacity: 1; } +} + +/* Pulsing opacity trick since content can't be animated */ +.thinking-op-icon.spin { + animation: thinkPulse 1s ease-in-out infinite; +} + +@keyframes thinkPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +.thinking-op-kind { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-dim); + min-width: 4.5rem; +} + +.thinking-op-name { + font-weight: 600; + font-size: 0.82rem; + color: var(--text); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.thinking-op-elapsed { + font-family: 'Fira Code', monospace; + font-size: 0.72rem; + color: var(--text-dim); + flex-shrink: 0; +} + +.thinking-op-elapsed.live { + color: var(--accent); +} + +.thinking-op-preview { + margin-top: 0.3rem; + font-size: 0.75rem; + color: var(--text-muted, var(--text-dim)); + line-height: 1.4; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + padding-left: 1.4rem; +} + +.thinking-op-result { + margin-top: 0.25rem; + font-size: 0.75rem; + color: var(--success, #34d399); + font-family: 'Fira Code', monospace; + line-height: 1.4; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + padding-left: 1.4rem; +} + +.thinking-op-tokens { + display: flex; + gap: 0.4rem; + margin-top: 0.3rem; + padding-left: 1.4rem; +} + +.thinking-tok-badge { + font-size: 0.68rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.1rem 0.35rem; + color: var(--text-dim); +} + +.thinking-toks { + color: #a78bfa; +} + +.error-kv strong { + color: var(--error); +} + +/* ── Context bar ──────────────────────────────────────────── */ +.context-bar { + margin-top: 0.5rem; + height: 4px; + background: var(--border); + border-radius: 2px; + overflow: hidden; +} + +.context-bar-fill { + height: 100%; + background: var(--accent); + border-radius: 2px; + transition: width 0.4s ease; +} + +/* ── Run detail live ops banner ────────────────────────────── */ +.run-live-ops { + margin-bottom: 1rem; +} + +.run-live-ops-inner { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; +} + +.run-live-op-pill { + display: inline-flex; + align-items: center; + gap: 0.35rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 20px; + padding: 0.25rem 0.65rem; + font-size: 0.78rem; + max-width: 320px; +} + +.run-live-op-pill.thinking { + border-color: var(--accent); + background: rgba(99, 102, 241, 0.08); +} + +.run-live-op-pill.subagent { + border-color: var(--purple, #a78bfa); + background: rgba(167, 139, 250, 0.08); +} + +.run-live-op-pill.tool { + border-color: #22d3ee; + background: rgba(34, 211, 238, 0.06); +} + +.run-live-op-spin { + animation: thinkPulse 1s ease-in-out infinite; + font-size: 0.75rem; + flex-shrink: 0; +} + +.run-live-op-name { + font-weight: 600; + color: var(--text); +} + +.run-live-op-preview { + color: var(--text-dim); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 140px; +} + +.run-live-op-time { + font-family: 'Fira Code', monospace; + font-size: 0.7rem; + color: var(--accent); + flex-shrink: 0; +} + +/* ── Mobile: agents live — sidebar after content ──────────── */ +@media (max-width: 640px) { + .agents-live-sidebar { + order: 2; + } +} + +/* ── Mobile: compact filters ──────────────────────────────── */ +@media (max-width: 480px) { + .filters { + padding: 0.75rem; + gap: 0.5rem; + } + + .filters label { + width: 100%; + } + + .filters input, + .filters select { + width: 100%; + min-width: unset; + } + + .chart-legend { + display: none; + } + + .chart-header-controls { + justify-content: flex-start; + gap: 0.5rem; + } + + .window-selector, + .mode-selector { + flex-shrink: 0; + } +} + +/* ── Toast notifications ──────────────────────────────── */ +.toast { + position: fixed; + bottom: 1.5rem; + left: 50%; + transform: translateX(-50%) translateY(1rem); + opacity: 0; + z-index: 9999; + padding: 0.65rem 1.25rem; + border-radius: 8px; + font-family: var(--font-body); + font-size: 0.82rem; + font-weight: 500; + color: var(--text-bright); + background: var(--surface); + border: 1px solid var(--border); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + transition: opacity 0.3s ease, transform 0.3s ease; + pointer-events: none; + max-width: 480px; + text-align: center; +} +.toast.visible { + opacity: 1; + transform: translateX(-50%) translateY(0); +} +.toast-error { + border-color: var(--error); + background: rgba(248, 113, 113, 0.12); +} +.toast-info { + border-color: var(--accent); + background: rgba(34, 211, 238, 0.08); +} + +/* ── 404 page ─────────────────────────────────────────── */ +.not-found { + text-align: center; + padding: 6rem 2rem; +} +.not-found h2 { + font-family: var(--font-display); + font-size: 1.6rem; + color: var(--text-bright); + margin-bottom: 0.5rem; +} +.not-found p { + color: var(--text-dim); + margin-bottom: 1.5rem; +} + +/* ── Skeleton loading ─────────────────────────────────── */ +.skeleton-line { + height: 0.85rem; + width: 80%; + border-radius: 4px; + background: linear-gradient( + 90deg, + var(--border) 25%, + rgba(255, 255, 255, 0.04) 50%, + var(--border) 75% + ); + background-size: 200% 100%; + animation: skeleton-pulse 1.5s ease-in-out infinite; +} + +@keyframes skeleton-pulse { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* Vary widths in table cells for a natural look */ +tbody .skeleton-line { margin: 0.15rem 0; } +tbody td:nth-child(1) .skeleton-line { width: 70%; } +tbody td:nth-child(2) .skeleton-line { width: 55%; } +tbody td:nth-child(3) .skeleton-line { width: 45%; } +tbody td:nth-child(4) .skeleton-line { width: 30%; } +tbody td:nth-child(5) .skeleton-line { width: 40%; } + +/* ── Keyboard accessibility ───────────────────────────── */ +tr.expandable[tabindex="0"]:focus-visible, +tr.expandable-run[tabindex="0"]:focus-visible, +tr.run-span-row[tabindex="0"]:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +}