diff --git a/cmd/web-ui/main.go b/cmd/web-ui/main.go index 817d810..f8adb59 100644 --- a/cmd/web-ui/main.go +++ b/cmd/web-ui/main.go @@ -19,13 +19,59 @@ import ( var staticFiles embed.FS var wsUpgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, + // Only accept WebSocket upgrades whose Origin matches the Host header. + // This blocks CSWSH: a third-party page can't open the live event + // firehose just because the user has this UI in another tab. + CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return false + } + u, err := url.Parse(origin) + if err != nil || u.Host == "" { + return false + } + return u.Host == r.Host + }, +} + +// securityHeaders sets sensible defaults on every response. The CSP allows +// the external CDNs currently referenced from index.html (fonts, uPlot). +// Remove 'unsafe-inline' from script-src once the theme-init snippet in +// index.html is moved to a file. +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("Referrer-Policy", "no-referrer") + h.Set("Content-Security-Policy", strings.Join([]string{ + "default-src 'self'", + "script-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'", + "style-src 'self' https://fonts.googleapis.com https://cdn.jsdelivr.net 'unsafe-inline'", + "font-src 'self' https://fonts.gstatic.com", + "img-src 'self' data:", + "connect-src 'self' ws: wss:", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + }, "; ")) + next.ServeHTTP(w, r) + }) } func main() { addr := envDefault("AGENTMON_UI_ADDR", ":8082") queryAPIBase := envDefault("AGENTMON_QUERY_BASE", "http://query-api") + queryURL, err := url.Parse(queryAPIBase) + if err != nil { + log.Fatalf("invalid AGENTMON_QUERY_BASE %q: %v", queryAPIBase, err) + } + if queryURL.Host == "" { + log.Fatalf("AGENTMON_QUERY_BASE %q has no host", queryAPIBase) + } + mux := http.NewServeMux() // Health check @@ -35,7 +81,6 @@ func main() { }) // API proxy to query-api - queryURL, _ := url.Parse(queryAPIBase) proxy := httputil.NewSingleHostReverseProxy(queryURL) mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { r.URL.Path = strings.TrimPrefix(r.URL.Path, "/api") @@ -45,20 +90,23 @@ func main() { // WebSocket proxy to query-api mux.HandleFunc("/api/v1/ws", func(w http.ResponseWriter, r *http.Request) { - queryWSURL := "ws://" + queryURL.Host + "/v1/ws" - conn, _, err := websocket.DefaultDialer.Dial(queryWSURL, nil) - if err != nil { - http.Error(w, "failed to connect to upstream WebSocket", http.StatusBadGateway) - return - } - defer conn.Close() - + // Upgrade first so CheckOrigin runs before we touch the upstream. + // On rejection, Upgrade has already written the response; just bail. uiConn, err := wsUpgrader.Upgrade(w, r, nil) if err != nil { return } defer uiConn.Close() + queryWSURL := "ws://" + queryURL.Host + "/v1/ws" + conn, _, err := websocket.DefaultDialer.Dial(queryWSURL, nil) + if err != nil { + _ = uiConn.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseTryAgainLater, "upstream unavailable")) + return + } + defer conn.Close() + var uiMu, upstreamMu sync.Mutex // upstream → UI @@ -133,7 +181,7 @@ func main() { }) log.Printf("web-ui listening on %s", addr) - log.Fatal(http.ListenAndServe(addr, mux)) + log.Fatal(http.ListenAndServe(addr, securityHeaders(mux))) } func envDefault(key, def string) string { diff --git a/cmd/web-ui/static/app.js b/cmd/web-ui/static/app.js index 839101b..b4b9a5e 100644 --- a/cmd/web-ui/static/app.js +++ b/cmd/web-ui/static/app.js @@ -1,4197 +1,147 @@ -(function() { +// ── app.js — entry point ────────────────────────────────── +// Thin bootstrap: imports modules, wires DOM events, fires route(). +// Navigation and copy-to-clipboard are wired via delegated click listeners +// below — no inline onclick= attributes, no window.* globals. - // ── Theme toggle ───────────────────────────────────────── - const THEME_CYCLE = ['system', 'light', 'dark']; - const THEME_ICONS = { - system: '', - light: '', - dark: '', - }; - const THEME_LABELS = { system: 'System theme', light: 'Light theme', dark: 'Dark theme' }; +import { navigate, route } from './modules/router.js'; +import { connectWS, subscribeWS, updateWSIndicator } from './modules/ws.js'; +import { applyTheme, getTheme, updateToggleBtn, cycleTheme } from './modules/theme.js'; +import { copyToClipboard } from './modules/api.js'; +import { getEnvelopeType } from './modules/utils.js'; +import { + incrementErrorBadge, + openCommandPalette, + closeCommandPalette, + isCommandPaletteOpen, + handleGlobalSearch, +} from './modules/palette.js'; - function getTheme() { return localStorage.getItem('theme') || 'system'; } +// ── Delegated click handlers ────────────────────────────── +// 1) Same-origin clicks → SPA navigation (no full page reload). +// 2) .copy-btn[data-copy] → copyToClipboard. +function isInternalLinkClick(e, a) { + if (e.defaultPrevented) return false; + if (e.button !== 0) return false; + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return false; + if (a.target && a.target !== '' && a.target !== '_self') return false; + if (a.hasAttribute('download')) return false; + const href = a.getAttribute('href'); + if (!href) return false; + if (href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return false; + let url; + try { + url = new URL(href, window.location.href); + } catch { + return false; + } + if (url.origin !== window.location.origin) return false; + return url; +} - function applyTheme(theme) { - if (theme === 'system') document.documentElement.removeAttribute('data-theme'); - else document.documentElement.setAttribute('data-theme', theme); +document.addEventListener('click', (e) => { + // Copy buttons first — stop the click from reaching a surrounding row link. + const copyBtn = e.target.closest('.copy-btn[data-copy]'); + if (copyBtn) { + e.preventDefault(); + e.stopPropagation(); + copyToClipboard(copyBtn.getAttribute('data-copy'), copyBtn); + return; } - function updateToggleBtn(theme) { - const btn = document.getElementById('theme-toggle'); - if (!btn) return; - btn.innerHTML = THEME_ICONS[theme]; - btn.title = THEME_LABELS[theme]; + const a = e.target.closest('a'); + if (!a) return; + const url = isInternalLinkClick(e, a); + if (!url) return; + e.preventDefault(); + navigate(url.pathname + url.search + url.hash); +}); + +// ── Bootstrap ───────────────────────────────────────────── +applyTheme(getTheme()); + +document.addEventListener('DOMContentLoaded', function() { + updateToggleBtn(getTheme()); + const btn = document.getElementById('theme-toggle'); + if (btn) btn.addEventListener('click', cycleTheme); + + // Global Search + const searchInput = document.getElementById('global-search'); + if (searchInput) { + searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + const val = searchInput.value.trim(); + if (val) handleGlobalSearch(val); + } + }); } - 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); + // Cmd+K hint button — show platform-appropriate label + const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgentData?.platform || ''); + const cmdKBtn = document.getElementById('cmd-k-hint'); + if (cmdKBtn) { + cmdKBtn.innerHTML = `${isMac ? '⌘K' : 'Ctrl+K'}`; + cmdKBtn.addEventListener('click', openCommandPalette); } - document.addEventListener('DOMContentLoaded', function() { - updateToggleBtn(getTheme()); - const btn = document.getElementById('theme-toggle'); - if (btn) btn.addEventListener('click', cycleTheme); - - // Global Search - const searchInput = document.getElementById('global-search'); - if (searchInput) { - searchInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - const val = searchInput.value.trim(); - if (val) handleGlobalSearch(val); - } - }); + // Global error tracking — persists across page navigations + subscribeWS(function globalErrorTracker(msg) { + if (msg.type !== 'message') return; + if (getEnvelopeType(msg.data) === 'error') { + incrementErrorBadge(); } - - // Cmd+K hint button - document.getElementById('cmd-k-hint')?.addEventListener('click', openCommandPalette); - - // Global error tracking — persists across page navigations - subscribeWS(function globalErrorTracker(msg) { - if (msg.type !== 'message') return; - if (getEnvelopeType(msg.data) === 'error') { - incrementErrorBadge(); - } - }); - - // Keyboard Shortcuts - let _pendingGoto = false; - document.addEventListener('keydown', (e) => { - // Ignore when typing in inputs - if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) { - if (e.key === 'Escape') document.activeElement.blur(); - return; - } - - // Cmd+K or Ctrl+K — command palette - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { - e.preventDefault(); - if (paletteOpen) closeCommandPalette(); - else openCommandPalette(); - return; - } - - // '/' to focus search - if (e.key === '/' && !paletteOpen) { - e.preventDefault(); - const searchInput = document.getElementById('global-search'); - if (searchInput) searchInput.focus(); - return; - } - - // Escape closes palette - if (e.key === 'Escape' && paletteOpen) { - closeCommandPalette(); - return; - } - - // 'g' prefix for goto shortcuts - if (e.key === 'g' && !_pendingGoto) { - _pendingGoto = true; - setTimeout(() => { _pendingGoto = false; }, 800); - return; - } - - if (_pendingGoto) { - _pendingGoto = false; - if (e.key === 'd') navigate('/'); - else if (e.key === 's') navigate('/sessions'); - else if (e.key === 'a') navigate('/agents'); - else if (e.key === 'i') navigate('/infrastructure'); - return; - } - }); }); - async function handleGlobalSearch(id) { - if (id.length < 8) { - showToast('Search ID must be at least 8 characters', 'info'); + // Keyboard shortcuts + let _pendingGoto = false; + document.addEventListener('keydown', (e) => { + // Ignore when typing in inputs + if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) { + if (e.key === 'Escape') document.activeElement.blur(); return; } - try { - // Try fetching as session first - const sessionData = await api('/v1/sessions/' + id).catch(() => null); - if (sessionData && sessionData.session) { - navigate('/sessions/' + id); - return; - } - - // Then try as run - const runData = await api('/v1/runs/' + id).catch(() => null); - if (runData && runData.run) { - navigate('/runs/' + id); - return; - } - - showToast('ID not found', 'error'); - } catch (e) { - showToast('Search failed: ' + e.message, 'error'); - } - } - - function copyToClipboard(text, el) { - if (!text) return; - navigator.clipboard.writeText(text).then(() => { - showToast('Copied to clipboard', 'success'); - if (el) { - const originalText = el.textContent; - el.textContent = 'Copied!'; - setTimeout(() => { el.textContent = originalText; }, 1500); - } - }).catch(err => { - console.error('Failed to copy:', err); - showToast('Copy failed', 'error'); - }); - } - - function renderCopyButton(text) { - return ``; - } - - // ── Command Palette ───────────────────────────────────── - let paletteOpen = false; - let paletteSelectedIndex = 0; - - function getCommandPaletteItems(query) { - const items = [ - { label: 'Dashboard', path: '/', icon: '◉', shortcut: 'g d' }, - { label: 'Sessions', path: '/sessions', icon: '▶', shortcut: 'g s' }, - { label: 'Agents', path: '/agents', icon: '◎', shortcut: 'g a' }, - { label: 'Infrastructure', path: '/infrastructure', icon: '⚡', shortcut: 'g i' }, - { label: 'Toggle Theme', action: 'theme', icon: '◐' }, - ]; - - // Add agent items dynamically - if (agentsState && agentsState.agents) { - for (const [key, agent] of Object.entries(agentsState.agents)) { - items.push({ - label: 'Agent: ' + (agent.name || key), - path: '/agents', - action: 'select-agent', - agentKey: key, - icon: isAgentOnline(agent) ? '●' : '○', - }); - } - } - - if (!query) return items; - const q = query.toLowerCase(); - return items.filter(item => item.label.toLowerCase().includes(q)); - } - - function openCommandPalette() { - if (paletteOpen) return; - paletteOpen = true; - paletteSelectedIndex = 0; - - const backdrop = document.createElement('div'); - backdrop.className = 'cmd-palette-backdrop'; - backdrop.id = 'cmd-palette-backdrop'; - backdrop.innerHTML = ` -
-
- - -
-
- -
- `; - - document.body.appendChild(backdrop); - const input = document.getElementById('cmd-palette-input'); - input.focus(); - renderPaletteItems(''); - - input.addEventListener('input', () => { - paletteSelectedIndex = 0; - renderPaletteItems(input.value); - }); - - input.addEventListener('keydown', (e) => { - const items = document.querySelectorAll('.cmd-palette-item'); - if (e.key === 'ArrowDown') { - e.preventDefault(); - paletteSelectedIndex = Math.min(paletteSelectedIndex + 1, items.length - 1); - updatePaletteSelection(); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - paletteSelectedIndex = Math.max(paletteSelectedIndex - 1, 0); - updatePaletteSelection(); - } else if (e.key === 'Enter') { - e.preventDefault(); - const selected = items[paletteSelectedIndex]; - if (selected) selected.click(); - } else if (e.key === 'Escape') { - closeCommandPalette(); - } - }); - - backdrop.addEventListener('click', (e) => { - if (e.target === backdrop) closeCommandPalette(); - }); - } - - function closeCommandPalette() { - paletteOpen = false; - const backdrop = document.getElementById('cmd-palette-backdrop'); - if (backdrop) backdrop.remove(); - } - - function renderPaletteItems(query) { - const container = document.getElementById('cmd-palette-results'); - if (!container) return; - const items = getCommandPaletteItems(query); - - // If query looks like an ID (8+ hex chars), add a search option - if (query.length >= 8) { - items.unshift({ label: 'Search for ID: ' + query, action: 'search', query, icon: '🔍' }); - } - - container.innerHTML = items.map((item, i) => ` -
-
${item.icon}
- ${escapeHTML(item.label)} - ${item.shortcut ? `${item.shortcut}` : ''} -
- `).join(''); - - container.querySelectorAll('.cmd-palette-item').forEach((el, i) => { - el.addEventListener('click', () => executePaletteItem(items[i])); - el.addEventListener('mouseenter', () => { - paletteSelectedIndex = i; - updatePaletteSelection(); - }); - }); - } - - function updatePaletteSelection() { - document.querySelectorAll('.cmd-palette-item').forEach((el, i) => { - el.classList.toggle('selected', i === paletteSelectedIndex); - if (i === paletteSelectedIndex) el.scrollIntoView({ block: 'nearest' }); - }); - } - - function executePaletteItem(item) { - closeCommandPalette(); - if (item.action === 'theme') { - cycleTheme(); - } else if (item.action === 'search') { - handleGlobalSearch(item.query); - } else if (item.action === 'select-agent') { - navigate('/agents'); - setTimeout(() => selectAgent(item.agentKey, 'live'), 100); - } else if (item.path) { - navigate(item.path); - } - } - - // ───────────────────────────────────────────────────────── - - // ── Error Badge ───────────────────────────────────────── - let _unseenErrors = 0; - - function incrementErrorBadge() { - if (window.location.pathname === '/') return; // On dashboard, don't badge - _unseenErrors++; - const badge = document.getElementById('nav-error-badge'); - if (badge) { - badge.textContent = _unseenErrors > 99 ? '99+' : String(_unseenErrors); - badge.classList.add('visible'); - } - } - - function clearErrorBadge() { - _unseenErrors = 0; - const badge = document.getElementById('nav-error-badge'); - if (badge) { - badge.classList.remove('visible'); - badge.textContent = ''; - } - } - - 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; - // ── Session Filter Pills ──────────────────────────────── - let sessionFilterMode = 'all'; - let openclawState = { instances: {} }; - let openclawUnsubscribe = null; - let infraUnsubscribe = null; - let _infraTimerInterval = 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 (_infraTimerInterval) { - clearInterval(_infraTimerInterval); - _infraTimerInterval = 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(); - renderBreadcrumbs(); - - app.classList.add('transitioning'); - requestAnimationFrame(() => { - setTimeout(() => { - const path = window.location.pathname; - if (path === '/') { - renderDashboard(); - } else if (path === '/sessions') { - renderSessions(); - } else if (path.startsWith('/agents')) { - renderAgents(); - } else if (path.startsWith('/infrastructure')) { - renderInfrastructure(); - } else if (path.startsWith('/sessions/')) { - renderSession(path.split('/sessions/')[1]); - } else if (path.startsWith('/runs/')) { - renderRun(path.split('/runs/')[1]); - } else { - app.innerHTML = '

Page not found

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

Go to Dashboard
'; - } - updateActiveNav(); - - // Fade back in - requestAnimationFrame(() => { - app.classList.remove('transitioning'); - }); - }, 80); - }); - } - - function renderBreadcrumbs() { - const el = document.getElementById('breadcrumbs'); - if (!el) return; - - const path = window.location.pathname; - const parts = path.split('/').filter(Boolean); - if (parts.length === 0) { - el.innerHTML = ''; - return; - } - - const items = [{ label: 'Dashboard', path: '/' }]; - let currentPath = ''; - - parts.forEach((part, i) => { - currentPath += '/' + part; - let label = part.charAt(0).toUpperCase() + part.slice(1); - - // Special labels for IDs - if (part.length > 20 || /^[a-f0-9-]{32,}$/.test(part)) { - label = part.substring(0, 8) + '…'; - } - - if (i === parts.length - 1) { - items.push({ label, current: true }); - } else { - items.push({ label, path: currentPath }); - } - }); - - el.innerHTML = items.map(item => { - if (item.current) { - return `${escapeHTML(item.label)}`; - } - return `${escapeHTML(item.label)}/`; - }).join(''); - } - - 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, '''); - } - - function animateCounter(elementId, newValue) { - const elem = document.getElementById(elementId); - if (!elem) return; - const oldText = elem.textContent; - const newText = String(newValue); - if (oldText === newText) return; - elem.textContent = newText; - elem.classList.remove('bumped'); - void elem.offsetWidth; // force reflow - elem.classList.add('bumped'); - } - - // ── Dashboard Sparklines ──────────────────────────────── - function buildSparklineSVG(values, color) { - if (!values || values.length < 2) return ''; - const max = Math.max(...values, 1); - const w = 100; - const h = 30; - const points = values.map((v, i) => { - const x = (i / (values.length - 1)) * w; - const y = h - (v / max) * h; - return `${x.toFixed(1)},${y.toFixed(1)}`; - }); - const polyline = points.join(' '); - // Area fill: close the path along the bottom - const areaPath = `M0,${h} L${points.map(p => p).join(' L')} L${w},${h} Z`; - return ` - - - `; - } - - function renderDashSparklines() { - const ts = dashboardState.timeseries; - if (!ts || !ts.series || ts.series.length < 2) return; - const cards = document.querySelectorAll('.summary-card'); - if (cards.length < 4) return; - - const runsData = ts.series.map(b => b.runs || 0); - const toolsData = ts.series.map(b => b.tools || 0); - const errorsData = ts.series.map(b => b.errors || 0); - const totalData = ts.series.map((b, i) => runsData[i] + toolsData[i] + errorsData[i]); - - // Remove existing sparklines - cards.forEach(c => { const s = c.querySelector('.summary-card-sparkline'); if (s) s.remove(); }); - - cards[0].insertAdjacentHTML('beforeend', buildSparklineSVG(totalData, 'var(--accent)')); - cards[1].insertAdjacentHTML('beforeend', buildSparklineSVG(runsData, 'var(--success)')); - cards[2].insertAdjacentHTML('beforeend', buildSparklineSVG(toolsData, 'var(--purple)')); - cards[3].insertAdjacentHTML('beforeend', buildSparklineSVG(errorsData, 'var(--error)')); - } - - function relativeTime(ts) { - if (!ts) { - return '-'; - } - - const now = Date.now(); - const then = new Date(ts).getTime(); - const diff = now - then; - - if (diff < 60000) return 'just now'; - if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago'; - if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago'; - return Math.floor(diff / 86400000) + 'd ago'; - } - - function formatDuration(ms) { - if (ms === undefined || ms === null || ms === '') return '-'; - if (ms < 1000) return ms + 'ms'; - if (ms < 60000) return (ms / 1000).toFixed(1) + 's'; - return (ms / 60000).toFixed(1) + 'm'; - } - - function formatBytes(bytes) { - if (!bytes) return null; - const units = ['B', 'KB', 'MB', 'GB', 'TB']; - let unitIndex = 0; - let value = bytes; - while (value >= 1024 && unitIndex < units.length - 1) { - value /= 1024; - unitIndex++; - } - return value.toFixed(1) + ' ' + units[unitIndex]; - } - - function statusIcon(status) { - if (status === 'success') return 'success'; - if (status === 'error') return 'error'; - return 'unknown'; - } - - function skeletonRows(rows, cols) { - return Array(rows).fill(0).map(() => - '' + Array(cols).fill('
').join('') + '' - ).join(''); - } - - // ── Content-Aware Skeletons ───────────────────────────── - function dashboardSkeleton() { - return ` -
${Array(4).fill('
').join('')}
-
- `; - } - - function sessionsSkeleton() { - // Returns rows suitable for insertion into a - return Array(8).fill(0).map((_, i) => { - const widths = [['55%','25%'], ['65%','20%'], ['45%','30%'], ['70%','15%'], ['50%','22%'], ['60%','18%'], ['42%','28%'], ['68%','12%']]; - const [w1, w2] = widths[i % widths.length]; - return ` -
-
-
-
-
- `; - }).join(''); - } - - function agentsSkeleton() { - return `
- ${Array(4).fill('
').join('')} -
`; - } - - function infrastructureSkeleton() { - return `
- ${Array(6).fill('
').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; - - // Update pill counts based on full unfiltered sessions list - const all = sessionsState.sessions; - const activeCount = all.filter(s => isSessionActive(s)).length; - const endedCount = all.filter(s => !isSessionActive(s)).length; - const erroredCount = all.filter(s => (s._errorCount || 0) > 0).length; - const pillDefs = [ - { filter: 'all', count: all.length }, - { filter: 'active', count: activeCount }, - { filter: 'ended', count: endedCount }, - { filter: 'errored', count: erroredCount }, - ]; - pillDefs.forEach(({ filter, count }) => { - const btn = document.querySelector(`#session-pills [data-filter="${filter}"]`); - if (!btn) return; - let countEl = btn.querySelector('.pill-count'); - if (!countEl) { - countEl = document.createElement('span'); - countEl.className = 'pill-count'; - btn.appendChild(countEl); - } - countEl.textContent = count; - }); - - // Apply filter - let filtered = sessionsState.sessions; - if (sessionFilterMode === 'active') { - filtered = filtered.filter(s => isSessionActive(s)); - } else if (sessionFilterMode === 'ended') { - filtered = filtered.filter(s => !isSessionActive(s)); - } else if (sessionFilterMode === 'errored') { - filtered = filtered.filter(s => (s._errorCount || 0) > 0); - } - - const groups = groupSessionsByDate(filtered); - if (groups.length === 0) { - tbody.innerHTML = 'No sessions found'; - return; - } - const allFiltered = groups.flatMap(g => g.items); - const maxDuration = Math.max(...allFiltered.map(s => { - const start = new Date(s.started_at).getTime(); - const end = s.ended_at ? new Date(s.ended_at).getTime() : Date.now(); - return end - start; - }), 1); - 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'); - const rowClass = active ? 'clickable active-session' : 'clickable'; - const start = new Date(s.started_at).getTime(); - const end = s.ended_at ? new Date(s.ended_at).getTime() : Date.now(); - const duration = end - start; - const barWidth = Math.max(4, (duration / maxDuration) * 80); - const durationBar = ``; - return ` - - ${escapeHTML(s.session_id.substring(0, 12))}…${renderCopyButton(s.session_id)} - ${escapeHTML(fw)} - ${escapeHTML(s.host || '-')} - ${s.run_count} - ${escapeHTML(relativeTime(s.started_at))}${durationBar} - `; - }).join(''); - return `${escapeHTML(group.label)}${rows}`; - }).join(''); - tbody.querySelectorAll('tr.clickable').forEach(row => { - row.addEventListener('click', () => navigate('/sessions/' + row.dataset.session)); - }); - } - - async function renderSessions() { - // Reset filter mode on each page visit - sessionFilterMode = 'all'; - - app.innerHTML = ` - -
- - - - -
-
- - - - -
-
- - - - - - - - - - - ${sessionsSkeleton()} -
SessionFrameworkHostRunsTime
-
- - `; - - // Wire up filter pill click handlers - document.querySelectorAll('#session-pills .filter-pill').forEach(btn => { - btn.addEventListener('click', () => { - document.querySelectorAll('#session-pills .filter-pill').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - sessionFilterMode = btn.dataset.filter; - refreshSessionsTable(); - }); - }); - - 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 ` - ${escapeHTML(s.session_id.substring(0, 12))}…${renderCopyButton(s.session_id)} - ${escapeHTML(fw)} - ${escapeHTML(s.host || '-')} - ${s.run_count} - ${escapeHTML(relativeTime(s.started_at))} - `; - } - - function updateSessionTimers() { - const tbody = document.getElementById('sessions-body'); - if (!tbody) return; - sessionsState.sessions.forEach(s => { - const row = tbody.querySelector(`[data-session="${s.session_id}"]`); - if (row) { - const td = row.cells[4]; - if (td) { - // Update only the text node, preserving the duration bar span - const bar = td.querySelector('.session-duration-bar'); - td.textContent = relativeTime(s.started_at); - if (bar) td.appendChild(bar); - } - } - }); - } - - 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(); - } - } - - if (eventType === 'error' && sessionId) { - const session = sessionsState.sessions.find(s => s.session_id === sessionId); - if (session) { - session._errorCount = (session._errorCount || 0) + 1; - } - } - - 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 = ` - ← Back to Sessions - -
Runs ${runs.length}
-
- - - - - - - - - - - - - - ${renderSessionRunsRows(runs)} - -
Run IDStatusModelToolsSpansDurationStarted
-
- `; - - bindSessionRunRows(); - - document.querySelector('.back-link').addEventListener('click', e => { + // Cmd+K or Ctrl+K — command palette + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { 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', 'span.start', 'span.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(`
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) { - 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)}

`; + if (isCommandPaletteOpen()) closeCommandPalette(); + else openCommandPalette(); 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 = ` - ← Back to Session - - ${!r.ended_at ? '
' : ''} -
- Spans ${spans.length} - ${!r.ended_at ? 'Live' : ''} -
-
- - - - - - - - - - - ${renderRunSpansRows(spans)} - -
NameKindStatusDuration
-
- `; - - bindRunSpanRows(); - - document.querySelector('.back-link').addEventListener('click', e => { + // '/' to focus search + if (e.key === '/' && !isCommandPaletteOpen()) { 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) { - if (!runs || runs.length === 0) { - return 'No runs'; - } - - return runs.map((r, i) => { - const runDuration = r.ended_at - ? formatDuration(new Date(r.ended_at) - new Date(r.started_at)) - : '-'; - const modelLabel = r.model ? escapeHTML(r.model.replace(/^claude-/, '')) : '-'; - const spans = r.spans || []; - const spansHTML = spans.length > 0 ? ` -
- ${spans.map(sp => { - const body = getSessionSpanSummary(sp); - return ` -
- ${escapeHTML(sp.name || sp.kind || 'span')} - ${escapeHTML(body)} -
- `; - }).join('')} -
- ` : '
No spans yet
'; - - return ` - - ${escapeHTML(r.run_id.substring(0, 12))}...${renderCopyButton(r.run_id)} - ${statusIcon(r.status)} - ${modelLabel} - ${r.tool_count || 0} - ${r.span_count} - ${escapeHTML(runDuration)} - ${escapeHTML(new Date(r.started_at).toLocaleTimeString())} - - - -
-
Spans ${spans.length}
- ${spansHTML} -
- - - `; - }).join(''); - } - - function getSessionSpanSummary(sp) { - const payload = sp.payload || {}; - const innerPayload = payload.payload || {}; - if (sp.kind === 'tool') { - const result = innerPayload.result_preview || ''; - const duration = sp.duration_ms !== undefined && sp.duration_ms !== null ? formatDuration(sp.duration_ms) : '-'; - return result ? `${duration} · ${String(result).slice(0, 80)}` : duration; - } - if (sp.kind === 'agent') { - const usage = innerPayload.usage || {}; - const totalTokens = usage.total_tokens !== undefined ? `${usage.total_tokens} tok` : ''; - const duration = sp.duration_ms !== undefined && sp.duration_ms !== null ? formatDuration(sp.duration_ms) : '-'; - return totalTokens ? `${duration} · ${totalTokens}` : duration; - } - return sp.duration_ms !== undefined && sp.duration_ms !== null ? formatDuration(sp.duration_ms) : '-'; - } - - function bindSessionRunRows() { - document.querySelectorAll('tr.expandable-run').forEach(row => { - row.addEventListener('click', event => { - if (event.metaKey || event.ctrlKey) { - navigate('/runs/' + row.dataset.run); - return; - } - - const idx = row.dataset.index; - const detailRow = document.querySelector(`tr.span-detail-row[data-index="${idx}"]`); - const icon = row.querySelector('.expand-icon'); - if (!detailRow) return; - - if (detailRow.style.display === 'none') { - detailRow.style.display = 'table-row'; - if (icon) icon.style.transform = 'rotate(45deg)'; - } else { - detailRow.style.display = 'none'; - if (icon) icon.style.transform = ''; - } - }); - - row.addEventListener('dblclick', () => navigate('/runs/' + row.dataset.run)); - 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 = `
${infrastructureSkeleton()}
`; - - infraUnsubscribe = subscribeWS(handleInfraWS); - - try { - const [ocData, swarmData] = await Promise.all([ - api('/v1/events?event_type=openclaw.snapshot&limit=100'), - api('/v1/events?event_type=swarm.snapshot&limit=10').catch(() => ({ events: [] })), - ]); - - mergeOpenClawEvents(ocData.events || []); - for (const evt of swarmData.events || []) mergeSwarmSnapshot(evt); - - if (isCurrentPath('/infrastructure')) { - renderInfraGrid(); - } - } catch (e) { - if (isCurrentPath('/infrastructure')) { - app.innerHTML = `

Error: ${escapeHTML(e.message)}

`; - } - } - } - - function handleInfraWS(msg) { - if (msg.type !== 'message') return; - - const eventType = getEnvelopeType(msg.data); - - if (eventType === 'openclaw.snapshot') { - mergeOpenClawEvents([msg.data]); - if (isCurrentPath('/infrastructure')) renderInfraGrid(); - if (isCurrentPath('/agents')) renderAgentVMStrip(); + const si = document.getElementById('global-search'); + if (si) si.focus(); return; } - if (eventType === 'swarm.snapshot') { - mergeSwarmSnapshot(msg.data); - if (isCurrentPath('/infrastructure')) renderInfraGrid(); + // Escape closes palette + if (e.key === 'Escape' && isCommandPaletteOpen()) { + closeCommandPalette(); 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 = ` - - -
-

VMs

- ${vmNames.length === 0 - ? '

No VM data

' - : `
${vmNames.map(name => renderVMCard(name)).join('')}
` - } -
- -
-

Swarm Services

- ${swarmServices.length === 0 - ? '

No swarm service data

' - : `
${swarmServices.map(svc => renderServiceCard(svc)).join('')}
` - } -
- -
-

K8s Homelab

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

No k8s homelab service data

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

Agentmon

- ${agentmonServices.length === 0 - ? '

No agentmon service data

' - : `
${agentmonServices.map(svc => renderServiceCard(svc)).join('')}
` - } -
- `; - - // Start freshness timer — update "Updated X ago" text every 10s - if (_infraTimerInterval) clearInterval(_infraTimerInterval); - _infraTimerInterval = setInterval(() => { - document.querySelectorAll('.freshness-timer[data-ts]').forEach(el => { - el.textContent = 'Updated ' + relativeTime(el.dataset.ts); - }); - }, 10000); - } - - function renderVMCard(name) { - const evt = openclawState.instances[name]; - const payload = getEnvelopePayload(evt); - const inst = payload.instance || {}; - const host = payload.host || {}; - const guest = payload.guest; - const issues = payload.issues; - - return ` -
-
-

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

-
- ${host.state === 'running' ? 'Running' : 'Stopped'} -
-
-
Updated ${escapeHTML(relativeTime(getEnvelopeTS(evt)))}
- - - - - - - -
Host${escapeHTML(inst.host || '-')}
Domain${escapeHTML(inst.domain || '-')}
vCPUs${host.vcpus || '-'}
Memory${escapeHTML(formatBytes(host.memory_kib ? host.memory_kib * 1024 : 0) || '-')}
Disk${escapeHTML(formatBytes(host.disk_actual_bytes) || '-')}
Autostart${host.autostart ? 'Yes' : 'No'}
- ${guest ? ` -
- - - - - - - - -
Gateway${guest.service_active ? 'Active' : 'Inactive'}
HTTP${guest.http_status || 'N/A'}
Version${escapeHTML(guest.version || '-')}
Guest Mem${guest.memory_percent !== undefined ? guest.memory_percent.toFixed(1) : '-'}%
Guest Disk${guest.disk_percent !== undefined ? guest.disk_percent.toFixed(1) : '-'}%
Load${guest.load_average !== undefined ? guest.load_average.toFixed(2) : '-'}
Uptime${escapeHTML(guest.service_uptime || '-')}
- ` : ''} - ${issues && Object.values(issues).some(Boolean) ? ` -
-
Issues
-
- ${Object.entries(issues).filter(([, value]) => value).map(([key]) => ` - ${escapeHTML(key.replace(/_/g, ' '))} - `).join('')} -
- ` : ''} -
- `; - } - - function renderServiceCard(svc) { - const role = svc.role || 'unknown'; - switch (role) { - case 'llm-proxy': return renderLLMProxyCard(svc); - case 'db': return renderDBCard(svc); - case 'search': return renderSearchCard(svc); - case 'mcp': return renderMCPCard(svc); - case 'voice': return renderVoiceCard(svc); - case 'automation':return renderAutomationCard(svc); - case 'api': - case 'web': return renderAPICard(svc); - case 'worker': - case 'queue': return renderWorkerCard(svc); - default: return renderGenericServiceCard(svc); - } - } - - function serviceCardHeader(svc) { - const uptimeBadge = getUptimeBadge(svc.uptime_sec); - return ` -
-
-
${escapeHTML(svc.name)}${uptimeBadge ? ' ' + uptimeBadge : ''}
-
${escapeHTML(svc.role || '')}
-
- ${escapeHTML(svc.status || 'down')} -
- `; - } - - function serviceStatRow(label, value, valueClass) { - return ` -
- ${escapeHTML(label)} - ${value} -
- `; - } - - // ── Infrastructure Uptime & Freshness ─────────────────── - function getUptimeBadge(uptimeSec) { - if (!uptimeSec) return ''; - const hours = uptimeSec / 3600; - const pct = Math.min(100, (hours / 24) * 100); - const cls = pct >= 99 ? 'good' : pct >= 90 ? 'warn' : 'bad'; - return `${pct.toFixed(0)}% / 24h`; - } - - function formatUptime(sec) { - if (!sec) return '-'; - if (sec < 60) return sec + 's'; - if (sec < 3600) return Math.floor(sec / 60) + 'm'; - if (sec < 86400) return Math.floor(sec / 3600) + 'h ' + Math.floor((sec % 3600) / 60) + 'm'; - return Math.floor(sec / 86400) + 'd ' + Math.floor((sec % 86400) / 3600) + 'h'; - } - - function renderLLMProxyCard(svc) { - const extra = svc.extra || {}; - const modelCount = extra.model_count; - const cooldowns = extra.cooldown_count || 0; - const httpStatus = svc.http_status; - const httpClass = httpStatus === 200 ? 'ok' : httpStatus ? 'bad' : ''; - - return ` -
- ${serviceCardHeader(svc)} -
- ${modelCount !== undefined ? modelCount : '-'} - models -
- ${cooldowns > 0 ? `
⚠ ${cooldowns} model${cooldowns > 1 ? 's' : ''} in cooldown
` : ''} -
- ${serviceStatRow('HTTP', httpStatus ? String(httpStatus) : '-', httpClass)} - ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')} - ${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')} -
-
- `; - } - - function renderDBCard(svc) { - const healthClass = svc.health_state === 'healthy' ? 'ok' : svc.health_state === 'unhealthy' ? 'bad' : ''; - return ` -
- ${serviceCardHeader(svc)} -
- ${serviceStatRow('Health', escapeHTML(svc.health_state || 'none'), healthClass)} - ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')} - ${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')} -
-
- `; - } - - function renderSearchCard(svc) { - const extra = svc.extra || {}; - const ms = extra.response_ms; - const httpStatus = svc.http_status; - const httpClass = httpStatus === 200 ? 'ok' : httpStatus ? 'bad' : ''; - return ` -
- ${serviceCardHeader(svc)} -
- ${serviceStatRow('HTTP', httpStatus ? String(httpStatus) : '-', httpClass)} - ${ms !== undefined ? serviceStatRow('Response', ms + 'ms', ms < 500 ? 'ok' : 'warn') : ''} - ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')} -
-
- `; - } - - function renderMCPCard(svc) { - const extra = svc.extra || {}; - const reachable = extra.port_reachable; - return ` -
- ${serviceCardHeader(svc)} -
- ${reachable !== undefined ? serviceStatRow('Port', reachable ? 'reachable' : 'unreachable', reachable ? 'ok' : 'bad') : ''} - ${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')} - ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')} -
-
- `; - } - - function renderVoiceCard(svc) { - const healthClass = svc.health_state === 'healthy' ? 'ok' : svc.health_state === 'unhealthy' ? 'bad' : ''; - return ` -
- ${serviceCardHeader(svc)} -
- ${serviceStatRow('Health', escapeHTML(svc.health_state || 'none'), healthClass)} - ${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')} - ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')} -
-
- `; - } - - function renderAutomationCard(svc) { - const healthClass = svc.health_state === 'healthy' ? 'ok' : svc.health_state === 'unhealthy' ? 'bad' : ''; - return ` -
- ${serviceCardHeader(svc)} -
- ${serviceStatRow('Health', escapeHTML(svc.health_state || 'none'), healthClass)} - ${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')} - ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')} -
-
- `; - } - - function renderAPICard(svc) { - const httpStatus = svc.http_status; - const httpClass = httpStatus === 200 ? 'ok' : httpStatus ? 'bad' : ''; - return ` -
- ${serviceCardHeader(svc)} -
- ${serviceStatRow('HTTP', httpStatus ? String(httpStatus) : '-', httpClass)} - ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')} - ${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')} -
-
- `; - } - - function renderWorkerCard(svc) { - return ` -
- ${serviceCardHeader(svc)} -
- ${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')} - ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')} -
-
- `; - } - - function renderGenericServiceCard(svc) { - return ` -
- ${serviceCardHeader(svc)} -
- ${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')} - ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')} -
-
- `; - } - - function 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: {}, - 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 = ` - -
-
- - -
-
-
-
${agentsSkeleton()}
- `; - - bindAgentViewToggle(); - - try { - const [snapshots, events, summaryData] = await Promise.all([ - api('/v1/events?event_type=openclaw.snapshot&limit=100').catch(() => ({ events: [] })), - api('/v1/events?limit=300'), - api('/v1/stats/summary').catch(() => null), - ]); - - if (!isCurrentPath('/agents')) return; - - if (summaryData) { - agentsState.dbStats.messages = summaryData.runs_today || 0; - agentsState.dbStats.tools = summaryData.tool_calls_today || 0; - agentsState.dbStats.errors = summaryData.errors_today || 0; - } - - mergeOpenClawEvents(snapshots.events || []); - addAgentEvents((events.events || []).filter(isAgentTimelineEvent).slice().reverse()); - renderAgentsContent(); - } catch (e) { - document.getElementById('agents-content').innerHTML = - `

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

`; - } - - agentsState.timerInterval = setInterval(updateAgentTimers, 1000); - agentsUnsubscribe = subscribeWS(handleAgentsWS); - } - - function bindAgentViewToggle() { - const root = document.getElementById('agents-view-toggle'); - if (!root) return; - root.querySelectorAll('[data-mode]').forEach(button => { - button.addEventListener('click', () => { - setAgentsViewMode(button.dataset.mode || 'overview'); - }); - }); - } - - function updateAgentViewToggle() { - const root = document.getElementById('agents-view-toggle'); - if (!root) return; - root.querySelectorAll('[data-mode]').forEach(button => { - button.classList.toggle('active', button.dataset.mode === agentsState.viewMode); - }); - } - - function renderAgentsContent() { - renderAgentSummary(); - updateAgentViewToggle(); - if (agentsState.viewMode === 'live') { - renderAgentsLive(); - return; - } - renderAgentLanes(); - } - - async function loadSelectedAgentLiveData() { - const selectedKey = ensureSelectedAgentKey(); - if (!selectedKey) return; - - const agent = agentsState.agents[selectedKey]; - if (!agent || agent.liveLoaded || agent.liveLoading || !agent.clientID || !agent.framework) { + // 'g' prefix for goto shortcuts + if (e.key === 'g' && !_pendingGoto) { + _pendingGoto = true; + setTimeout(() => { _pendingGoto = false; }, 800); 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(); - } - } - } - - // ── Agent Lane Sparklines ─────────────────────────────── - function buildAgentActivityBars(agent, bucketCount) { - const events = agent.events || []; - if (events.length === 0) return ''; - const count = bucketCount || 20; - const now = Date.now(); - const windowMS = 3600000; // 1 hour - const bucketMS = windowMS / count; - const buckets = new Array(count).fill(0); - - for (const evt of events) { - const ts = new Date(getEnvelopeTS(evt)).getTime(); - const age = now - ts; - if (age > windowMS || age < 0) continue; - const idx = Math.min(count - 1, Math.floor((windowMS - age) / bucketMS)); - buckets[idx]++; - } - - const max = Math.max(...buckets, 1); - return `
${buckets.map(b => { - const pct = (b / max * 100).toFixed(0); - return `
`; - }).join('')}
`; - } - - function renderAgentLanes() { - const contentEl = document.getElementById('agents-content'); - if (!contentEl) return; - contentEl.innerHTML = '
'; - - const lanesEl = document.getElementById('agents-lanes'); - if (!lanesEl) return; - - const agentKeys = getSortedAgentKeys(); - - if (agentKeys.length === 0) { - lanesEl.innerHTML = '

No recent agent activity

'; + if (_pendingGoto) { + _pendingGoto = false; + if (e.key === 'd') navigate('/'); + else if (e.key === 's') navigate('/sessions'); + else if (e.key === 'a') navigate('/agents'); + else if (e.key === 'i') navigate('/infrastructure'); + else if (e.key === 'p') navigate('/settings'); + else if (e.key === 'u') navigate('/usage'); return; } + }); - lanesEl.innerHTML = agentKeys.map(key => { - const agent = agentsState.agents[key]; - const isOnline = isAgentOnline(agent); - const sessionCount = Object.keys(agent.sessions).length; - const ops = getAgentDisplayOps(agent); - const subagentCount = ops.filter(op => op.kind === 'agent' || op.subType === 'subagent').length; - - const statusClass = sessionCount > 0 ? ' has-sessions' : ''; - const statusText = !isOnline ? 'offline' - : subagentCount > 0 ? subagentCount + ' subagent' + (subagentCount > 1 ? 's' : '') - : sessionCount > 0 ? sessionCount + ' session' + (sessionCount > 1 ? 's' : '') - : 'idle'; - - const opsHTML = ops.length > 0 ? `
${ops.map(op => { - const elapsed = Math.floor((Date.now() - op.startedAt) / 1000); - const stale = elapsed > 300; - const kindClass = op.kind === 'agent' || op.subType === 'subagent' ? ' subagent' : ''; - return ` -
- - ${escapeHTML(op.name)} - ${formatElapsed(elapsed)} - ${stale ? '(stale?)' : ''} -
`; - }).join('')}
` : ''; - - const recent = agent.events.slice(-40).reverse(); - const eventsHTML = recent.length > 0 ? recent.map(evt => { - const eventType = getEnvelopeType(evt); - const details = getEventDetails(evt); - const detailHTML = details ? `
${escapeHTML(details)}
` : ''; - const expandHTML = details ? '' : ''; - - return ` -
-
- ${getEventIcon(eventType)} - ${escapeHTML(getEventLabel(eventType))} - ${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())} -
- ${getEventBody(evt)} - ${expandHTML} - ${detailHTML} -
`; - }).join('') : '

No recent activity

'; - - return ` -
-
-
-
- - ${escapeHTML(agent.name || key)} -
-
${escapeHTML(agent.framework || 'unknown')}${agent.host && agent.host !== agent.name ? ' · ' + escapeHTML(agent.host) : ''}
- ${buildAgentActivityBars(agent)} -
- ${statusText} -
- ${opsHTML} -
${eventsHTML}
-
`; - }).join(''); - - lanesEl.querySelectorAll('.agent-lane[data-agent-key]').forEach(lane => { - lane.addEventListener('click', () => { - selectAgent(lane.dataset.agentKey || '', 'live'); - }); - }); - lanesEl.querySelectorAll('.timeline-expand-hint').forEach(button => { - button.addEventListener('click', event => { - event.stopPropagation(); - button.parentElement.classList.toggle('expanded'); - }); - }); - } - - function formatElapsed(seconds) { - if (seconds < 60) return seconds + 's'; - if (seconds < 3600) return Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's'; - return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm'; - } - - function renderAgentSummary() { - const el = document.getElementById('agents-summary'); - if (!el) return; - const s = agentsState.dbStats; - const liveAgents = getSortedAgentKeys().filter(key => isAgentOnline(agentsState.agents[key])).length; - const liveSubagents = getSortedAgentKeys().reduce((count, key) => { - const agent = agentsState.agents[key]; - return count + Object.values(agent.operations).filter(op => op.kind === 'agent' || op.subType === 'subagent').length; - }, 0); - el.innerHTML = ` -
Live Agents ${liveAgents}
-
Active Subagents ${liveSubagents}
-
Runs Today ${s.messages}
-
Tool Calls ${s.tools}
-
Errors ${s.errors}
- `; - } - - function getAgentLabel(agent) { - if (!agent) return 'Unknown'; - return agent.name || agent.host || agent.framework || agent.key || 'Unknown'; - } - - function getAgentLiveSummary(agent) { - const recent = agent.events.slice().reverse(); - const activeOps = getAgentDisplayOps(agent); - const sessionIDs = Object.keys(agent.sessions); - const live = { - sessionIDs, - activeOps, - activeSubagents: activeOps.filter(op => op.kind === 'agent' || op.subType === 'subagent'), - activeTools: activeOps.filter(op => op.kind === 'tool'), - latestPrompt: '', - latestRunStatus: '', - latestModel: '', - latestError: '', - latestUsage: null, - latestContextWindow: null, - }; - - for (const evt of recent) { - const eventType = getEnvelopeType(evt); - const payload = getEnvelopePayload(evt); - if (!live.latestPrompt && eventType === 'run.start') { - live.latestPrompt = payload.prompt_preview || payload.message_preview || payload.message || ''; - } - if (!live.latestRunStatus && eventType === 'run.end') { - live.latestRunStatus = payload.status || ''; - live.latestModel = payload.model || ''; - live.latestUsage = payload.usage || null; - live.latestContextWindow = payload.context_window || null; - } - if (!live.latestUsage && eventType === 'metric.snapshot' && payload.metrics) { - live.latestUsage = payload.metrics.usage || null; - live.latestModel = live.latestModel || payload.metrics.model || ''; - } - if (!live.latestError && eventType === 'error') { - const errPayload = payload.error || {}; - live.latestError = errPayload.message || payload.message || ''; - } - if (live.latestPrompt && live.latestRunStatus && live.latestError) { - break; - } - } - - return live; - } - - function formatCount(value) { - if (value === undefined || value === null || value === '') return '-'; - return String(value); - } - - function formatCost(value) { - if (value === undefined || value === null || value === '') return '-'; - const num = Number(value); - if (!Number.isFinite(num)) return String(value); - return '$' + num.toFixed(4); - } - - function 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(`
input${escapeHTML(typeof payload.input === 'string' ? payload.input : JSON.stringify(payload.input))}
`); - } - if (payload.result_preview) { - parts.push(`
result${escapeHTML(String(payload.result_preview))}
`); - } - } - if ((eventType === 'span.start' || eventType === 'span.end') && (attrs.span_kind === 'agent' || attrs.type === 'subagent')) { - if (payload.prompt_preview) { - parts.push(`
prompt${escapeHTML(String(payload.prompt_preview))}
`); - } - if (payload.usage && payload.usage.total_tokens !== undefined) { - parts.push(`
tokens${escapeHTML(formatCount(payload.usage.total_tokens))}
`); - } - if (payload.usage && payload.usage.total_cost !== undefined) { - parts.push(`
cost${escapeHTML(formatCost(payload.usage.total_cost))}
`); - } - } - if (eventType === 'run.start') { - const preview = payload.prompt_preview || payload.message_preview || payload.message || ''; - if (preview) { - parts.push(`
prompt${escapeHTML(String(preview))}
`); - } - } - if (eventType === 'run.end') { - if (payload.model) { - parts.push(`
model${escapeHTML(String(payload.model))}
`); - } - if (payload.usage && payload.usage.total_tokens !== undefined) { - parts.push(`
tokens${escapeHTML(formatCount(payload.usage.total_tokens))}
`); - } - if (payload.duration_ms !== undefined) { - parts.push(`
duration${escapeHTML(formatDuration(payload.duration_ms))}
`); - } - } - if (eventType === 'metric.snapshot' && payload.metrics) { - if (payload.metrics.model) { - parts.push(`
model${escapeHTML(String(payload.metrics.model))}
`); - } - if (payload.metrics.usage && payload.metrics.usage.total_tokens !== undefined) { - parts.push(`
tokens${escapeHTML(formatCount(payload.metrics.usage.total_tokens))}
`); - } - if (payload.metrics.usage && payload.metrics.usage.total_cost !== undefined) { - parts.push(`
cost${escapeHTML(formatCost(payload.metrics.usage.total_cost))}
`); - } - } - if (eventType === 'error') { - const errPayload = payload.error || {}; - if (errPayload.type) { - parts.push(`
type${escapeHTML(String(errPayload.type))}
`); - } - } - - const ids = []; - if (correlation.session_id) ids.push(`session ${correlation.session_id}`); - if (correlation.run_id) ids.push(`run ${correlation.run_id}`); - if (correlation.span_id) ids.push(`span ${correlation.span_id}`); - if (ids.length > 0) { - parts.push(`
ids${escapeHTML(ids.join(' · '))}
`); - } - - return parts.join(''); - } - - function getRunGroupLabel(runID, events) { - const runStart = events.find(evt => getEnvelopeType(evt) === 'run.start'); - if (!runStart) { - return runID ? `Run ${runID.slice(0, 12)}...` : 'Session activity'; - } - const payload = getEnvelopePayload(runStart); - const preview = payload.prompt_preview || payload.message_preview || payload.message || ''; - if (preview) { - return preview.length > 72 ? preview.slice(0, 72) + '...' : preview; - } - return runID ? `Run ${runID.slice(0, 12)}...` : 'Session activity'; - } - - function groupAgentEventsByRun(events) { - const groups = []; - const byRun = new Map(); - - for (const evt of events) { - const correlation = getEnvelopeCorrelation(evt); - const runID = correlation.run_id || ''; - const key = runID || `session:${correlation.session_id || 'unknown'}`; - if (!byRun.has(key)) { - const group = { - key, - runID, - sessionID: correlation.session_id || '', - events: [], - subagents: new Set(), - tools: new Set(), - }; - byRun.set(key, group); - groups.push(group); - } - - const group = byRun.get(key); - group.events.push(evt); - const attrs = getEnvelopeAttributes(evt); - if (attrs.span_kind === 'agent' || attrs.type === 'subagent') { - group.subagents.add(attrs.name || 'unknown'); - } - if (attrs.span_kind === 'tool' && attrs.name) { - group.tools.add(attrs.name); - } - } - - return groups; - } - - function 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; - - const agentKeys = getSortedAgentKeys(); - const selectedKey = ensureSelectedAgentKey(); - if (!selectedKey || agentKeys.length === 0) { - contentEl.innerHTML = '

No recent agent activity

'; - return; - } - - const selected = agentsState.agents[selectedKey]; - const summary = getAgentLiveSummary(selected); - const recent = selected.events.slice(-80).reverse(); - const runGroups = groupAgentEventsByRun(recent); - - contentEl.innerHTML = ` -
- -
-
-
-
${escapeHTML(getAgentLabel(selected))}
-
${escapeHTML(selected.framework || 'unknown')}${selected.host && selected.host !== selected.name ? ' · ' + escapeHTML(selected.host) : ''}
-
-
- ${summary.sessionIDs.length} sessions - ${summary.activeSubagents.length} subagents - ${summary.activeTools.length} tools -
-
-
-
-
- Live Operations - ${summary.activeOps.length > 0 ? `${summary.activeOps.length} active` : ''} -
-
- ${renderThinkingStream(selected)} -
-
-
-
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)}
` : ''} -
-
-
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 ? ` -
-
-
` : ''} -
-
-
- ${runGroups.length > 0 ? runGroups.map(group => ` -
-
-
${escapeHTML(getRunGroupLabel(group.runID, group.events))}
-
- ${escapeHTML(group.runID ? `run ${group.runID.slice(0, 12)}...` : 'session-only')} - ${escapeHTML(group.subagents.size > 0 ? `${group.subagents.size} subagents` : '0 subagents')} - ${escapeHTML(group.tools.size > 0 ? `${group.tools.size} tools` : '0 tools')} -
-
-
- ${group.events.map(evt => ` -
-
- ${getEventIcon(getEnvelopeType(evt))} - ${escapeHTML(getEventLabel(getEnvelopeType(evt)))} - ${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())} -
- ${getEventBody(evt)} -
${buildLiveEventContext(evt)}
-
`).join('')} -
-
- `).join('') : '

No recent activity

'} -
-
-
- `; - - 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', '(stale?)'); - } - } - }); - - } - - function addAgentEvents(events) { - let changed = false; - - for (const evt of events) { - const id = getRecordID(evt); - const agent = getAgentBucket(evt); - if (!id || !agent || agent.eventIDs.has(id)) continue; - processAgentEvent(evt); - changed = true; - } - - if (changed) { - for (const agent of Object.values(agentsState.agents)) { - agent.events.sort((a, b) => new Date(getEnvelopeTS(a)).getTime() - new Date(getEnvelopeTS(b)).getTime()); - } - recomputeAgentStats(); - } - } - - function recomputeAgentStats() { - const stats = { messages: 0, tools: 0, errors: 0, toolCounts: {} }; - - for (const agent of Object.values(agentsState.agents)) { - for (const evt of agent.events) { - const eventType = getEnvelopeType(evt); - const attrs = getEnvelopeAttributes(evt); - - if (eventType === 'run.start' || eventType === 'run.end') stats.messages++; - if (eventType === 'span.end' && attrs.span_kind === 'tool') { - stats.tools++; - const toolName = attrs.name || 'unknown'; - stats.toolCounts[toolName] = (stats.toolCounts[toolName] || 0) + 1; - } - if (eventType === 'error') stats.errors++; - } - } - - agentsState.stats = stats; - } - - function getEventIcon(eventType) { - switch (eventType) { - case 'run.start': - return '
'; - case 'run.end': - return '
'; - case 'span.start': - case 'span.end': - return '
'; - case 'error': - return '
!
'; - case 'session.start': - case 'session.end': - return '
'; - default: - return '
·
'; - } - } - - function getEventLabel(eventType) { - const labels = { - 'session.start': 'Session Started', - 'session.end': 'Session Ended', - 'run.start': 'Message Received', - 'run.end': 'Response Sent', - 'span.start': 'Span Started', - 'span.end': 'Span Completed', - 'error': 'Error', - 'metric.snapshot': 'Metric', - }; - return labels[eventType] || eventType; - } - - function getVMName(evt) { - return getAgentIdentity(evt).name || 'unknown'; - } - - function getVMClassName(vmName) { - const normalized = String(vmName || 'unknown').toLowerCase(); - return ['zap', 'orb', 'sun'].includes(normalized) ? normalized : 'unknown'; - } - - function getEventBody(evt) { - const eventType = getEnvelopeType(evt); - const payload = getEnvelopePayload(evt); - const attrs = getEnvelopeAttributes(evt); - const correlation = getEnvelopeCorrelation(evt); - - if (eventType === 'span.start' || eventType === 'span.end') { - const name = attrs.name || attrs.span_kind || 'unknown span'; - const duration = payload.duration_ms !== undefined && payload.duration_ms !== null - ? ` ${escapeHTML(formatDuration(payload.duration_ms))}` - : ''; - const detailClass = attrs.span_kind === 'agent' || attrs.type === 'subagent' ? ' subagent-name' : ' tool-name'; - const prefix = attrs.span_kind === 'agent' || attrs.type === 'subagent' ? 'subagent ' : ''; - return `
${escapeHTML(prefix + name)}${duration}
`; - } - - if (eventType === 'run.start') { - const preview = payload.prompt_preview || payload.message_preview || payload.message || ''; - if (!preview) { - return ''; - } - const trimmed = preview.length > 140 ? preview.slice(0, 140) + '...' : preview; - return `
"${escapeHTML(trimmed)}"
`; - } - - if (eventType === 'run.end') { - return `
${statusIcon(payload.status || 'unknown')}
`; - } - - if (eventType === 'error') { - const errPayload = payload.error || {}; - const errType = errPayload.type || 'error'; - const message = errPayload.message || payload.message || 'unknown'; - return `
${escapeHTML(errType + ': ' + message)}
`; - } - - if (eventType === 'session.start' || eventType === 'session.end') { - return correlation.session_id - ? `
session ${escapeHTML(correlation.session_id)}
` - : ''; - } - - return ''; - } - - function getEventDetails(evt) { - const details = {}; - const correlation = getEnvelopeCorrelation(evt); - const attributes = getEnvelopeAttributes(evt); - const payload = getEnvelopePayload(evt); - - if (Object.keys(correlation).length > 0) { - details.correlation = correlation; - } - if (Object.keys(attributes).length > 0) { - details.attributes = attributes; - } - if (Object.keys(payload).length > 0) { - details.payload = payload; - } - - if (Object.keys(details).length === 0) { - return ''; - } - - return JSON.stringify(details, null, 2); - } - - 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() { - clearErrorBadge(); - 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 = ` - -
-
-
Active Sessions
-
-
-
 
-
-
-
Runs Today
-
-
-
 
-
-
-
Tool Calls
-
-
-
 
-
-
-
Errors
-
-
-
 
-
-
-
-
- Tokens today - - -
-
- Cost today - - -
-
- Avg run duration - - -
-
- Error rate - - -
-
-
Infrastructure
-
-
-
-
-
- Event Rate - Runs, tool spans, and errors over time -
-
-
- total - runs - tools - errors -
-
- - -
-
- - - - -
-
-
-
-
-
-
-
-
-
- - - -
-
-
-

Loading...

-
-
-
-
-
-
- Recent Activity -
-
-

Loading...

-
-
-
-
- Top Usage -
-
-
Tools
-
    -
  • Loading...
  • -
-
-
-
Models
-
    -
  • Loading...
  • -
-
-
-
- `; - - 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(); renderDashSparklines(); 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(); - renderDashSparklines(); - 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 => ` -
- - ${escapeHTML(vm.name)} - ${vm.active ? 'online' : 'offline'} -
- `), - ` -
- - ${escapeHTML(infra.name)} - ${escapeHTML(infra.label)} -
- `, - ].join(''); - } - - function handleDashboardWS(msg) { - if (msg.type !== 'message') return; - - const eventType = getEnvelopeType(msg.data); - - if (eventType === 'openclaw.snapshot') { - mergeOpenClawEvents([msg.data]); - renderDashVMStrip(); - return; - } - if (eventType === 'swarm.snapshot') { - mergeSwarmSnapshot(msg.data); - 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; - - animateCounter('dash-active', s.active_sessions); - animateCounter('dash-runs', s.runs_today); - animateCounter('dash-tools', s.tool_calls_today); - animateCounter('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 - animateCounter('dash-tokens-today', formatTokenCount(s.tokens_today || 0)); - animateCounter('dash-cost-today', s.cost_today ? formatCost(s.cost_today) : '$0.0000'); - animateCounter('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; - animateCounter('dash-error-rate', 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(); renderDashSparklines(); 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(); - renderDashSparklines(); - 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 = ` -
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() { - const container = document.getElementById('dash-chart'); - if (!container || !dashboardState.timeseries) return; - - const data = buildChartData(); - renderDashboardChartInsights(); - renderDashboardChartHover(dashboardState.chartCursorIndex); - if (!data) { - container.innerHTML = '

No data for this window

'; - return; - } - - // If chart already exists, just update the data - if (dashboardChart) { - dashboardChart.setData(data); - return; - } - - container.innerHTML = ''; - - const width = container.clientWidth || 600; - const height = 200; - - const 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(); - renderDashSparklines(); - } - - 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-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 = '

No framework data

'; - return; - } - - const maxTotal = Math.max(...entries.map(([, s]) => s.runs + s.tools + s.errors)); - - container.innerHTML = '
' + entries.map(([name, stats]) => { - const total = stats.runs + stats.tools + stats.errors; - const pct = maxTotal > 0 ? (total / maxTotal * 100) : 0; - const cssClass = name.toLowerCase().replace(/[^a-z0-9-]/g, '-'); - return ` -
-
- ${escapeHTML(name)} - ${total} events -
-
-
-
-
- `; - }).join('') + '
'; - } - - function 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(-DASH_RECENT_EVENTS_LIMIT).reverse(); - if (recent.length === 0) { - feed.innerHTML = '

Waiting for events...

'; - 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 = '
  • No tool data yet
  • '; - return; - } - - const maxCount = topTools[0]?.[1] || 1; - list.innerHTML = topTools.map(([name, count]) => { - const pct = (count / maxCount * 100).toFixed(1); - return ` -
  • -
    - ${escapeHTML(name)} - ${count} -
    -
    -
    -
    -
  • - `; - }).join(''); - } - - 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(''); - } - + connectWS(); + updateWSIndicator(); route(); -})(); +}); diff --git a/cmd/web-ui/static/index.html b/cmd/web-ui/static/index.html index c144bb6..b7365a5 100644 --- a/cmd/web-ui/static/index.html +++ b/cmd/web-ui/static/index.html @@ -17,14 +17,12 @@ - +
    @@ -35,6 +33,6 @@

    Loading...

    - + diff --git a/cmd/web-ui/static/modules/api.js b/cmd/web-ui/static/modules/api.js new file mode 100644 index 0000000..1d31401 --- /dev/null +++ b/cmd/web-ui/static/modules/api.js @@ -0,0 +1,67 @@ +// ── api.js — fetch wrapper, toast, clipboard ───────────── + +import { getNavSignal } from './nav-signal.js'; + +export async function api(path, opts) { + opts = opts || {}; + const signal = opts.signal !== undefined ? opts.signal : getNavSignal(); + let resp; + try { + resp = await fetch('/api' + path, { signal }); + } catch (err) { + // Route changed or caller aborted — swallow silently, no toast. + if (err && err.name === 'AbortError') throw err; + showToast('Network error: ' + (err && err.message ? err.message : 'fetch failed'), 'error'); + throw err; + } + 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(); +} + +export function showToast(message, type) { + // Limit to 3 stacked toasts + const existing = document.querySelectorAll('.toast'); + if (existing.length >= 3) existing[0].remove(); + + const toast = document.createElement('div'); + toast.className = 'toast toast-' + (type || 'info'); + toast.textContent = message; + document.body.appendChild(toast); + + // Stack: offset each toast by its position + const stackToasts = () => { + document.querySelectorAll('.toast').forEach((t, i) => { + t.style.bottom = (2 + i * 3.5) + 'rem'; + }); + }; + stackToasts(); + + requestAnimationFrame(() => toast.classList.add('visible')); + setTimeout(() => { + toast.classList.remove('visible'); + setTimeout(() => { + toast.remove(); + stackToasts(); + }, 300); + }, 4000); +} + +export function copyToClipboard(text, el) { + if (!text) return; + navigator.clipboard.writeText(text).then(() => { + showToast('Copied to clipboard', 'success'); + if (el) { + const originalText = el.textContent; + el.textContent = 'Copied!'; + setTimeout(() => { el.textContent = originalText; }, 1500); + } + }).catch(err => { + console.error('Failed to copy:', err); + showToast('Copy failed', 'error'); + }); +} diff --git a/cmd/web-ui/static/modules/nav-signal.js b/cmd/web-ui/static/modules/nav-signal.js new file mode 100644 index 0000000..19a15cc --- /dev/null +++ b/cmd/web-ui/static/modules/nav-signal.js @@ -0,0 +1,14 @@ +// ── nav-signal.js — per-route AbortController ──────────── +// Lives in its own module so api.js and router.js can both import +// without creating a circular dependency via the page modules. + +let current = null; + +export function resetNavController() { + if (current) current.abort(); + current = new AbortController(); +} + +export function getNavSignal() { + return current ? current.signal : undefined; +} diff --git a/cmd/web-ui/static/modules/pages/agents.js b/cmd/web-ui/static/modules/pages/agents.js new file mode 100644 index 0000000..8635b8c --- /dev/null +++ b/cmd/web-ui/static/modules/pages/agents.js @@ -0,0 +1,939 @@ +// ── agents.js — Agents page ─────────────────────────────── + +import { + escapeHTML, + formatDuration, + formatCount, + formatCost, + formatTokenCount, + formatElapsed, + getEnvelopeType, + getEnvelopePayload, + getEnvelopeAttributes, + getEnvelopeCorrelation, + getEnvelopeTS, + getRecordID, + getEventIcon, + getEventLabel, + getEventBody, + getEventDetails, + isAgentTimelineEvent, + isCurrentPath, + getAgentIdentity, + normalizeAgentKey, + agentsSkeleton, +} from '../utils.js'; + +import { subscribeWS } from '../ws.js'; + +import { + agentsState, + resetAgentsState, + mergeOpenClawEvents, + getVMStatus, + isOpenClawVM, + isAgentOnline, + openclawState, +} from '../state.js'; + +import { app, navigate, renderBreadcrumbs, isRouteCurrent } from '../router.js'; +import { api } from '../api.js'; + +// ── Module-level state ─────────────────────────────────── + +let agentsUnsubscribe = null; +let _agentsRenderTimer = null; + +// ── Private helpers ────────────────────────────────────── + +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 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; + 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 buildAgentActivityBars(agent, bucketCount) { + const events = agent.events || []; + if (events.length === 0) return ''; + const count = bucketCount || 20; + const now = Date.now(); + const windowMS = 3600000; // 1 hour + const bucketMS = windowMS / count; + const buckets = new Array(count).fill(0); + + for (const evt of events) { + const ts = new Date(getEnvelopeTS(evt)).getTime(); + const age = now - ts; + if (age > windowMS || age < 0) continue; + const idx = Math.min(count - 1, Math.floor((windowMS - age) / bucketMS)); + buckets[idx]++; + } + + const max = Math.max(...buckets, 1); + return `
    ${buckets.map(b => { + const pct = (b / max * 100).toFixed(0); + return `
    `; + }).join('')}
    `; +} + +function renderAgentLanes() { + const contentEl = document.getElementById('agents-content'); + if (!contentEl) return; + contentEl.innerHTML = '
    '; + + const lanesEl = document.getElementById('agents-lanes'); + if (!lanesEl) return; + + const agentKeys = getSortedAgentKeys(); + + if (agentKeys.length === 0) { + lanesEl.innerHTML = '

    No recent agent activity

    '; + return; + } + + lanesEl.innerHTML = agentKeys.map(key => { + const agent = agentsState.agents[key]; + const isOnline = isAgentOnline(agent); + const sessionCount = Object.keys(agent.sessions).length; + const ops = getAgentDisplayOps(agent); + const subagentCount = ops.filter(op => op.kind === 'agent' || op.subType === 'subagent').length; + + const statusClass = sessionCount > 0 ? ' has-sessions' : ''; + const statusText = !isOnline ? 'offline' + : subagentCount > 0 ? subagentCount + ' subagent' + (subagentCount > 1 ? 's' : '') + : sessionCount > 0 ? sessionCount + ' session' + (sessionCount > 1 ? 's' : '') + : 'idle'; + + const opsHTML = ops.length > 0 ? `
    ${ops.map(op => { + const elapsed = Math.floor((Date.now() - op.startedAt) / 1000); + const stale = elapsed > 300; + const kindClass = op.kind === 'agent' || op.subType === 'subagent' ? ' subagent' : ''; + return ` +
    + + ${escapeHTML(op.name)} + ${formatElapsed(elapsed)} + ${stale ? '(stale?)' : ''} +
    `; + }).join('')}
    ` : ''; + + const recent = agent.events.slice(-40).reverse(); + const eventsHTML = recent.length > 0 ? recent.map(evt => { + const eventType = getEnvelopeType(evt); + const details = getEventDetails(evt); + const detailHTML = details ? `
    ${escapeHTML(details)}
    ` : ''; + const expandHTML = details ? '' : ''; + + return ` +
    +
    + ${getEventIcon(eventType)} + ${escapeHTML(getEventLabel(eventType))} + ${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())} +
    + ${getEventBody(evt)} + ${expandHTML} + ${detailHTML} +
    `; + }).join('') : '

    No recent activity

    '; + + return ` +
    +
    +
    +
    + + ${escapeHTML(agent.name || key)} +
    +
    ${escapeHTML(agent.framework || 'unknown')}${agent.host && agent.host !== agent.name ? ' · ' + escapeHTML(agent.host) : ''}
    + ${buildAgentActivityBars(agent)} +
    + ${statusText} +
    + ${opsHTML} +
    ${eventsHTML}
    +
    `; + }).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 renderAgentSummary() { + const el = document.getElementById('agents-summary'); + if (!el) return; + const s = agentsState.dbStats; + const liveAgents = getSortedAgentKeys().filter(key => isAgentOnline(agentsState.agents[key])).length; + const liveSubagents = getSortedAgentKeys().reduce((count, key) => { + const agent = agentsState.agents[key]; + return count + Object.values(agent.operations).filter(op => op.kind === 'agent' || op.subType === 'subagent').length; + }, 0); + el.innerHTML = ` +
    Live Agents ${liveAgents}
    +
    Active Subagents ${liveSubagents}
    +
    Runs Today ${s.messages}
    +
    Tool Calls ${s.tools}
    +
    Errors ${s.errors}
    + `; +} + +function getAgentLabel(agent) { + if (!agent) return 'Unknown'; + return agent.name || agent.host || agent.framework || agent.key || 'Unknown'; +} + +function getAgentLiveSummary(agent) { + const recent = agent.events.slice().reverse(); + const activeOps = getAgentDisplayOps(agent); + const sessionIDs = Object.keys(agent.sessions); + const live = { + sessionIDs, + activeOps, + activeSubagents: activeOps.filter(op => op.kind === 'agent' || op.subType === 'subagent'), + activeTools: activeOps.filter(op => op.kind === 'tool'), + latestPrompt: '', + latestRunStatus: '', + latestModel: '', + latestError: '', + latestUsage: null, + latestContextWindow: null, + }; + + for (const evt of recent) { + const eventType = getEnvelopeType(evt); + const payload = getEnvelopePayload(evt); + if (!live.latestPrompt && eventType === 'run.start') { + live.latestPrompt = payload.prompt_preview || payload.message_preview || payload.message || ''; + } + if (!live.latestRunStatus && eventType === 'run.end') { + live.latestRunStatus = payload.status || ''; + live.latestModel = payload.model || ''; + live.latestUsage = payload.usage || null; + live.latestContextWindow = payload.context_window || null; + } + if (!live.latestUsage && eventType === 'metric.snapshot' && payload.metrics) { + live.latestUsage = payload.metrics.usage || null; + live.latestModel = live.latestModel || payload.metrics.model || ''; + } + if (!live.latestError && eventType === 'error') { + const errPayload = payload.error || {}; + live.latestError = errPayload.message || payload.message || ''; + } + if (live.latestPrompt && live.latestRunStatus && live.latestError) break; + } + + return live; +} + +function buildLiveEventContext(evt) { + const eventType = getEnvelopeType(evt); + const payload = getEnvelopePayload(evt); + const attrs = getEnvelopeAttributes(evt); + const correlation = getEnvelopeCorrelation(evt); + const parts = []; + + if ((eventType === 'span.start' || eventType === 'span.end') && attrs.span_kind === 'tool') { + if (payload.input) { + parts.push(`
    input${escapeHTML(typeof payload.input === 'string' ? payload.input : JSON.stringify(payload.input))}
    `); + } + if (payload.result_preview) { + parts.push(`
    result${escapeHTML(String(payload.result_preview))}
    `); + } + } + if ((eventType === 'span.start' || eventType === 'span.end') && (attrs.span_kind === 'agent' || attrs.type === 'subagent')) { + if (payload.prompt_preview) { + parts.push(`
    prompt${escapeHTML(String(payload.prompt_preview))}
    `); + } + if (payload.usage && payload.usage.total_tokens !== undefined) { + parts.push(`
    tokens${escapeHTML(formatCount(payload.usage.total_tokens))}
    `); + } + if (payload.usage && payload.usage.total_cost !== undefined) { + parts.push(`
    cost${escapeHTML(formatCost(payload.usage.total_cost))}
    `); + } + } + if (eventType === 'run.start') { + const preview = payload.prompt_preview || payload.message_preview || payload.message || ''; + if (preview) { + parts.push(`
    prompt${escapeHTML(String(preview))}
    `); + } + } + if (eventType === 'run.end') { + if (payload.model) { + parts.push(`
    model${escapeHTML(String(payload.model))}
    `); + } + if (payload.usage && payload.usage.total_tokens !== undefined) { + parts.push(`
    tokens${escapeHTML(formatCount(payload.usage.total_tokens))}
    `); + } + if (payload.duration_ms !== undefined) { + parts.push(`
    duration${escapeHTML(formatDuration(payload.duration_ms))}
    `); + } + } + if (eventType === 'metric.snapshot' && payload.metrics) { + if (payload.metrics.model) { + parts.push(`
    model${escapeHTML(String(payload.metrics.model))}
    `); + } + if (payload.metrics.usage && payload.metrics.usage.total_tokens !== undefined) { + parts.push(`
    tokens${escapeHTML(formatCount(payload.metrics.usage.total_tokens))}
    `); + } + if (payload.metrics.usage && payload.metrics.usage.total_cost !== undefined) { + parts.push(`
    cost${escapeHTML(formatCost(payload.metrics.usage.total_cost))}
    `); + } + } + if (eventType === 'error') { + const errPayload = payload.error || {}; + if (errPayload.type) { + parts.push(`
    type${escapeHTML(String(errPayload.type))}
    `); + } + } + + const ids = []; + if (correlation.session_id) ids.push(`session ${correlation.session_id}`); + if (correlation.run_id) ids.push(`run ${correlation.run_id}`); + if (correlation.span_id) ids.push(`span ${correlation.span_id}`); + if (ids.length > 0) { + parts.push(`
    ids${escapeHTML(ids.join(' · '))}
    `); + } + + return parts.join(''); +} + +function getRunGroupLabel(runID, events) { + const runStart = events.find(evt => getEnvelopeType(evt) === 'run.start'); + if (!runStart) { + return runID ? `Run ${runID.slice(0, 12)}...` : 'Session activity'; + } + const payload = getEnvelopePayload(runStart); + const preview = payload.prompt_preview || payload.message_preview || payload.message || ''; + if (preview) { + return preview.length > 72 ? preview.slice(0, 72) + '...' : preview; + } + return runID ? `Run ${runID.slice(0, 12)}...` : 'Session activity'; +} + +function groupAgentEventsByRun(events) { + const groups = []; + const byRun = new Map(); + + for (const evt of events) { + const correlation = getEnvelopeCorrelation(evt); + const runID = correlation.run_id || ''; + const key = runID || `session:${correlation.session_id || 'unknown'}`; + if (!byRun.has(key)) { + const group = { + key, + runID, + sessionID: correlation.session_id || '', + events: [], + subagents: new Set(), + tools: new Set(), + }; + byRun.set(key, group); + groups.push(group); + } + + const group = byRun.get(key); + group.events.push(evt); + const attrs = getEnvelopeAttributes(evt); + if (attrs.span_kind === 'agent' || attrs.type === 'subagent') { + group.subagents.add(attrs.name || 'unknown'); + } + if (attrs.span_kind === 'tool' && attrs.name) { + group.tools.add(attrs.name); + } + } + + return groups; +} + +function 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 => { + 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; + + const agentKeys = getSortedAgentKeys(); + const selectedKey = ensureSelectedAgentKey(); + if (!selectedKey || agentKeys.length === 0) { + contentEl.innerHTML = '

    No recent agent activity

    '; + return; + } + + const selected = agentsState.agents[selectedKey]; + const summary = getAgentLiveSummary(selected); + const recent = selected.events.slice(-80).reverse(); + const runGroups = groupAgentEventsByRun(recent); + + contentEl.innerHTML = ` +
    + +
    +
    +
    +
    ${escapeHTML(getAgentLabel(selected))}
    +
    ${escapeHTML(selected.framework || 'unknown')}${selected.host && selected.host !== selected.name ? ' · ' + escapeHTML(selected.host) : ''}
    +
    +
    + ${summary.sessionIDs.length} sessions + ${summary.activeSubagents.length} subagents + ${summary.activeTools.length} tools +
    +
    +
    +
    +
    + Live Operations + ${summary.activeOps.length > 0 ? `${summary.activeOps.length} active` : ''} +
    +
    + ${renderThinkingStream(selected)} +
    +
    +
    +
    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)}
    ` : ''} +
    +
    +
    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 ? ` +
    +
    +
    ` : ''} +
    +
    +
    + ${runGroups.length > 0 ? runGroups.map(group => ` +
    +
    +
    ${escapeHTML(getRunGroupLabel(group.runID, group.events))}
    +
    + ${escapeHTML(group.runID ? `run ${group.runID.slice(0, 12)}...` : 'session-only')} + ${escapeHTML(group.subagents.size > 0 ? `${group.subagents.size} subagents` : '0 subagents')} + ${escapeHTML(group.tools.size > 0 ? `${group.tools.size} tools` : '0 tools')} +
    +
    +
    + ${group.events.map(evt => ` +
    +
    + ${getEventIcon(getEnvelopeType(evt))} + ${escapeHTML(getEventLabel(getEnvelopeType(evt)))} + ${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())} +
    + ${getEventBody(evt)} +
    ${buildLiveEventContext(evt)}
    +
    `).join('')} +
    +
    + `).join('') : '

    No recent activity

    '} +
    +
    +
    + `; + + contentEl.querySelectorAll('[data-agent-key]').forEach(button => { + button.addEventListener('click', () => selectAgent(button.dataset.agentKey || '', 'live')); + }); + + 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 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', '(stale?)'); + } + } + }); +} + +function addAgentEvents(events) { + let changed = false; + + for (const evt of events) { + const id = getRecordID(evt); + const agent = getAgentBucket(evt); + if (!id || !agent || agent.eventIDs.has(id)) continue; + processAgentEvent(evt); + changed = true; + } + + if (changed) { + for (const agent of Object.values(agentsState.agents)) { + agent.events.sort((a, b) => new Date(getEnvelopeTS(a)).getTime() - new Date(getEnvelopeTS(b)).getTime()); + } + recomputeAgentStats(); + } +} + +function recomputeAgentStats() { + const stats = { messages: 0, tools: 0, errors: 0, toolCounts: {} }; + + for (const agent of Object.values(agentsState.agents)) { + for (const evt of agent.events) { + const eventType = getEnvelopeType(evt); + const attrs = getEnvelopeAttributes(evt); + + if (eventType === 'run.start' || eventType === 'run.end') stats.messages++; + if (eventType === 'span.end' && attrs.span_kind === 'tool') { + stats.tools++; + const toolName = attrs.name || 'unknown'; + stats.toolCounts[toolName] = (stats.toolCounts[toolName] || 0) + 1; + } + if (eventType === 'error') stats.errors++; + } + } + + agentsState.stats = stats; +} + +function 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(); + } + } +} + +// ── Exports ────────────────────────────────────────────── + +export async function renderAgents(initialKey, routeToken) { + resetAgentsState(); + + app.innerHTML = ` + +
    +
    + + +
    +
    +
    +
    ${agentsSkeleton()}
    + `; + + 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 ((routeToken && !isRouteCurrent(routeToken)) || !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()); + + if (initialKey && agentsState.agents[initialKey]) { + agentsState.selectedAgentKey = initialKey; + renderBreadcrumbs(); + } + + renderAgentsContent(); + } catch (e) { + if (routeToken && !isRouteCurrent(routeToken)) return; + document.getElementById('agents-content').innerHTML = + `

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

    `; + } + + if (!routeToken || isRouteCurrent(routeToken)) { + agentsState.timerInterval = setInterval(updateAgentTimers, 1000); + agentsUnsubscribe = subscribeWS(handleAgentsWS); + } +} + +export function selectAgent(key, nextMode) { + if (!key || !agentsState.agents[key]) return; + agentsState.selectedAgentKey = key; + if (nextMode) { + agentsState.viewMode = nextMode; + } + const newPath = '/agents/' + encodeURIComponent(key); + if (window.location.pathname !== newPath) { + history.pushState(null, '', newPath); + renderBreadcrumbs(); + } + renderAgentsContent(); + if (agentsState.viewMode === 'live') { + void loadSelectedAgentLiveData(); + } +} + +export function cleanup() { + if (agentsState.timerInterval) { + clearInterval(agentsState.timerInterval); + agentsState.timerInterval = null; + } + if (agentsUnsubscribe) { + agentsUnsubscribe(); + agentsUnsubscribe = null; + } + if (_agentsRenderTimer) { + cancelAnimationFrame(_agentsRenderTimer); + _agentsRenderTimer = null; + } +} diff --git a/cmd/web-ui/static/modules/pages/dashboard.js b/cmd/web-ui/static/modules/pages/dashboard.js new file mode 100644 index 0000000..6912383 --- /dev/null +++ b/cmd/web-ui/static/modules/pages/dashboard.js @@ -0,0 +1,1065 @@ +// ── dashboard.js — Dashboard page ──────────────────────── + +import { + escapeHTML, + formatDuration, + formatCount, + formatCost, + formatTokenCount, + tryParseJSON, + animateCounter, + getEnvelopeType, + getEnvelopePayload, + getEnvelopeAttributes, + getEnvelopeSource, + getEnvelopeCorrelation, + getEnvelopeTS, + getVMName, + getVMClassName, + getEventIcon, + getEventLabel, + getEventBody, + isDashboardFeedEvent, + getRecordID, + isCurrentPath, +} from '../utils.js'; + +import { subscribeWS } from '../ws.js'; + +import { + openclawState, + swarmState, + mergeOpenClawEvents, + mergeSwarmSnapshot, + mergeSwarmServiceSnapshot, + getVMStatus, + getDashboardInfraPill, +} from '../state.js'; + +import { clearErrorBadge } from '../palette.js'; +import { app, navigate, isRouteCurrent } from '../router.js'; +import { api } from '../api.js'; + +// uPlot is loaded as a global IIFE; access via window.uPlot +/* global uPlot */ + +// ── Constants & module-level state ────────────────────── + +const DASH_RECENT_EVENTS_LIMIT = 10; +const DASH_RECENT_EVENTS_STORAGE_KEY = 'agentmon:dash:recent-events'; + +let dashboardState = null; +let dashboardUnsubscribe = null; +let dashboardChart = null; +let dashboardResizeObserver = null; +let _dashFeedRenderTimer = null; + +// ── Private helpers ────────────────────────────────────── + +function getDashboardChartMode() { + const mode = localStorage.getItem('agentmon:dash:chart-mode'); + return mode === 'lines' ? 'lines' : 'stacked'; +} + +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; +} + +function buildSparklineSVG(values, color) { + if (!values || values.length < 2) return ''; + const max = Math.max(...values, 1); + const w = 100; + const h = 30; + const points = values.map((v, i) => { + const x = (i / (values.length - 1)) * w; + const y = h - (v / max) * h; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }); + const polyline = points.join(' '); + const areaPath = `M0,${h} L${points.map(p => p).join(' L')} L${w},${h} Z`; + return ` + + +`; +} + +function renderDashSparklines() { + const ts = dashboardState.timeseries; + if (!ts || !ts.series || ts.series.length < 2) return; + const cards = document.querySelectorAll('.summary-card'); + if (cards.length < 4) return; + + const runsData = ts.series.map(b => b.runs || 0); + const toolsData = ts.series.map(b => b.tools || 0); + const errorsData = ts.series.map(b => b.errors || 0); + const totalData = ts.series.map((b, i) => runsData[i] + toolsData[i] + errorsData[i]); + + cards.forEach(c => { const s = c.querySelector('.summary-card-sparkline'); if (s) s.remove(); }); + + cards[0].insertAdjacentHTML('beforeend', buildSparklineSVG(totalData, 'var(--accent)')); + cards[1].insertAdjacentHTML('beforeend', buildSparklineSVG(runsData, 'var(--success)')); + cards[2].insertAdjacentHTML('beforeend', buildSparklineSVG(toolsData, 'var(--purple)')); + cards[3].insertAdjacentHTML('beforeend', buildSparklineSVG(errorsData, 'var(--error)')); +} + +function renderDashVMStrip() { + const strip = document.getElementById('dash-vm-strip'); + if (!strip) return; + const vms = getVMStatus(); + const infra = getDashboardInfraPill(); + strip.innerHTML = [ + ...vms.map(vm => ` +
    + + ${escapeHTML(vm.name)} + ${vm.active ? 'online' : 'offline'} +
    + `), + ` +
    + + ${escapeHTML(infra.name)} + ${escapeHTML(infra.label)} +
    + `, + ].join(''); +} + +function renderSummaryCards() { + const s = dashboardState.summary; + if (!s) return; + + animateCounter('dash-active', s.active_sessions); + animateCounter('dash-runs', s.runs_today); + animateCounter('dash-tools', s.tool_calls_today); + animateCounter('dash-errors', s.errors_today); + + 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); + + animateCounter('dash-tokens-today', formatTokenCount(s.tokens_today || 0)); + animateCounter('dash-cost-today', s.cost_today ? formatCost(s.cost_today) : '$0.0000'); + animateCounter('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; + animateCounter('dash-error-rate', rate.toFixed(1) + '%'); + errorRateEl.classList.toggle('alert', rate > 5); + } +} + +async function loadTimeseries() { + try { + 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(); renderDashSparklines(); 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(); + renderDashSparklines(); + 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 = ` +
    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() { + const container = document.getElementById('dash-chart'); + if (!container || !dashboardState.timeseries) return; + + const data = buildChartData(); + renderDashboardChartInsights(); + renderDashboardChartHover(dashboardState.chartCursorIndex); + if (!data) { + container.innerHTML = '

    No data for this window

    '; + return; + } + + 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 window.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(); + renderDashSparklines(); +} + +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 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); + 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 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-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 = '

    No framework data

    '; + return; + } + + const maxTotal = Math.max(...entries.map(([, s]) => s.runs + s.tools + s.errors)); + + container.innerHTML = '
    ' + entries.map(([name, stats]) => { + const total = stats.runs + stats.tools + stats.errors; + const pct = maxTotal > 0 ? (total / maxTotal * 100) : 0; + const cssClass = name.toLowerCase().replace(/[^a-z0-9-]/g, '-'); + return ` +
    +
    + ${escapeHTML(name)} + ${total} events +
    +
    +
    +
    +
    + `; + }).join('') + '
    '; +} + +function renderRightPanel() { + const mode = dashboardState && dashboardState.rightPanelMode; + if (mode === 'tokens') { + renderTokenPanel(); + } else if (mode === 'latency') { + renderLatencyPanel(); + } else { + renderFrameworkBars(); + } +} + +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(-DASH_RECENT_EVENTS_LIMIT).reverse(); + if (recent.length === 0) { + feed.innerHTML = '

    Waiting for events...

    '; + 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 = '
  • No tool data yet
  • '; + return; + } + + const maxCount = topTools[0]?.[1] || 1; + list.innerHTML = topTools.map(([name, count]) => { + const pct = (count / maxCount * 100).toFixed(1); + return ` +
  • +
    + ${escapeHTML(name)} + ${count} +
    +
    +
    +
    +
  • + `; + }).join(''); +} + +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(''); +} + +// ── Exports ────────────────────────────────────────────── + +export async function renderDashboard(routeToken) { + clearErrorBadge(); + 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 = ` + +
    +
    +
    Active Sessions
    +
    -
    +
     
    +
    +
    +
    Runs Today
    +
    -
    +
     
    +
    +
    +
    Tool Calls
    +
    -
    +
     
    +
    +
    +
    Errors
    +
    -
    +
     
    +
    +
    +
    +
    + Tokens today + - +
    +
    + Cost today + - +
    +
    + Avg run duration + - +
    +
    + Error rate + - +
    +
    +
    Infrastructure
    +
    +
    +
    +
    +
    + Event Rate + Runs, tool spans, and errors over time +
    +
    +
    + total + runs + tools + errors +
    +
    + + +
    +
    + + + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + +
    +
    +
    +

    Loading...

    +
    +
    +
    +
    +
    +
    + Recent Activity +
    +
    +

    Loading...

    +
    +
    +
    +
    + Top Usage +
    +
    +
    Tools
    +
      +
    • Loading...
    • +
    +
    +
    +
    Models
    +
      +
    • Loading...
    • +
    +
    +
    +
    + `; + + 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(); + } + + 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(); renderDashSparklines(); 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 ((routeToken && !isRouteCurrent(routeToken)) || !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(); + renderDashSparklines(); + renderRightPanel(); + + 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); + } + + if (!routeToken || isRouteCurrent(routeToken)) { + dashboardUnsubscribe = subscribeWS(handleDashboardWS); + } +} + +export function cleanup() { + if (dashboardUnsubscribe) { + dashboardUnsubscribe(); + dashboardUnsubscribe = null; + } + if (dashboardChart) { + dashboardChart.destroy(); + dashboardChart = null; + } + if (dashboardResizeObserver) { + dashboardResizeObserver.disconnect(); + dashboardResizeObserver = null; + } + if (_dashFeedRenderTimer) { + cancelAnimationFrame(_dashFeedRenderTimer); + _dashFeedRenderTimer = null; + } +} diff --git a/cmd/web-ui/static/modules/pages/infrastructure.js b/cmd/web-ui/static/modules/pages/infrastructure.js new file mode 100644 index 0000000..ad502f4 --- /dev/null +++ b/cmd/web-ui/static/modules/pages/infrastructure.js @@ -0,0 +1,412 @@ +import { app, isRouteCurrent } from '../router.js'; +import { api } from '../api.js'; +import { + escapeHTML, relativeTime, + getEnvelopeType, getEnvelopePayload, getEnvelopeTS, + isCurrentPath, infrastructureSkeleton, formatBytes, +} from '../utils.js'; +import { subscribeWS } from '../ws.js'; +import { + openclawState, swarmState, + mergeOpenClawEvents, mergeSwarmSnapshot, mergeSwarmServiceSnapshot, + getK8sHomelabServices, +} from '../state.js'; + +let infraUnsubscribe = null; +let _infraTimerInterval = null; + +export function cleanup() { + if (infraUnsubscribe) { infraUnsubscribe(); infraUnsubscribe = null; } + if (_infraTimerInterval) { clearInterval(_infraTimerInterval); _infraTimerInterval = null; } +} + +function handleInfraWS(msg) { + if (msg.type !== 'message') return; + + const eventType = getEnvelopeType(msg.data); + + if (eventType === 'openclaw.snapshot') { + mergeOpenClawEvents([msg.data]); + if (isCurrentPath('/infrastructure')) renderInfraGrid(); + // Dead branch removed: 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; + } +} + +async function loadInfrastructureSnapshots() { + const [ocData, swarmData, serviceData] = await Promise.all([ + api('/v1/events?event_type=openclaw.snapshot&limit=100'), + api('/v1/events?event_type=swarm.snapshot&limit=10').catch(() => ({ events: [] })), + api('/v1/events?event_type=swarm.service.snapshot&limit=100').catch(() => ({ events: [] })), + ]); + + mergeOpenClawEvents(ocData.events || []); + for (const evt of swarmData.events || []) mergeSwarmSnapshot(evt); + for (const evt of serviceData.events || []) mergeSwarmServiceSnapshot(evt); +} + +// ── Infrastructure Uptime & Freshness ─────────────────── +function getUptimeBadge(uptimeSec) { + if (!uptimeSec) return ''; + const hours = uptimeSec / 3600; + const pct = Math.min(100, (hours / 24) * 100); + const cls = pct >= 99 ? 'good' : pct >= 90 ? 'warn' : 'bad'; + return `${pct.toFixed(0)}% / 24h`; +} + +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 serviceCardHeader(svc) { + const uptimeBadge = getUptimeBadge(svc.uptime_sec); + return ` +
    +
    +
    ${escapeHTML(svc.name)}${uptimeBadge ? ' ' + uptimeBadge : ''}
    +
    ${escapeHTML(svc.role || '')}
    +
    + ${escapeHTML(svc.status || 'down')} +
    + `; +} + +function serviceStatRow(label, value, valueClass) { + return ` +
    + ${escapeHTML(label)} + ${value} +
    + `; +} + +function renderVMCard(name) { + const evt = openclawState.instances[name]; + const payload = getEnvelopePayload(evt); + const inst = payload.instance || {}; + const host = payload.host || {}; + const guest = payload.guest; + const issues = payload.issues; + + return ` +
    +
    +

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

    +
    + ${host.state === 'running' ? 'Running' : 'Stopped'} +
    +
    +
    Updated ${escapeHTML(relativeTime(getEnvelopeTS(evt)))}
    + + + + + + + +
    Host${escapeHTML(inst.host || '-')}
    Domain${escapeHTML(inst.domain || '-')}
    vCPUs${host.vcpus || '-'}
    Memory${escapeHTML(formatBytes(host.memory_kib ? host.memory_kib * 1024 : 0) || '-')}
    Disk${escapeHTML(formatBytes(host.disk_actual_bytes) || '-')}
    Autostart${host.autostart ? 'Yes' : 'No'}
    + ${guest ? ` +
    + + + + + + + + +
    Gateway${guest.service_active ? 'Active' : 'Inactive'}
    HTTP${guest.http_status || 'N/A'}
    Version${escapeHTML(guest.version || '-')}
    Guest Mem${guest.memory_percent !== undefined ? guest.memory_percent.toFixed(1) : '-'}%
    Guest Disk${guest.disk_percent !== undefined ? guest.disk_percent.toFixed(1) : '-'}%
    Load${guest.load_average !== undefined ? guest.load_average.toFixed(2) : '-'}
    Uptime${escapeHTML(guest.service_uptime || '-')}
    + ` : ''} + ${issues && Object.values(issues).some(Boolean) ? ` +
    +
    Issues
    +
    + ${Object.entries(issues).filter(([, value]) => value).map(([key]) => ` + ${escapeHTML(key.replace(/_/g, ' '))} + `).join('')} +
    + ` : ''} +
    + `; +} + +function renderLLMProxyCard(svc) { + const extra = svc.extra || {}; + const modelCount = extra.model_count; + const cooldowns = extra.cooldown_count || 0; + const httpStatus = svc.http_status; + const httpClass = httpStatus === 200 ? 'ok' : httpStatus ? 'bad' : ''; + + return ` +
    + ${serviceCardHeader(svc)} +
    + ${modelCount !== undefined ? modelCount : '-'} + models +
    + ${cooldowns > 0 ? `
    ⚠ ${cooldowns} model${cooldowns > 1 ? 's' : ''} in cooldown
    ` : ''} +
    + ${serviceStatRow('HTTP', httpStatus ? String(httpStatus) : '-', httpClass)} + ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')} + ${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')} +
    +
    + `; +} + +function renderDBCard(svc) { + const healthClass = svc.health_state === 'healthy' ? 'ok' : svc.health_state === 'unhealthy' ? 'bad' : ''; + return ` +
    + ${serviceCardHeader(svc)} +
    + ${serviceStatRow('Health', escapeHTML(svc.health_state || 'none'), healthClass)} + ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')} + ${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')} +
    +
    + `; +} + +function renderSearchCard(svc) { + const extra = svc.extra || {}; + const ms = extra.response_ms; + const httpStatus = svc.http_status; + const httpClass = httpStatus === 200 ? 'ok' : httpStatus ? 'bad' : ''; + return ` +
    + ${serviceCardHeader(svc)} +
    + ${serviceStatRow('HTTP', httpStatus ? String(httpStatus) : '-', httpClass)} + ${ms !== undefined ? serviceStatRow('Response', ms + 'ms', ms < 500 ? 'ok' : 'warn') : ''} + ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')} +
    +
    + `; +} + +function renderMCPCard(svc) { + const extra = svc.extra || {}; + const reachable = extra.port_reachable; + return ` +
    + ${serviceCardHeader(svc)} +
    + ${reachable !== undefined ? serviceStatRow('Port', reachable ? 'reachable' : 'unreachable', reachable ? 'ok' : 'bad') : ''} + ${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')} + ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')} +
    +
    + `; +} + +function renderVoiceCard(svc) { + const healthClass = svc.health_state === 'healthy' ? 'ok' : svc.health_state === 'unhealthy' ? 'bad' : ''; + return ` +
    + ${serviceCardHeader(svc)} +
    + ${serviceStatRow('Health', escapeHTML(svc.health_state || 'none'), healthClass)} + ${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')} + ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')} +
    +
    + `; +} + +function renderAutomationCard(svc) { + const healthClass = svc.health_state === 'healthy' ? 'ok' : svc.health_state === 'unhealthy' ? 'bad' : ''; + return ` +
    + ${serviceCardHeader(svc)} +
    + ${serviceStatRow('Health', escapeHTML(svc.health_state || 'none'), healthClass)} + ${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')} + ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')} +
    +
    + `; +} + +function renderAPICard(svc) { + const httpStatus = svc.http_status; + const httpClass = httpStatus === 200 ? 'ok' : httpStatus ? 'bad' : ''; + return ` +
    + ${serviceCardHeader(svc)} +
    + ${serviceStatRow('HTTP', httpStatus ? String(httpStatus) : '-', httpClass)} + ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')} + ${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')} +
    +
    + `; +} + +function renderWorkerCard(svc) { + return ` +
    + ${serviceCardHeader(svc)} +
    + ${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')} + ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')} +
    +
    + `; +} + +function renderGenericServiceCard(svc) { + return ` +
    + ${serviceCardHeader(svc)} +
    + ${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')} + ${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')} +
    +
    + `; +} + +function 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 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 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 = ` + + +
    +

    VMs

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

    No VM data

    ' + : `
    ${vmNames.map(name => renderVMCard(name)).join('')}
    ` + } +
    + +
    +

    Swarm Services

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

    No swarm service data

    ' + : `
    ${swarmServices.map(svc => renderServiceCard(svc)).join('')}
    ` + } +
    + +
    +

    K8s Homelab

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

    No k8s homelab service data

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

    Agentmon

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

    No agentmon service data

    ' + : `
    ${agentmonServices.map(svc => renderServiceCard(svc)).join('')}
    ` + } +
    + `; + + // Start freshness timer — update "Updated X ago" text every 10s + if (_infraTimerInterval) clearInterval(_infraTimerInterval); + _infraTimerInterval = setInterval(() => { + document.querySelectorAll('.freshness-timer[data-ts]').forEach(el => { + el.textContent = 'Updated ' + relativeTime(el.dataset.ts); + }); + }, 10000); + + // Manual refresh button + document.getElementById('infra-refresh-btn')?.addEventListener('click', async () => { + const btn = document.getElementById('infra-refresh-btn'); + if (btn) btn.disabled = true; + try { + await loadInfrastructureSnapshots(); + renderInfraGrid(); + } finally { + const b = document.getElementById('infra-refresh-btn'); + if (b) b.disabled = false; + } + }); +} + +export async function renderInfrastructure(routeToken) { + app.innerHTML = `
    ${infrastructureSkeleton()}
    `; + + infraUnsubscribe = subscribeWS(handleInfraWS); + + try { + await loadInfrastructureSnapshots(); + if (routeToken && !isRouteCurrent(routeToken)) return; + + if (isCurrentPath('/infrastructure')) { + renderInfraGrid(); + } + } catch (e) { + if (isCurrentPath('/infrastructure')) { + app.innerHTML = `

    Error: ${escapeHTML(e.message)}

    `; + } + } +} diff --git a/cmd/web-ui/static/modules/pages/run-detail.js b/cmd/web-ui/static/modules/pages/run-detail.js new file mode 100644 index 0000000..98a4e42 --- /dev/null +++ b/cmd/web-ui/static/modules/pages/run-detail.js @@ -0,0 +1,409 @@ +import { app, navigate, isRouteCurrent } from '../router.js'; +import { api } from '../api.js'; +import { + escapeHTML, formatDuration, formatTokenCount, formatCost, formatElapsed, + getEnvelopeCorrelation, getEnvelopeType, getEnvelopeAttributes, getEnvelopePayload, + isCurrentPath, renderCopyButton, statusIcon, skeletonRows, extractRunUsage, +} from '../utils.js'; +import { subscribeWS } from '../ws.js'; + +let runDetailUnsubscribe = null; +let runLiveOps = {}; // spanID → { name, kind, startedAt, promptPreview, inputPreview } +let _runReloadTimer = null; +let runSpansViewMode = 'table'; + +export function cleanup() { + if (runDetailUnsubscribe) { runDetailUnsubscribe(); runDetailUnsubscribe = null; } + clearTimeout(_runReloadTimer); + _runReloadTimer = null; + runLiveOps = {}; +} + +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 renderTimescale(totalMS) { + const ticks = 5; + return Array.from({ length: ticks + 1 }, (_, i) => { + const pct = (i / ticks * 100).toFixed(0); + return `${escapeHTML(formatDuration(totalMS * i / ticks))}`; + }).join(''); +} + +function renderSpanWaterfall(spans, runStartedAt, runDurationMS) { + if (!spans || spans.length === 0) return '

    No spans

    '; + const runStart = new Date(runStartedAt).getTime(); + const totalMS = runDurationMS || Math.max(...spans.map(sp => { + const s = new Date(sp.started_at || runStartedAt).getTime(); + return (s - runStart) + (sp.duration_ms || 0); + }), 1); + + return ` +
    +
    +
    Span
    +
    +
    ${renderTimescale(totalMS)}
    +
    +
    + ${spans.map(sp => { + const spStart = sp.started_at ? new Date(sp.started_at).getTime() - runStart : 0; + const spDur = sp.duration_ms || 0; + const leftPct = Math.max(0, (spStart / totalMS * 100)).toFixed(2); + const widthPct = Math.max(0.5, (spDur / totalMS * 100)).toFixed(2); + const kindClass = sp.kind || 'unknown'; + const statusClass = sp.status === 'error' ? ' wf-error' : sp.status === 'success' ? ' wf-success' : ''; + return ` +
    +
    + ${escapeHTML(sp.kind || '?')} + ${escapeHTML((sp.name || '(unnamed)').slice(0, 40))} +
    +
    +
    +
    + ${spDur > totalMS * 0.05 ? escapeHTML(formatDuration(spDur)) : ''} +
    +
    +
    +
    `; + }).join('')} +
    `; +} + +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 renderRunSpansTable(spans) { + return ` +
    + + + + + + + + + + + ${renderRunSpansRows(spans)} + +
    NameKindStatusDuration
    +
    `; +} + +function captureOpenSpanIndices() { + const openIndices = new Set(); + document.querySelectorAll('tr.span-detail-row').forEach(row => { + if (row.style.display !== 'none') openIndices.add(row.dataset.index); + }); + return openIndices; +} + +function restoreOpenSpanIndices(openIndices) { + if (!openIndices || openIndices.size === 0) return; + document.querySelectorAll('tr.span-detail-row').forEach(row => { + if (!openIndices.has(row.dataset.index)) return; + row.style.display = 'table-row'; + const hdr = document.querySelector(`tr.run-span-row[data-index="${row.dataset.index}"]`); + const icon = hdr?.querySelector('.expand-icon'); + if (icon) icon.style.transform = 'rotate(45deg)'; + }); +} + +function updateSpanViewButtons() { + document.getElementById('spans-view-table')?.classList.toggle('active', runSpansViewMode === 'table'); + document.getElementById('spans-view-waterfall')?.classList.toggle('active', runSpansViewMode === 'waterfall'); +} + +function renderSpansView(spans, run, openIndices) { + const container = document.getElementById('spans-container'); + if (!container) return; + + if (runSpansViewMode === 'waterfall') { + container.innerHTML = renderSpanWaterfall( + spans, + run.started_at, + run.ended_at ? new Date(run.ended_at) - new Date(run.started_at) : null, + ); + } else { + container.innerHTML = renderRunSpansTable(spans); + bindRunSpanRows(); + restoreOpenSpanIndices(openIndices); + } + + updateSpanViewButtons(); +} + +function bindSpanViewToggles(spans, run) { + const tableBtn = document.getElementById('spans-view-table'); + const waterfallBtn = document.getElementById('spans-view-waterfall'); + + if (tableBtn) { + tableBtn.onclick = () => { + runSpansViewMode = 'table'; + renderSpansView(spans, run, captureOpenSpanIndices()); + }; + } + if (waterfallBtn) { + waterfallBtn.onclick = () => { + runSpansViewMode = 'waterfall'; + renderSpansView(spans, run, captureOpenSpanIndices()); + }; + } +} + +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(); + } + }); + }); +} + +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 openIndices = captureOpenSpanIndices(); + + renderSpansView(spans, r, openIndices); + bindSpanViewToggles(spans, r); + + 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 (runDetailUnsubscribe) { runDetailUnsubscribe(); runDetailUnsubscribe = null; } + const liveSpan = document.querySelector('.section-title .live-indicator'); + if (liveSpan) liveSpan.remove(); + } + } catch (e) { + console.error('Failed to reload run detail:', e); + } +} + +export async function renderRun(runID, routeToken) { + app.innerHTML = '
    ' + '
    ' + skeletonRows(5, 4) + '
    NameKindStatusDuration
    '; + runLiveOps = {}; + runSpansViewMode = 'table'; + let data; + try { + data = await api('/v1/runs/' + runID); + } catch (e) { + if (routeToken && !isRouteCurrent(routeToken)) return; + app.innerHTML = `

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

    `; + return; + } + if (routeToken && !isRouteCurrent(routeToken)) 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'; + const runUsage = extractRunUsage(spans); + + app.innerHTML = ` + ← Back to Session + + ${!r.ended_at ? '
    ' : ''} +
    + Spans ${spans.length} + ${!r.ended_at ? 'Live' : ''} +
    + + +
    +
    +
    + ${renderRunSpansTable(spans)} +
    + `; + + bindRunSpanRows(); + bindSpanViewToggles(spans, r); + + document.querySelector('.back-link').addEventListener('click', e => { + e.preventDefault(); + navigate('/sessions/' + r.session_id); + }); + + if (!r.ended_at) { + runDetailUnsubscribe = subscribeWS((msg) => handleRunWS(runID, msg)); + } +} diff --git a/cmd/web-ui/static/modules/pages/session-detail.js b/cmd/web-ui/static/modules/pages/session-detail.js new file mode 100644 index 0000000..12f62b6 --- /dev/null +++ b/cmd/web-ui/static/modules/pages/session-detail.js @@ -0,0 +1,219 @@ +import { app, navigate, isRouteCurrent } from '../router.js'; +import { api } from '../api.js'; +import { + escapeHTML, formatDuration, formatTokenCount, formatCost, + getEnvelopeCorrelation, getEnvelopeType, + isCurrentPath, renderCopyButton, statusIcon, extractRunUsage, +} from '../utils.js'; +import { subscribeWS } from '../ws.js'; + +let sessionDetailUnsubscribe = null; +let _sessionReloadTimer = null; + +export function cleanup() { + if (sessionDetailUnsubscribe) { sessionDetailUnsubscribe(); sessionDetailUnsubscribe = null; } + clearTimeout(_sessionReloadTimer); + _sessionReloadTimer = null; +} + +function renderSessionRunsRows(runs) { + if (!runs || runs.length === 0) { + return 'No runs'; + } + + return runs.map((r, i) => { + const runDuration = r.ended_at + ? formatDuration(new Date(r.ended_at) - new Date(r.started_at)) + : '-'; + const modelLabel = r.model ? escapeHTML(r.model.replace(/^claude-/, '')) : '-'; + const spans = r.spans || []; + const spansHTML = spans.length > 0 ? ` +
    + ${spans.map(sp => { + const body = getSessionSpanSummary(sp); + return ` +
    + ${escapeHTML(sp.name || sp.kind || 'span')} + ${escapeHTML(body)} +
    + `; + }).join('')} +
    + ` : '
    No spans yet
    '; + + return ` + + ${escapeHTML(r.run_id.substring(0, 12))}...${renderCopyButton(r.run_id)} + ${statusIcon(r.status)} + ${modelLabel} + ${r.tool_count || 0} + ${r.span_count} + ${escapeHTML(runDuration)} + ${escapeHTML(new Date(r.started_at).toLocaleTimeString())} + + + +
    +
    Spans ${spans.length}
    + ${spansHTML} +
    + + + `; + }).join(''); +} + +function getSessionSpanSummary(sp) { + const payload = sp.payload || {}; + const innerPayload = payload.payload || {}; + if (sp.kind === 'tool') { + const result = innerPayload.result_preview || ''; + const duration = sp.duration_ms !== undefined && sp.duration_ms !== null ? formatDuration(sp.duration_ms) : '-'; + return result ? `${duration} · ${String(result).slice(0, 80)}` : duration; + } + if (sp.kind === 'agent') { + const usage = innerPayload.usage || {}; + const totalTokens = usage.total_tokens !== undefined ? `${usage.total_tokens} tok` : ''; + const duration = sp.duration_ms !== undefined && sp.duration_ms !== null ? formatDuration(sp.duration_ms) : '-'; + return totalTokens ? `${duration} · ${totalTokens}` : duration; + } + return sp.duration_ms !== undefined && sp.duration_ms !== null ? formatDuration(sp.duration_ms) : '-'; +} + +function bindSessionRunRows() { + document.querySelectorAll('tr.expandable-run').forEach(row => { + row.addEventListener('click', event => { + if (event.metaKey || event.ctrlKey) { + navigate('/runs/' + row.dataset.run); + return; + } + + const idx = row.dataset.index; + const detailRow = document.querySelector(`tr.span-detail-row[data-index="${idx}"]`); + const icon = row.querySelector('.expand-icon'); + if (!detailRow) return; + + if (detailRow.style.display === 'none') { + detailRow.style.display = 'table-row'; + if (icon) icon.style.transform = 'rotate(45deg)'; + } else { + detailRow.style.display = 'none'; + if (icon) icon.style.transform = ''; + } + }); + + row.addEventListener('dblclick', () => navigate('/runs/' + row.dataset.run)); + row.setAttribute('tabindex', '0'); + row.setAttribute('role', 'button'); + row.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + row.click(); + } + }); + }); +} + +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', 'span.start', 'span.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; +} + +export async function renderSession(sessionID, routeToken) { + const data = await api('/v1/sessions/' + sessionID); + if (routeToken && !isRouteCurrent(routeToken)) return; + 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'; + + // Aggregate token/cost/tool data from runs' spans + let sessionTotalTokens = 0, sessionTotalCost = 0, sessionTotalTools = 0; + runs.forEach(r => { + const usage = extractRunUsage(r.spans || []); + if (usage) { sessionTotalTokens += usage.totalTokens; sessionTotalCost += usage.totalCost; } + sessionTotalTools += (r.tool_count || 0); + }); + + app.innerHTML = ` + ← Back to Sessions + +
    Runs ${runs.length}
    +
    + + + + + + + + + + + + + + ${renderSessionRunsRows(runs)} + +
    Run IDStatusModelToolsSpansDurationStarted
    +
    + `; + + bindSessionRunRows(); + + document.querySelector('.back-link').addEventListener('click', e => { + e.preventDefault(); + navigate('/sessions'); + }); + + sessionDetailUnsubscribe = subscribeWS((msg) => handleSessionWS(sessionID, msg)); +} diff --git a/cmd/web-ui/static/modules/pages/sessions.js b/cmd/web-ui/static/modules/pages/sessions.js new file mode 100644 index 0000000..a95ff56 --- /dev/null +++ b/cmd/web-ui/static/modules/pages/sessions.js @@ -0,0 +1,480 @@ +import { app, navigate, isRouteCurrent } from '../router.js'; +import { api } from '../api.js'; +import { + escapeHTML, relativeTime, + getEnvelopeType, getEnvelopeCorrelation, getEnvelopeSource, getEnvelopeTS, + isCurrentPath, renderCopyButton, sessionsSkeleton, +} from '../utils.js'; +import { subscribeWS } from '../ws.js'; + +let sessionsState = { sessions: [], cursor: null, total: 0, activeSessionByBackend: {} }; +let sessionsPageUnsubscribe = null; + +let sessionFilterMode = 'all'; +let sessionSortKey = 'started_at'; +let sessionSortDir = 'desc'; + +export function cleanup() { + if (sessionsPageUnsubscribe) { sessionsPageUnsubscribe(); sessionsPageUnsubscribe = null; } + if (sessionsState.timerInterval) { clearInterval(sessionsState.timerInterval); } + sessionsState = { sessions: [], cursor: null, total: 0, activeSessionByBackend: {} }; +} + +function isSessionActive(s) { return !s.ended_at; } + +function sortSessions(sessions) { + return [...sessions].sort((a, b) => { + let av = a[sessionSortKey], bv = b[sessionSortKey]; + if (sessionSortKey === 'started_at') { + av = new Date(av).getTime(); + bv = new Date(bv).getTime(); + } else if (typeof av === 'string') { + av = av.toLowerCase(); + bv = (bv || '').toLowerCase(); + } + if (av == null) av = 0; + if (bv == null) bv = 0; + if (av < bv) return sessionSortDir === 'asc' ? -1 : 1; + if (av > bv) return sessionSortDir === 'asc' ? 1 : -1; + return 0; + }); +} + +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 updatePaginationInfo() { + const el = document.getElementById('pagination-info'); + if (!el) return; + const loaded = sessionsState.sessions.length; + const total = sessionsState.total || loaded; + let filtered = loaded; + if (sessionFilterMode === 'active') { + filtered = sessionsState.sessions.filter(s => isSessionActive(s)).length; + } else if (sessionFilterMode === 'ended') { + filtered = sessionsState.sessions.filter(s => !isSessionActive(s)).length; + } else if (sessionFilterMode === 'errored') { + filtered = sessionsState.sessions.filter(s => (s._errorCount || 0) > 0).length; + } + if (filtered < loaded) { + el.textContent = `Showing ${filtered} of ${loaded} loaded (${total} total)`; + } else { + el.textContent = `Showing ${loaded} of ${total}`; + } +} + +function refreshSessionsTable() { + const tbody = document.getElementById('sessions-body'); + if (!tbody) return; + + // Update pill counts based on full unfiltered sessions list + const all = sessionsState.sessions; + const activeCount = all.filter(s => isSessionActive(s)).length; + const endedCount = all.filter(s => !isSessionActive(s)).length; + const erroredCount = all.filter(s => (s._errorCount || 0) > 0).length; + const pillDefs = [ + { filter: 'all', count: all.length }, + { filter: 'active', count: activeCount }, + { filter: 'ended', count: endedCount }, + { filter: 'errored', count: erroredCount }, + ]; + pillDefs.forEach(({ filter, count }) => { + const btn = document.querySelector(`#session-pills [data-filter="${filter}"]`); + if (!btn) return; + let countEl = btn.querySelector('.pill-count'); + if (!countEl) { + countEl = document.createElement('span'); + countEl.className = 'pill-count'; + btn.appendChild(countEl); + } + countEl.textContent = count; + }); + + // Apply filter + let filtered = sessionsState.sessions; + if (sessionFilterMode === 'active') { + filtered = filtered.filter(s => isSessionActive(s)); + } else if (sessionFilterMode === 'ended') { + filtered = filtered.filter(s => !isSessionActive(s)); + } else if (sessionFilterMode === 'errored') { + filtered = filtered.filter(s => (s._errorCount || 0) > 0); + } + + const groups = groupSessionsByDate(sortSessions(filtered)); + if (groups.length === 0) { + tbody.innerHTML = 'No sessions found'; + return; + } + + // Update sort indicator classes on headers + document.querySelectorAll('th.sortable').forEach(th => { + th.classList.remove('sort-asc', 'sort-desc'); + if (th.dataset.sort === sessionSortKey) { + th.classList.add(sessionSortDir === 'asc' ? 'sort-asc' : 'sort-desc'); + } + }); + const allFiltered = groups.flatMap(g => g.items); + const maxDuration = Math.max(...allFiltered.map(s => { + const start = new Date(s.started_at).getTime(); + const end = s.ended_at ? new Date(s.ended_at).getTime() : Date.now(); + return end - start; + }), 1); + 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'); + const rowClass = active ? 'clickable active-session' : 'clickable'; + const start = new Date(s.started_at).getTime(); + const end = s.ended_at ? new Date(s.ended_at).getTime() : Date.now(); + const duration = end - start; + const barWidth = Math.max(4, (duration / maxDuration) * 80); + const durationBar = ``; + const errorCell = (s._errorCount || 0) > 0 + ? `${s._errorCount}` + : ''; + return ` + + ${escapeHTML(s.session_id.substring(0, 12))}…${renderCopyButton(s.session_id)} + ${escapeHTML(fw)} + ${escapeHTML(s.host || '-')} + ${s.run_count} + ${escapeHTML(relativeTime(s.started_at))}${durationBar} + ${errorCell} + `; + }).join(''); + return `${escapeHTML(group.label)}${rows}`; + }).join(''); + tbody.querySelectorAll('tr.clickable').forEach(row => { + row.addEventListener('click', () => navigate('/sessions/' + row.dataset.session)); + }); + updatePaginationInfo(); +} + +// Dead code: renderSessionRow is never called but preserved for fidelity +function renderSessionRow(s) { // eslint-disable-line no-unused-vars + 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'); + const errorCell = (s._errorCount || 0) > 0 + ? `${s._errorCount}` + : ''; + return ` + ${escapeHTML(s.session_id.substring(0, 12))}…${renderCopyButton(s.session_id)} + ${escapeHTML(fw)} + ${escapeHTML(s.host || '-')} + ${s.run_count} + ${escapeHTML(relativeTime(s.started_at))} + ${errorCell} + `; +} + +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) { + // Update only the text node, preserving the duration bar span + const bar = td.querySelector('.session-duration-bar'); + td.textContent = relativeTime(s.started_at); + if (bar) td.appendChild(bar); + } + } + }); +} + +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(); + } + } + + if (eventType === 'error' && sessionId) { + const session = sessionsState.sessions.find(s => s.session_id === sessionId); + if (session) { + session._errorCount = (session._errorCount || 0) + 1; + } + } + + refreshSessionsTable(); +} + +async function loadSessions(routeToken) { + 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()); + if (routeToken && !isRouteCurrent(routeToken)) return; + 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; + // total is only returned on the first page (no cursor) + if (data.total !== undefined) sessionsState.total = data.total; + recomputeActiveSessionByBackend(); + + refreshSessionsTable(); + document.getElementById('load-more').style.display = sessionsState.cursor ? 'block' : 'none'; +} + +export async function renderSessions(routeToken) { + // Reset filter mode on each page visit + sessionFilterMode = 'all'; + + app.innerHTML = ` + +
    + + + + +
    +
    + + + + +
    +
    +
    + + + + + + + + + + + + ${sessionsSkeleton()} +
    Session Framework Host Runs Time Errors
    +
    + + `; + + // Wire up filter pill click handlers + document.querySelectorAll('#session-pills .filter-pill').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('#session-pills .filter-pill').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + sessionFilterMode = btn.dataset.filter; + refreshSessionsTable(); + }); + }); + + // Wire up sortable column headers + document.querySelectorAll('th.sortable').forEach(th => { + th.addEventListener('click', () => { + const key = th.dataset.sort; + if (sessionSortKey === key) { + sessionSortDir = sessionSortDir === 'asc' ? 'desc' : 'asc'; + } else { + sessionSortKey = key; + sessionSortDir = 'desc'; + } + refreshSessionsTable(); + }); + }); + + api('/v1/stats/summary').then(data => { + if (routeToken && !isRouteCurrent(routeToken)) return; + 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(routeToken); + }); + }); + let _hostDebounce = null; + document.getElementById('filter-host').addEventListener('input', () => { + clearTimeout(_hostDebounce); + _hostDebounce = setTimeout(() => { + sessionsState.sessions = []; + sessionsState.cursor = null; + loadSessions(routeToken); + }, 400); + }); + + document.getElementById('load-more').addEventListener('click', () => loadSessions(routeToken)); + + sessionsState = { sessions: [], cursor: null, total: 0, timerInterval: null, activeSessionByBackend: {} }; + await loadSessions(routeToken); + if (routeToken && !isRouteCurrent(routeToken)) return; + + sessionsState.timerInterval = setInterval(updateSessionTimers, 30000); + sessionsPageUnsubscribe = subscribeWS(handleSessionsWS); +} diff --git a/cmd/web-ui/static/modules/pages/settings.js b/cmd/web-ui/static/modules/pages/settings.js new file mode 100644 index 0000000..cab3aa0 --- /dev/null +++ b/cmd/web-ui/static/modules/pages/settings.js @@ -0,0 +1,49 @@ +import { app } from '../router.js'; +import { showToast } from '../api.js'; + +export async function renderSettings() { + app.innerHTML = ` + + +
    +

    Data Retention

    +

    Delete events older than the specified number of days. This runs automatically every 24 hours. Currently configured via RETENTION_DAYS environment variable (default: 30 days).

    +
    + +
    + + days + +
    +
    +
    +
    + `; + + document.getElementById('run-retention-btn')?.addEventListener('click', async () => { + const days = parseInt(document.getElementById('retention-days').value, 10); + if (!days || days < 1) { showToast('Enter a valid number of days', 'error'); return; } + const btn = document.getElementById('run-retention-btn'); + btn.disabled = true; + btn.textContent = 'Running…'; + try { + const resp = await fetch('/api/v1/admin/retention', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ days }), + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Request failed'); + const result = document.getElementById('retention-result'); + if (result) result.innerHTML = `Deleted ${data.deleted} events older than ${new Date(data.cutoff).toLocaleDateString()}.`; + showToast(`Deleted ${data.deleted} events`, 'success'); + } catch (e) { + showToast('Retention failed: ' + e.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Run Now'; + } + }); +} diff --git a/cmd/web-ui/static/modules/pages/usage.js b/cmd/web-ui/static/modules/pages/usage.js new file mode 100644 index 0000000..63ed7ce --- /dev/null +++ b/cmd/web-ui/static/modules/pages/usage.js @@ -0,0 +1,107 @@ +import { app, isRouteCurrent } from '../router.js'; +import { api } from '../api.js'; +import { escapeHTML, formatTokenCount, formatCost } from '../utils.js'; + +export async function renderUsage(routeToken) { + app.innerHTML = ` + +
    Loading…
    + `; + + const [summary, toolsData, modelsData, tsData] = await Promise.all([ + api('/v1/stats/summary').catch(() => null), + api('/v1/stats/top-tools?limit=20').catch(() => ({ tools: [] })), + api('/v1/stats/top-models?limit=10').catch(() => ({ models: [] })), + api('/v1/stats/timeseries?window=7d').catch(() => ({ series: [] })), + ]); + if (routeToken && !isRouteCurrent(routeToken)) return; + + const tools = toolsData.tools || []; + const models = modelsData.models || []; + const series = tsData.series || []; + + // Aggregate 7d totals from timeseries + const totals7d = series.reduce((acc, b) => { + acc.runs += b.runs || 0; + acc.tools += b.tools || 0; + acc.errors += b.errors || 0; + acc.tokens += b.tokens || 0; + acc.cost += b.cost || 0; + return acc; + }, { runs: 0, tools: 0, errors: 0, tokens: 0, cost: 0 }); + + const s = summary || {}; + + const content = document.getElementById('usage-content'); + if (!content) return; + + content.innerHTML = ` +
    +
    Active Sessions
    ${s.active_sessions || 0}
    +
    Runs Today
    ${s.runs_today || 0}
    +
    Tool Calls Today
    ${s.tool_calls_today || 0}
    +
    Errors Today
    ${s.errors_today || 0}
    +
    Tokens Today
    ${formatTokenCount(s.tokens_today || 0)}
    +
    Cost Today
    ${formatCost(s.cost_today || 0)}
    +
    + +
    +
    +
    7-Day Totals
    +
    +
    Runs${totals7d.runs}
    +
    Tool Calls${totals7d.tools}
    +
    Errors${totals7d.errors}
    +
    Tokens${formatTokenCount(totals7d.tokens)}
    +
    Est. Cost${formatCost(totals7d.cost)}
    +
    +
    +
    + +
    +
    +
    Top Models ${models.length}
    + ${models.length === 0 ? '

    No model data yet

    ' : ` +
      + ${(() => { + const max = models[0]?.count || 1; + return models.map(m => { + const pct = (m.count / max * 100).toFixed(1); + return `
    • +
      + ${escapeHTML(m.name)} + ${m.count} +
      +
      +
      +
      +
    • `; + }).join(''); + })()} +
    `} +
    + +
    +
    Top Tools ${tools.length}
    + ${tools.length === 0 ? '

    No tool data yet

    ' : ` +
      + ${(() => { + const max = tools[0]?.count || 1; + return tools.map(t => { + const pct = (t.count / max * 100).toFixed(1); + return `
    • +
      + ${escapeHTML(t.name)} + ${t.count} +
      +
      +
      +
      +
    • `; + }).join(''); + })()} +
    `} +
    +
    + `; +} diff --git a/cmd/web-ui/static/modules/palette.js b/cmd/web-ui/static/modules/palette.js new file mode 100644 index 0000000..9871acb --- /dev/null +++ b/cmd/web-ui/static/modules/palette.js @@ -0,0 +1,212 @@ +// ── palette.js — command palette, error badge, global search + +import { escapeHTML } from './utils.js'; +import { api, showToast } from './api.js'; +import { cycleTheme } from './theme.js'; +import { agentsState, isAgentOnline } from './state.js'; +import { navigate } from './router.js'; +import { selectAgent } from './pages/agents.js'; + +// ── Error badge ────────────────────────────────────────── + +let _unseenErrors = 0; + +export function incrementErrorBadge() { + if (window.location.pathname === '/') return; // On dashboard, don't badge + _unseenErrors++; + const badge = document.getElementById('nav-error-badge'); + if (badge) { + badge.textContent = _unseenErrors > 99 ? '99+' : String(_unseenErrors); + badge.classList.add('visible'); + } +} + +export function clearErrorBadge() { + _unseenErrors = 0; + const badge = document.getElementById('nav-error-badge'); + if (badge) { + badge.classList.remove('visible'); + badge.textContent = ''; + } +} + +// ── Command palette ────────────────────────────────────── + +let paletteOpen = false; +let paletteSelectedIndex = 0; + +export function isCommandPaletteOpen() { return paletteOpen; } + +export function getCommandPaletteItems(query) { + const items = [ + { label: 'Dashboard', path: '/', icon: '◉', shortcut: 'g d' }, + { label: 'Sessions', path: '/sessions', icon: '▶', shortcut: 'g s' }, + { label: 'Agents', path: '/agents', icon: '◎', shortcut: 'g a' }, + { label: 'Infrastructure', path: '/infrastructure', icon: '⚡', shortcut: 'g i' }, + { label: 'Settings', path: '/settings', icon: '⚙', shortcut: 'g p' }, + { label: 'Usage', path: '/usage', icon: '◈', shortcut: 'g u' }, + { label: 'Toggle Theme', action: 'theme', icon: '◐' }, + ]; + + // Add agent items dynamically + if (agentsState && agentsState.agents) { + for (const [key, agent] of Object.entries(agentsState.agents)) { + items.push({ + label: 'Agent: ' + (agent.name || key), + path: '/agents', + action: 'select-agent', + agentKey: key, + icon: isAgentOnline(agent) ? '●' : '○', + }); + } + } + + if (!query) return items; + const q = query.toLowerCase(); + return items.filter(item => item.label.toLowerCase().includes(q)); +} + +export function openCommandPalette() { + if (paletteOpen) return; + paletteOpen = true; + paletteSelectedIndex = 0; + + const backdrop = document.createElement('div'); + backdrop.className = 'cmd-palette-backdrop'; + backdrop.id = 'cmd-palette-backdrop'; + backdrop.innerHTML = ` +
    +
    + + +
    +
    + +
    + `; + + document.body.appendChild(backdrop); + const input = document.getElementById('cmd-palette-input'); + input.focus(); + renderPaletteItems(''); + + input.addEventListener('input', () => { + paletteSelectedIndex = 0; + renderPaletteItems(input.value); + }); + + input.addEventListener('keydown', (e) => { + const items = document.querySelectorAll('.cmd-palette-item'); + if (e.key === 'ArrowDown') { + e.preventDefault(); + paletteSelectedIndex = Math.min(paletteSelectedIndex + 1, items.length - 1); + updatePaletteSelection(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + paletteSelectedIndex = Math.max(paletteSelectedIndex - 1, 0); + updatePaletteSelection(); + } else if (e.key === 'Enter') { + e.preventDefault(); + const selected = items[paletteSelectedIndex]; + if (selected) selected.click(); + } else if (e.key === 'Escape') { + closeCommandPalette(); + } + }); + + backdrop.addEventListener('click', (e) => { + if (e.target === backdrop) closeCommandPalette(); + }); +} + +export function closeCommandPalette() { + paletteOpen = false; + const backdrop = document.getElementById('cmd-palette-backdrop'); + if (backdrop) backdrop.remove(); +} + +function renderPaletteItems(query) { + const container = document.getElementById('cmd-palette-results'); + if (!container) return; + const items = getCommandPaletteItems(query); + + // If query looks like an ID (4+ hex chars), add a search option + if (query.length >= 4) { + items.unshift({ label: 'Search: ' + query, action: 'search', query, icon: '🔍' }); + } + + container.innerHTML = items.map((item, i) => ` +
    +
    ${item.icon}
    + ${escapeHTML(item.label)} + ${item.shortcut ? `${item.shortcut}` : ''} +
    + `).join(''); + + container.querySelectorAll('.cmd-palette-item').forEach((el, i) => { + el.addEventListener('click', () => executePaletteItem(items[i])); + el.addEventListener('mouseenter', () => { + paletteSelectedIndex = i; + updatePaletteSelection(); + }); + }); +} + +function updatePaletteSelection() { + document.querySelectorAll('.cmd-palette-item').forEach((el, i) => { + el.classList.toggle('selected', i === paletteSelectedIndex); + if (i === paletteSelectedIndex) el.scrollIntoView({ block: 'nearest' }); + }); +} + +function executePaletteItem(item) { + closeCommandPalette(); + if (item.action === 'theme') { + cycleTheme(); + } else if (item.action === 'search') { + handleGlobalSearch(item.query); + } else if (item.action === 'select-agent') { + navigate('/agents'); + setTimeout(() => selectAgent(item.agentKey, 'live'), 100); + } else if (item.path) { + navigate(item.path); + } +} + +// ── Global search ──────────────────────────────────────── + +export async function handleGlobalSearch(query) { + query = query.trim(); + if (query.length < 4) { + showToast('Enter at least 4 characters', 'info'); + return; + } + + // Hex ID pattern — try as session or run + if (/^[a-f0-9-]{4,}$/i.test(query)) { + try { + const sessionData = await api('/v1/sessions/' + query).catch(() => null); + if (sessionData && sessionData.session) { + navigate('/sessions/' + query); + return; + } + + const runData = await api('/v1/runs/' + query).catch(() => null); + if (runData && runData.run) { + navigate('/runs/' + query); + return; + } + + showToast('ID not found', 'error'); + } catch (e) { + showToast('Search failed: ' + e.message, 'error'); + } + } else { + // Non-hex: treat as framework/host search + navigate('/sessions?framework=' + encodeURIComponent(query)); + } +} diff --git a/cmd/web-ui/static/modules/router.js b/cmd/web-ui/static/modules/router.js new file mode 100644 index 0000000..60668c7 --- /dev/null +++ b/cmd/web-ui/static/modules/router.js @@ -0,0 +1,156 @@ +// ── router.js — SPA routing, navigation, breadcrumbs ───── +// Circular imports with page modules are safe: all cross-module +// accesses happen inside function bodies, never at module init time. + +import { escapeHTML } from './utils.js'; +import { agentsState } from './state.js'; +import { resetNavController } from './nav-signal.js'; + +import { renderDashboard, cleanup as cleanupDashboard } from './pages/dashboard.js'; +import { renderSessions, cleanup as cleanupSessions } from './pages/sessions.js'; +import { renderSession, cleanup as cleanupSessionDetail } from './pages/session-detail.js'; +import { renderRun, cleanup as cleanupRunDetail } from './pages/run-detail.js'; +import { renderAgents, cleanup as cleanupAgents } from './pages/agents.js'; +import { renderInfrastructure, cleanup as cleanupInfra } from './pages/infrastructure.js'; +import { renderSettings } from './pages/settings.js'; +import { renderUsage } from './pages/usage.js'; + +// Exported so all page modules can write into it without querying the DOM each time +export const app = document.getElementById('app'); + +let currentRouteToken = 0; + +export function isRouteCurrent(token) { + return token === currentRouteToken; +} + +export function cleanupLiveViews() { + cleanupInfra(); + cleanupAgents(); + cleanupSessions(); + cleanupSessionDetail(); + cleanupRunDetail(); + cleanupDashboard(); +} + +export function route() { + const routeToken = ++currentRouteToken; + resetNavController(); + cleanupLiveViews(); + renderBreadcrumbs(); + + app.classList.add('transitioning'); + const path = window.location.pathname; + + const safeRender = (fn) => { + if (!isRouteCurrent(routeToken)) return; + Promise.resolve().then(() => { + if (!isRouteCurrent(routeToken)) return null; + return fn(routeToken); + }).catch(err => { + if (!isRouteCurrent(routeToken)) return; + if (err && err.name === 'AbortError') return; // route changed mid-fetch + console.error('Render error:', err); + app.innerHTML = ` +
    +

    Something went wrong

    +

    An error occurred while rendering this page.

    +
    ${escapeHTML(err.message)}
    + Back to Dashboard +
    + `; + }); + }; + + if (path === '/') { + safeRender((token) => renderDashboard(token)); + } else if (path === '/sessions') { + safeRender((token) => renderSessions(token)); + } else if (path.startsWith('/agents/')) { + const agentKey = decodeURIComponent(path.split('/agents/')[1]); + safeRender((token) => renderAgents(agentKey, token)); + } else if (path.startsWith('/agents')) { + safeRender((token) => renderAgents(undefined, token)); + } else if (path.startsWith('/infrastructure')) { + safeRender((token) => renderInfrastructure(token)); + } else if (path.startsWith('/sessions/')) { + safeRender((token) => renderSession(path.split('/sessions/')[1], token)); + } else if (path.startsWith('/runs/')) { + safeRender((token) => renderRun(path.split('/runs/')[1], token)); + } else if (path === '/settings') { + safeRender((token) => renderSettings(token)); + } else if (path === '/usage') { + safeRender((token) => renderUsage(token)); + } else { + app.innerHTML = '

    Page not found

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

    Go to Dashboard
    '; + } + updateActiveNav(); + + requestAnimationFrame(() => { + if (isRouteCurrent(routeToken)) app.classList.remove('transitioning'); + }); +} + +export function renderBreadcrumbs() { + const el = document.getElementById('breadcrumbs'); + if (!el) return; + + const path = window.location.pathname; + const parts = path.split('/').filter(Boolean); + if (parts.length === 0) { + el.innerHTML = ''; + return; + } + + const items = [{ label: 'Dashboard', path: '/' }]; + let currentPath = ''; + + parts.forEach((part, i) => { + currentPath += '/' + part; + let label = part.charAt(0).toUpperCase() + part.slice(1); + + // Special labels for IDs + if (part.length > 20 || /^[a-f0-9-]{32,}$/.test(part)) { + label = part.substring(0, 8) + '…'; + } + + // For /agents/:key, show the human-readable agent name + if (parts[0] === 'agents' && i === 1) { + const agentKey = decodeURIComponent(part); + const agent = agentsState && agentsState.agents && agentsState.agents[agentKey]; + label = (agent && agent.name) ? agent.name : agentKey; + } + + if (i === parts.length - 1) { + items.push({ label, current: true }); + } else { + items.push({ label, path: currentPath }); + } + }); + + // No inline onclick — the delegated click handler in app.js picks these up. + el.innerHTML = items.map(item => { + if (item.current) { + return `${escapeHTML(item.label)}`; + } + return `${escapeHTML(item.label)}/`; + }).join(''); +} + +export function navigate(path) { + history.pushState(null, '', path); + route(); +} + +export 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); diff --git a/cmd/web-ui/static/modules/state.js b/cmd/web-ui/static/modules/state.js new file mode 100644 index 0000000..75a04d0 --- /dev/null +++ b/cmd/web-ui/static/modules/state.js @@ -0,0 +1,134 @@ +// ── state.js — shared live state for openclawState, swarmState, agentsState + +import { + getEnvelopePayload, + getEnvelopeTS, + normalizeAgentKey, +} from './utils.js'; + +// ── OpenClaw & Swarm ───────────────────────────────────── + +export let openclawState = { instances: {} }; +export let swarmState = { services: {} }; + +export 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; + } + } +} + +export function mergeSwarmSnapshot(evt) { + const payload = getEnvelopePayload(evt); + const services = payload.services || []; + for (const svc of services) { + if (svc.name) swarmState.services[svc.name] = svc; + } +} + +export function mergeSwarmServiceSnapshot(evt) { + const payload = getEnvelopePayload(evt); + const svc = payload.service; + if (svc && svc.name) swarmState.services[svc.name] = svc; +} + +export 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; +} + +export 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' }; + }); +} + +export 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'}`, + }; +} + +export function isOpenClawVM(agent) { + const key = normalizeAgentKey(agent && agent.name); + return !!openclawState.instances[key]; +} + +export 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; +} + +// ── Agents state ───────────────────────────────────────── + +function createAgentsState() { + return { + agents: {}, + stats: { messages: 0, tools: 0, errors: 0, toolCounts: {} }, + dbStats: { messages: 0, tools: 0, errors: 0 }, + viewMode: 'overview', + selectedAgentKey: '', + timerInterval: null, + }; +} + +export let agentsState = createAgentsState(); + +export function resetAgentsState() { + agentsState = createAgentsState(); +} diff --git a/cmd/web-ui/static/modules/theme.js b/cmd/web-ui/static/modules/theme.js new file mode 100644 index 0000000..0cf1faf --- /dev/null +++ b/cmd/web-ui/static/modules/theme.js @@ -0,0 +1,33 @@ +// ── theme.js — theme toggle, no imports ────────────────── + +export const THEME_CYCLE = ['system', 'light', 'dark']; + +export const THEME_ICONS = { + system: '', + light: '', + dark: '', +}; + +export const THEME_LABELS = { system: 'System theme', light: 'Light theme', dark: 'Dark theme' }; + +export function getTheme() { return localStorage.getItem('theme') || 'system'; } + +export function applyTheme(theme) { + if (theme === 'system') document.documentElement.removeAttribute('data-theme'); + else document.documentElement.setAttribute('data-theme', theme); +} + +export function updateToggleBtn(theme) { + const btn = document.getElementById('theme-toggle'); + if (!btn) return; + btn.innerHTML = THEME_ICONS[theme]; + btn.title = THEME_LABELS[theme]; +} + +export 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); +} diff --git a/cmd/web-ui/static/modules/utils.js b/cmd/web-ui/static/modules/utils.js new file mode 100644 index 0000000..191c372 --- /dev/null +++ b/cmd/web-ui/static/modules/utils.js @@ -0,0 +1,338 @@ +// ── utils.js — pure helpers, no imports ────────────────── + +export function escapeHTML(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function tryParseJSON(s) { try { return s ? JSON.parse(s) : null; } catch { return null; } } + +export function animateCounter(elementId, newValue) { + const elem = document.getElementById(elementId); + if (!elem) return; + const oldText = elem.textContent; + const newText = String(newValue); + if (oldText === newText) return; + elem.textContent = newText; + elem.classList.remove('bumped'); + void elem.offsetWidth; // force reflow + elem.classList.add('bumped'); +} + +export 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'; +} + +export 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'; +} + +export 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]; +} + +export function formatCount(value) { + if (value === undefined || value === null || value === '') return '-'; + return String(value); +} + +export 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); +} + +export 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); +} + +export 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'; +} + +export function statusIcon(status) { + if (status === 'success') return 'success'; + if (status === 'error') return 'error'; + return 'unknown'; +} + +export function skeletonRows(rows, cols) { + return Array(rows).fill(0).map(() => + '' + Array(cols).fill('
    ').join('') + '' + ).join(''); +} + +export function dashboardSkeleton() { + return ` +
    ${Array(4).fill('
    ').join('')}
    +
    + `; +} + +export function sessionsSkeleton() { + return Array(8).fill(0).map((_, i) => { + const widths = [['55%','25%'], ['65%','20%'], ['45%','30%'], ['70%','15%'], ['50%','22%'], ['60%','18%'], ['42%','28%'], ['68%','12%']]; + const [w1, w2] = widths[i % widths.length]; + return ` +
    +
    +
    +
    +
    + `; + }).join(''); +} + +export function agentsSkeleton() { + return `
    + ${Array(4).fill('
    ').join('')} +
    `; +} + +export function infrastructureSkeleton() { + return `
    + ${Array(6).fill('
    ').join('')} +
    `; +} + +// ── Envelope helpers ───────────────────────────────────── + +export function extractEnvelope(record) { + if (record && typeof record === 'object' && record.payload && record.payload.event && record.payload.schema) { + return record.payload; + } + return record || {}; +} + +export function getEnvelopeEvent(record) { + const envelope = extractEnvelope(record); + return envelope.event || envelope.Event || {}; +} + +export function getEnvelopeType(record) { + return record?.type || getEnvelopeEvent(record).type || ''; +} + +export function getEnvelopeTS(record) { + return record?.ts || getEnvelopeEvent(record).ts || ''; +} + +export function getEnvelopeSource(record) { + return getEnvelopeEvent(record).source || {}; +} + +export function getEnvelopePayload(record) { + const envelope = extractEnvelope(record); + return envelope.payload || envelope.Payload || {}; +} + +export function getEnvelopeAttributes(record) { + const envelope = extractEnvelope(record); + return envelope.attributes || envelope.Attributes || {}; +} + +export function getEnvelopeCorrelation(record) { + const envelope = extractEnvelope(record); + return envelope.correlation || envelope.Correlation || {}; +} + +export function getRecordID(record) { + return record?.event_id || getEnvelopeEvent(record).id || ''; +} + +export function isCurrentPath(prefix) { + return window.location.pathname.startsWith(prefix); +} + +// ── Agent identity helpers ─────────────────────────────── + +export function normalizeAgentKey(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-'); +} + +export 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 }; +} + +// ── VM/event display helpers ───────────────────────────── + +export function getVMName(evt) { + return getAgentIdentity(evt).name || 'unknown'; +} + +export function getVMClassName(vmName) { + const normalized = String(vmName || 'unknown').toLowerCase(); + return ['zap', 'orb', 'sun'].includes(normalized) ? normalized : 'unknown'; +} + +export function getEventIcon(eventType) { + switch (eventType) { + case 'run.start': return '
    '; + case 'run.end': return '
    '; + case 'span.start': + case 'span.end': return '
    '; + case 'error': return '
    !
    '; + case 'session.start': + case 'session.end': return '
    '; + default: return '
    ·
    '; + } +} + +export 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; +} + +export function getEventBody(evt) { + const eventType = getEnvelopeType(evt); + const payload = getEnvelopePayload(evt); + const attrs = getEnvelopeAttributes(evt); + const correlation = getEnvelopeCorrelation(evt); + + if (eventType === 'span.start' || eventType === 'span.end') { + const name = attrs.name || attrs.span_kind || 'unknown span'; + const duration = payload.duration_ms !== undefined && payload.duration_ms !== null + ? ` ${escapeHTML(formatDuration(payload.duration_ms))}` + : ''; + const detailClass = attrs.span_kind === 'agent' || attrs.type === 'subagent' ? ' subagent-name' : ' tool-name'; + const prefix = attrs.span_kind === 'agent' || attrs.type === 'subagent' ? 'subagent ' : ''; + return `
    ${escapeHTML(prefix + name)}${duration}
    `; + } + + if (eventType === 'run.start') { + const preview = payload.prompt_preview || payload.message_preview || payload.message || ''; + if (!preview) return ''; + const trimmed = preview.length > 140 ? preview.slice(0, 140) + '...' : preview; + return `
    "${escapeHTML(trimmed)}"
    `; + } + + if (eventType === 'run.end') { + return `
    ${statusIcon(payload.status || 'unknown')}
    `; + } + + if (eventType === 'error') { + const errPayload = payload.error || {}; + const errType = errPayload.type || 'error'; + const message = errPayload.message || payload.message || 'unknown'; + return `
    ${escapeHTML(errType + ': ' + message)}
    `; + } + + if (eventType === 'session.start' || eventType === 'session.end') { + return correlation.session_id + ? `
    session ${escapeHTML(correlation.session_id)}
    ` + : ''; + } + + return ''; +} + +export 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); +} + +export function isAgentTimelineEvent(evt) { + const eventType = getEnvelopeType(evt); + return [ + 'session.start', 'session.end', + 'run.start', 'run.end', + 'span.start', 'span.end', + 'error', + ].includes(eventType); +} + +export function isDashboardFeedEvent(evt) { + const eventType = getEnvelopeType(evt); + return isAgentTimelineEvent(evt) || eventType === 'metric.snapshot'; +} + +// ── Run usage extraction ───────────────────────────────── + +export function extractRunUsage(spans) { + let totalTokens = 0, inputTokens = 0, outputTokens = 0, totalCost = 0; + let found = false; + (spans || []).forEach(sp => { + const inner = (sp.payload || {}).payload || {}; + const checkUsage = (u) => { + if (!u) return; + if (u.total_tokens != null) { totalTokens = Math.max(totalTokens, Number(u.total_tokens) || 0); found = true; } + if (u.input_tokens != null) inputTokens = Math.max(inputTokens, Number(u.input_tokens) || 0); + if (u.output_tokens != null) outputTokens = Math.max(outputTokens, Number(u.output_tokens) || 0); + if (u.total_cost != null) totalCost = Math.max(totalCost, Number(u.total_cost) || 0); + }; + checkUsage(inner.usage); + if (inner.metrics) checkUsage(inner.metrics.usage); + }); + return found ? { totalTokens, inputTokens, outputTokens, totalCost } : null; +} + +// ── Copy button ────────────────────────────────────────── + +export function renderCopyButton(text) { + // data-copy is decoded in a single HTML-attribute context by the parser, + // so escapeHTML is sufficient. A delegated click handler in app.js picks + // these up and calls copyToClipboard — no inline onclick needed. + return ``; +} diff --git a/cmd/web-ui/static/modules/ws.js b/cmd/web-ui/static/modules/ws.js new file mode 100644 index 0000000..5ef1a35 --- /dev/null +++ b/cmd/web-ui/static/modules/ws.js @@ -0,0 +1,76 @@ +// ── ws.js — WebSocket connection and subscription ──────── + +let ws = null; +let wsStatus = 'disconnected'; +let wsReconnectTimeout = null; +let wsReconnectDelay = 1000; +const wsCallbacks = new Set(); + +export function getWsURL() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return protocol + '//' + window.location.host + '/api/v1/ws'; +} + +export 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); + } +} + +export function subscribeWS(callback) { + wsCallbacks.add(callback); + if (!ws || ws.readyState !== WebSocket.OPEN) { + connectWS(); + } + return () => wsCallbacks.delete(callback); +} + +export 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'; +} diff --git a/cmd/web-ui/static/style.css b/cmd/web-ui/static/style.css index be741ac..ff26261 100644 --- a/cmd/web-ui/static/style.css +++ b/cmd/web-ui/static/style.css @@ -447,6 +447,20 @@ tr:last-child td { border-bottom: none; } tr.clickable { cursor: pointer; } +th.sortable { cursor: pointer; user-select: none; } +th.sortable:hover { color: var(--text-bright); } +th.sortable.sort-asc .sort-icon::after { content: ' ↑'; } +th.sortable.sort-desc .sort-icon::after { content: ' ↓'; } +.sort-icon { color: var(--accent); font-size: 0.7rem; } + +.pagination-info { + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--text-dim); + margin-bottom: 0.75rem; + letter-spacing: 0.02em; +} + tr.clickable:hover td { background: var(--surface-2); color: var(--text-bright); @@ -623,8 +637,9 @@ tr:hover .copy-btn, box-shadow: 0 4px 12px rgba(0,0,0,0.5); transform: translateY(1rem); opacity: 0; - transition: transform 0.2s cubic-bezier(0.17, 0.67, 0.83, 0.67), opacity 0.2s; + transition: transform 0.2s cubic-bezier(0.17, 0.67, 0.83, 0.67), opacity 0.2s, bottom 0.2s ease; pointer-events: none; + max-width: min(420px, calc(100vw - 2rem)); } .toast.visible { @@ -796,6 +811,32 @@ tr:hover .copy-btn, background: var(--accent-dim); } +.refresh-btn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + background: transparent; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-dim); + font-family: var(--font-body); + font-size: 0.78rem; + font-weight: 500; + padding: 0.35rem 0.75rem; + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; +} +.refresh-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-dim); } +.refresh-btn:disabled { opacity: 0.4; cursor: default; } + +.page-header-row { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 0.6rem; +} +.page-header-row h2 { margin-bottom: 0; } + /* ── Span expand ───────────────────────────────────────────── */ .expandable { cursor: pointer; } @@ -2046,6 +2087,13 @@ tr.expandable:hover .expand-icon::before { font-family: var(--font-mono); } +.meta-tile-sub { + font-family: var(--font-mono); + font-size: 0.68rem; + color: var(--text-dim); + margin-top: 0.2rem; +} + /* ── VM card divider ──────────────────────────────────────── */ .vm-card-divider { height: 1px; @@ -2966,6 +3014,22 @@ tr.clickable.active-session td:first-child { vertical-align: middle; } +.error-count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 5px; + background: rgba(248, 113, 113, 0.15); + color: var(--error); + border: 1px solid rgba(248, 113, 113, 0.25); + border-radius: 10px; + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 600; +} + /* ── Span kind badge ──────────────────────────────────────── */ .span-kind-badge { display: inline-flex; @@ -3580,41 +3644,6 @@ tr.clickable.active-session td:first-child { } } -/* ── 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; @@ -3631,6 +3660,31 @@ tr.clickable.active-session td:first-child { margin-bottom: 1.5rem; } +.error-boundary { + padding: 3rem 2rem; + max-width: 560px; + margin: 0 auto; +} +.error-boundary h2 { + font-family: var(--font-display); + font-size: 1.4rem; + color: var(--error); + margin-bottom: 0.5rem; +} +.error-boundary p { color: var(--text-dim); margin-bottom: 1rem; } +.error-boundary-detail { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.75rem 1rem; + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--code-text); + margin-bottom: 1.25rem; + white-space: pre-wrap; + word-break: break-word; +} + /* ── Infrastructure Uptime & Freshness ────────────────────────────────── */ .uptime-badge { display: inline-block; @@ -3717,6 +3771,159 @@ tr.run-span-row[tabindex="0"]:focus-visible { color: var(--accent); } +/* ── Span Waterfall ──────────────────────────────────────── */ +.waterfall { + overflow-x: auto; +} +.waterfall-header, +.waterfall-row { + display: grid; + grid-template-columns: 240px 1fr; + gap: 0.75rem; + align-items: center; + padding: 0.4rem 1.25rem; + border-bottom: 1px solid var(--border-soft); +} +.waterfall-header { background: var(--surface-2); font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); } +.waterfall-row:hover { background: var(--surface-2); } +.waterfall-name-col { display: flex; align-items: center; gap: 0.4rem; min-width: 0; } +.waterfall-name { font-size: 0.8rem; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.waterfall-bar-col { position: relative; } +.waterfall-bar-track { position: relative; height: 20px; background: var(--surface-2); border-radius: 3px; } +.waterfall-bar { + position: absolute; + top: 2px; + height: 16px; + border-radius: 3px; + background: var(--accent); + opacity: 0.7; + display: flex; + align-items: center; + overflow: hidden; + transition: opacity 0.15s; +} +.waterfall-bar:hover { opacity: 1; } +.waterfall-bar.wf-error { background: var(--error); } +.waterfall-bar.wf-success { background: var(--success); } +.waterfall-bar-label { font-family: var(--font-mono); font-size: 0.6rem; padding: 0 4px; color: #fff; white-space: nowrap; } +.waterfall-timescale { position: relative; height: 16px; } +.waterfall-timescale span { position: absolute; transform: translateX(-50%); font-family: var(--font-mono); font-size: 0.62rem; color: var(--text-dim); } + +/* ── Usage Page ──────────────────────────────────────────── */ +.usage-summary-tiles { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1.5rem; +} +.usage-section-row { + display: flex; + gap: 1.25rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} +.usage-panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.25rem 1.5rem; + flex: 1 1 300px; + min-width: 260px; +} +.usage-7d-tiles { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-top: 0.75rem; +} +.usage-7d-tile { + display: flex; + flex-direction: column; + gap: 0.2rem; +} +.usage-7d-label { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-dim); +} +.usage-7d-tile strong { + font-family: var(--font-mono); + font-size: 1.1rem; + color: var(--text-bright); +} +.usage-loading { color: var(--text-dim); padding: 2rem; font-size: 0.9rem; } + +/* ── Settings Page ───────────────────────────────────────── */ +.settings-section { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.5rem; + margin-bottom: 1.5rem; + max-width: 640px; +} +.settings-section-title { + font-family: var(--font-display); + font-size: 1rem; + font-weight: 700; + color: var(--text-bright); + margin-bottom: 0.5rem; +} +.settings-section-desc { + font-size: 0.82rem; + color: var(--text-dim); + margin-bottom: 1.25rem; + line-height: 1.6; +} +.settings-row { + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.settings-label { + font-size: 0.78rem; + font-weight: 600; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.06em; +} +.settings-input-group { + display: flex; + align-items: center; + gap: 0.75rem; +} +.settings-input { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + padding: 0.45rem 0.75rem; + font-family: var(--font-mono); + font-size: 0.88rem; + width: 80px; + outline: none; +} +.settings-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); } +.settings-input-suffix { font-size: 0.82rem; color: var(--text-dim); } +.settings-btn { + background: var(--accent-dim); + border: 1px solid var(--accent-glow); + border-radius: var(--radius); + color: var(--accent); + font-family: var(--font-body); + font-size: 0.82rem; + font-weight: 600; + padding: 0.45rem 1rem; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.settings-btn:hover { background: rgba(34, 211, 238, 0.15); border-color: var(--accent); } +.settings-btn:disabled { opacity: 0.5; cursor: default; } +.settings-result { margin-top: 0.75rem; font-size: 0.82rem; } +.settings-result-ok { color: var(--success); } + /* ── Polish: Focus Rings ─────────────────────────────────── */ a:focus-visible, button:focus-visible,