fix(web-ui): security hardening, SPA nav, and modularization
Ship the in-progress ES-module refactor of the web-ui (new static/modules/ layout, Usage/Settings pages, uplot-based dashboard) alongside a round of security and UX fixes: - main.go: add CSP + X-Frame-Options: DENY + X-Content-Type-Options: nosniff + Referrer-Policy middleware on every response; WS CheckOrigin now requires Origin host to match Host (blocks cross-site WebSocket hijacking); upgrade client before dialing upstream so origin check runs first; fatal on unparseable AGENTMON_QUERY_BASE. - app.js: delegated click handler intercepts same-origin <a> clicks for SPA navigation (prev. every nav link caused a full page reload, dropping WS + in-memory state); delegated .copy-btn[data-copy] handler replaces inline onclick=; removed window.navigate / window.copyToClipboard globals and the duplicated handleGlobalSearch. - modules/nav-signal.js: per-route AbortController so in-flight fetches are cancelled when the user navigates away, preventing stale toasts and wasted renders. - modules/api.js: honours the nav signal by default; AbortError is silent. - modules/router.js: resets the nav controller on every route; dropped the fixed 80ms transition delay; breadcrumbs no longer emit inline onclick= (delegated handler picks them up). - modules/utils.js: renderCopyButton emits data-copy=\"...\" instead of nesting a JS string inside an HTML attribute — fixes an XSS where values containing ' broke out via ' decoding. Verified: go build clean; `node --check` clean on all modified modules; manual curl probes confirm security headers present on every response and WS upgrade returns 403 for cross-origin/missing Origin while 101 for same-origin. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+59
-11
@@ -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 {
|
||||
|
||||
+70
-4120
File diff suppressed because it is too large
Load Diff
@@ -17,14 +17,12 @@
|
||||
<div class="header-logo">
|
||||
<h1><a href="/">agentmon<span class="logo-dot"></span></a></h1>
|
||||
</div>
|
||||
<nav><a href="/">Dashboard<span class="nav-badge" id="nav-error-badge"></span></a><a href="/sessions">Sessions</a><a href="/agents">Agents</a><a href="/infrastructure">Infra</a></nav>
|
||||
<nav><a href="/">Dashboard<span class="nav-badge" id="nav-error-badge"></span></a><a href="/sessions">Sessions</a><a href="/agents">Agents</a><a href="/infrastructure">Infra</a><a href="/usage">Usage</a><a href="/settings">Settings</a></nav>
|
||||
<div class="header-right">
|
||||
<div class="header-search">
|
||||
<input type="text" id="global-search" placeholder="Search ID..." spellcheck="false" autocomplete="off">
|
||||
<kbd>/</kbd>
|
||||
<button class="cmd-k-hint" id="cmd-k-hint" title="Command palette" type="button">
|
||||
<kbd>⌘K</kbd>
|
||||
</button>
|
||||
<button class="cmd-k-hint" id="cmd-k-hint" title="Command palette" type="button"></button>
|
||||
</div>
|
||||
<span class="ws-dot" id="ws-dot" title="Disconnected"></span>
|
||||
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"></button>
|
||||
@@ -35,6 +33,6 @@
|
||||
<p>Loading...</p>
|
||||
</main>
|
||||
<script src="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.iife.min.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
<script type="module" src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 `<div class="agent-lane-sparkline">${buckets.map(b => {
|
||||
const pct = (b / max * 100).toFixed(0);
|
||||
return `<div class="agent-lane-sparkline-bar" style="height:${Math.max(pct, 3)}%"></div>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderAgentLanes() {
|
||||
const contentEl = document.getElementById('agents-content');
|
||||
if (!contentEl) return;
|
||||
contentEl.innerHTML = '<div class="agent-lanes" id="agents-lanes"></div>';
|
||||
|
||||
const lanesEl = document.getElementById('agents-lanes');
|
||||
if (!lanesEl) return;
|
||||
|
||||
const agentKeys = getSortedAgentKeys();
|
||||
|
||||
if (agentKeys.length === 0) {
|
||||
lanesEl.innerHTML = '<p class="empty-state">No recent agent activity</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
lanesEl.innerHTML = agentKeys.map(key => {
|
||||
const agent = agentsState.agents[key];
|
||||
const isOnline = isAgentOnline(agent);
|
||||
const sessionCount = Object.keys(agent.sessions).length;
|
||||
const ops = getAgentDisplayOps(agent);
|
||||
const subagentCount = ops.filter(op => op.kind === 'agent' || op.subType === 'subagent').length;
|
||||
|
||||
const statusClass = sessionCount > 0 ? ' has-sessions' : '';
|
||||
const statusText = !isOnline ? 'offline'
|
||||
: subagentCount > 0 ? subagentCount + ' subagent' + (subagentCount > 1 ? 's' : '')
|
||||
: sessionCount > 0 ? sessionCount + ' session' + (sessionCount > 1 ? 's' : '')
|
||||
: 'idle';
|
||||
|
||||
const opsHTML = ops.length > 0 ? `<div class="active-ops">${ops.map(op => {
|
||||
const elapsed = Math.floor((Date.now() - op.startedAt) / 1000);
|
||||
const stale = elapsed > 300;
|
||||
const kindClass = op.kind === 'agent' || op.subType === 'subagent' ? ' subagent' : '';
|
||||
return `
|
||||
<div class="active-op${stale ? ' stale' : ''}${kindClass}">
|
||||
<span class="active-op-dot"></span>
|
||||
<span class="active-op-name">${escapeHTML(op.name)}</span>
|
||||
<span class="active-op-time" data-start="${op.startedAt}">${formatElapsed(elapsed)}</span>
|
||||
${stale ? '<span class="active-op-stale">(stale?)</span>' : ''}
|
||||
</div>`;
|
||||
}).join('')}</div>` : '';
|
||||
|
||||
const recent = agent.events.slice(-40).reverse();
|
||||
const eventsHTML = recent.length > 0 ? recent.map(evt => {
|
||||
const eventType = getEnvelopeType(evt);
|
||||
const details = getEventDetails(evt);
|
||||
const detailHTML = details ? `<div class="timeline-detail">${escapeHTML(details)}</div>` : '';
|
||||
const expandHTML = details ? '<button class="timeline-expand-hint" type="button">details</button>' : '';
|
||||
|
||||
return `
|
||||
<div class="timeline-event">
|
||||
<div class="timeline-event-header">
|
||||
${getEventIcon(eventType)}
|
||||
<span class="timeline-event-type">${escapeHTML(getEventLabel(eventType))}</span>
|
||||
<span class="timeline-event-time">${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())}</span>
|
||||
</div>
|
||||
${getEventBody(evt)}
|
||||
${expandHTML}
|
||||
${detailHTML}
|
||||
</div>`;
|
||||
}).join('') : '<p class="empty-state">No recent activity</p>';
|
||||
|
||||
return `
|
||||
<div class="agent-lane" data-agent-key="${escapeHTML(key)}">
|
||||
<div class="agent-lane-header">
|
||||
<div>
|
||||
<div class="agent-lane-name">
|
||||
<span class="agent-lane-dot ${isOnline ? 'online' : 'offline'}"></span>
|
||||
${escapeHTML(agent.name || key)}
|
||||
</div>
|
||||
<div class="agent-lane-meta">${escapeHTML(agent.framework || 'unknown')}${agent.host && agent.host !== agent.name ? ' · ' + escapeHTML(agent.host) : ''}</div>
|
||||
${buildAgentActivityBars(agent)}
|
||||
</div>
|
||||
<span class="agent-lane-status${statusClass}">${statusText}</span>
|
||||
</div>
|
||||
${opsHTML}
|
||||
<div class="agent-lane-events">${eventsHTML}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
lanesEl.querySelectorAll('.agent-lane[data-agent-key]').forEach(lane => {
|
||||
lane.addEventListener('click', () => {
|
||||
selectAgent(lane.dataset.agentKey || '', 'live');
|
||||
});
|
||||
});
|
||||
lanesEl.querySelectorAll('.timeline-expand-hint').forEach(button => {
|
||||
button.addEventListener('click', event => {
|
||||
event.stopPropagation();
|
||||
button.parentElement.classList.toggle('expanded');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderAgentSummary() {
|
||||
const el = document.getElementById('agents-summary');
|
||||
if (!el) return;
|
||||
const s = agentsState.dbStats;
|
||||
const liveAgents = getSortedAgentKeys().filter(key => isAgentOnline(agentsState.agents[key])).length;
|
||||
const liveSubagents = getSortedAgentKeys().reduce((count, key) => {
|
||||
const agent = agentsState.agents[key];
|
||||
return count + Object.values(agent.operations).filter(op => op.kind === 'agent' || op.subType === 'subagent').length;
|
||||
}, 0);
|
||||
el.innerHTML = `
|
||||
<div class="agents-summary-stat">Live Agents <span class="value">${liveAgents}</span></div>
|
||||
<div class="agents-summary-stat">Active Subagents <span class="value">${liveSubagents}</span></div>
|
||||
<div class="agents-summary-stat">Runs Today <span class="value">${s.messages}</span></div>
|
||||
<div class="agents-summary-stat">Tool Calls <span class="value">${s.tools}</span></div>
|
||||
<div class="agents-summary-stat">Errors <span class="value">${s.errors}</span></div>
|
||||
`;
|
||||
}
|
||||
|
||||
function getAgentLabel(agent) {
|
||||
if (!agent) return 'Unknown';
|
||||
return agent.name || agent.host || agent.framework || agent.key || 'Unknown';
|
||||
}
|
||||
|
||||
function getAgentLiveSummary(agent) {
|
||||
const recent = agent.events.slice().reverse();
|
||||
const activeOps = getAgentDisplayOps(agent);
|
||||
const sessionIDs = Object.keys(agent.sessions);
|
||||
const live = {
|
||||
sessionIDs,
|
||||
activeOps,
|
||||
activeSubagents: activeOps.filter(op => op.kind === 'agent' || op.subType === 'subagent'),
|
||||
activeTools: activeOps.filter(op => op.kind === 'tool'),
|
||||
latestPrompt: '',
|
||||
latestRunStatus: '',
|
||||
latestModel: '',
|
||||
latestError: '',
|
||||
latestUsage: null,
|
||||
latestContextWindow: null,
|
||||
};
|
||||
|
||||
for (const evt of recent) {
|
||||
const eventType = getEnvelopeType(evt);
|
||||
const payload = getEnvelopePayload(evt);
|
||||
if (!live.latestPrompt && eventType === 'run.start') {
|
||||
live.latestPrompt = payload.prompt_preview || payload.message_preview || payload.message || '';
|
||||
}
|
||||
if (!live.latestRunStatus && eventType === 'run.end') {
|
||||
live.latestRunStatus = payload.status || '';
|
||||
live.latestModel = payload.model || '';
|
||||
live.latestUsage = payload.usage || null;
|
||||
live.latestContextWindow = payload.context_window || null;
|
||||
}
|
||||
if (!live.latestUsage && eventType === 'metric.snapshot' && payload.metrics) {
|
||||
live.latestUsage = payload.metrics.usage || null;
|
||||
live.latestModel = live.latestModel || payload.metrics.model || '';
|
||||
}
|
||||
if (!live.latestError && eventType === 'error') {
|
||||
const errPayload = payload.error || {};
|
||||
live.latestError = errPayload.message || payload.message || '';
|
||||
}
|
||||
if (live.latestPrompt && live.latestRunStatus && live.latestError) break;
|
||||
}
|
||||
|
||||
return live;
|
||||
}
|
||||
|
||||
function buildLiveEventContext(evt) {
|
||||
const eventType = getEnvelopeType(evt);
|
||||
const payload = getEnvelopePayload(evt);
|
||||
const attrs = getEnvelopeAttributes(evt);
|
||||
const correlation = getEnvelopeCorrelation(evt);
|
||||
const parts = [];
|
||||
|
||||
if ((eventType === 'span.start' || eventType === 'span.end') && attrs.span_kind === 'tool') {
|
||||
if (payload.input) {
|
||||
parts.push(`<div class="live-detail-row"><span class="k">input</span><span class="v">${escapeHTML(typeof payload.input === 'string' ? payload.input : JSON.stringify(payload.input))}</span></div>`);
|
||||
}
|
||||
if (payload.result_preview) {
|
||||
parts.push(`<div class="live-detail-row"><span class="k">result</span><span class="v">${escapeHTML(String(payload.result_preview))}</span></div>`);
|
||||
}
|
||||
}
|
||||
if ((eventType === 'span.start' || eventType === 'span.end') && (attrs.span_kind === 'agent' || attrs.type === 'subagent')) {
|
||||
if (payload.prompt_preview) {
|
||||
parts.push(`<div class="live-detail-row"><span class="k">prompt</span><span class="v">${escapeHTML(String(payload.prompt_preview))}</span></div>`);
|
||||
}
|
||||
if (payload.usage && payload.usage.total_tokens !== undefined) {
|
||||
parts.push(`<div class="live-detail-row"><span class="k">tokens</span><span class="v">${escapeHTML(formatCount(payload.usage.total_tokens))}</span></div>`);
|
||||
}
|
||||
if (payload.usage && payload.usage.total_cost !== undefined) {
|
||||
parts.push(`<div class="live-detail-row"><span class="k">cost</span><span class="v">${escapeHTML(formatCost(payload.usage.total_cost))}</span></div>`);
|
||||
}
|
||||
}
|
||||
if (eventType === 'run.start') {
|
||||
const preview = payload.prompt_preview || payload.message_preview || payload.message || '';
|
||||
if (preview) {
|
||||
parts.push(`<div class="live-detail-row"><span class="k">prompt</span><span class="v">${escapeHTML(String(preview))}</span></div>`);
|
||||
}
|
||||
}
|
||||
if (eventType === 'run.end') {
|
||||
if (payload.model) {
|
||||
parts.push(`<div class="live-detail-row"><span class="k">model</span><span class="v">${escapeHTML(String(payload.model))}</span></div>`);
|
||||
}
|
||||
if (payload.usage && payload.usage.total_tokens !== undefined) {
|
||||
parts.push(`<div class="live-detail-row"><span class="k">tokens</span><span class="v">${escapeHTML(formatCount(payload.usage.total_tokens))}</span></div>`);
|
||||
}
|
||||
if (payload.duration_ms !== undefined) {
|
||||
parts.push(`<div class="live-detail-row"><span class="k">duration</span><span class="v">${escapeHTML(formatDuration(payload.duration_ms))}</span></div>`);
|
||||
}
|
||||
}
|
||||
if (eventType === 'metric.snapshot' && payload.metrics) {
|
||||
if (payload.metrics.model) {
|
||||
parts.push(`<div class="live-detail-row"><span class="k">model</span><span class="v">${escapeHTML(String(payload.metrics.model))}</span></div>`);
|
||||
}
|
||||
if (payload.metrics.usage && payload.metrics.usage.total_tokens !== undefined) {
|
||||
parts.push(`<div class="live-detail-row"><span class="k">tokens</span><span class="v">${escapeHTML(formatCount(payload.metrics.usage.total_tokens))}</span></div>`);
|
||||
}
|
||||
if (payload.metrics.usage && payload.metrics.usage.total_cost !== undefined) {
|
||||
parts.push(`<div class="live-detail-row"><span class="k">cost</span><span class="v">${escapeHTML(formatCost(payload.metrics.usage.total_cost))}</span></div>`);
|
||||
}
|
||||
}
|
||||
if (eventType === 'error') {
|
||||
const errPayload = payload.error || {};
|
||||
if (errPayload.type) {
|
||||
parts.push(`<div class="live-detail-row"><span class="k">type</span><span class="v">${escapeHTML(String(errPayload.type))}</span></div>`);
|
||||
}
|
||||
}
|
||||
|
||||
const ids = [];
|
||||
if (correlation.session_id) ids.push(`session ${correlation.session_id}`);
|
||||
if (correlation.run_id) ids.push(`run ${correlation.run_id}`);
|
||||
if (correlation.span_id) ids.push(`span ${correlation.span_id}`);
|
||||
if (ids.length > 0) {
|
||||
parts.push(`<div class="live-detail-row ids"><span class="k">ids</span><span class="v">${escapeHTML(ids.join(' · '))}</span></div>`);
|
||||
}
|
||||
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function getRunGroupLabel(runID, events) {
|
||||
const runStart = events.find(evt => getEnvelopeType(evt) === 'run.start');
|
||||
if (!runStart) {
|
||||
return runID ? `Run ${runID.slice(0, 12)}...` : 'Session activity';
|
||||
}
|
||||
const payload = getEnvelopePayload(runStart);
|
||||
const preview = payload.prompt_preview || payload.message_preview || payload.message || '';
|
||||
if (preview) {
|
||||
return preview.length > 72 ? preview.slice(0, 72) + '...' : preview;
|
||||
}
|
||||
return runID ? `Run ${runID.slice(0, 12)}...` : 'Session activity';
|
||||
}
|
||||
|
||||
function groupAgentEventsByRun(events) {
|
||||
const groups = [];
|
||||
const byRun = new Map();
|
||||
|
||||
for (const evt of events) {
|
||||
const correlation = getEnvelopeCorrelation(evt);
|
||||
const runID = correlation.run_id || '';
|
||||
const key = runID || `session:${correlation.session_id || 'unknown'}`;
|
||||
if (!byRun.has(key)) {
|
||||
const group = {
|
||||
key,
|
||||
runID,
|
||||
sessionID: correlation.session_id || '',
|
||||
events: [],
|
||||
subagents: new Set(),
|
||||
tools: new Set(),
|
||||
};
|
||||
byRun.set(key, group);
|
||||
groups.push(group);
|
||||
}
|
||||
|
||||
const group = byRun.get(key);
|
||||
group.events.push(evt);
|
||||
const attrs = getEnvelopeAttributes(evt);
|
||||
if (attrs.span_kind === 'agent' || attrs.type === 'subagent') {
|
||||
group.subagents.add(attrs.name || 'unknown');
|
||||
}
|
||||
if (attrs.span_kind === 'tool' && attrs.name) {
|
||||
group.tools.add(attrs.name);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function refreshThinkingStream(agent) {
|
||||
if (!agent) return;
|
||||
const selectedKey = agentsState.selectedAgentKey;
|
||||
if (agent.key !== selectedKey) return;
|
||||
const streamEl = document.getElementById('thinking-stream-' + selectedKey);
|
||||
if (streamEl) {
|
||||
streamEl.innerHTML = renderThinkingStream(agent);
|
||||
}
|
||||
}
|
||||
|
||||
function renderThinkingStream(agent) {
|
||||
const now = Date.now();
|
||||
const ops = Object.values(agent.operations).filter(op => {
|
||||
if (op.endedAt) return (now - op.endedAt) < 3000;
|
||||
return (now - op.startedAt) < 300000;
|
||||
});
|
||||
|
||||
if (ops.length === 0) {
|
||||
return '<div class="thinking-stream-empty">Idle — waiting for activity</div>';
|
||||
}
|
||||
|
||||
return ops.map(op => {
|
||||
const elapsed = op.endedAt
|
||||
? Math.floor((op.endedAt - op.startedAt) / 1000)
|
||||
: Math.floor((now - op.startedAt) / 1000);
|
||||
const isEnded = !!op.endedAt;
|
||||
const isSubagent = op.kind === 'agent' || op.subType === 'subagent';
|
||||
const isRun = op.kind === 'run';
|
||||
const isTool = op.kind === 'tool';
|
||||
|
||||
let icon, kindLabel, kindClass;
|
||||
if (isRun) {
|
||||
icon = isEnded ? '✓' : '◌';
|
||||
kindLabel = isEnded ? (op.status === 'success' ? 'Done' : op.status || 'Done') : 'Thinking';
|
||||
kindClass = 'thinking-op-run' + (isEnded ? ' ended' : ' active');
|
||||
} else if (isSubagent) {
|
||||
icon = isEnded ? '✓' : '◎';
|
||||
kindLabel = isEnded ? (op.status === 'success' ? 'Subagent done' : 'Subagent ' + (op.status || 'done')) : 'Subagent';
|
||||
kindClass = 'thinking-op-subagent' + (isEnded ? ' ended' : ' active');
|
||||
} else if (isTool) {
|
||||
icon = isEnded ? '✓' : '▸';
|
||||
kindLabel = isEnded ? (op.status === 'success' ? 'Tool done' : 'Tool ' + (op.status || 'done')) : 'Tool';
|
||||
kindClass = 'thinking-op-tool' + (isEnded ? ' ended' : ' active');
|
||||
} else {
|
||||
icon = '·';
|
||||
kindLabel = op.name;
|
||||
kindClass = 'thinking-op-other' + (isEnded ? ' ended' : ' active');
|
||||
}
|
||||
|
||||
const preview = op.promptPreview || op.inputPreview || '';
|
||||
const result = op.resultPreview || '';
|
||||
const usage = op.usage || {};
|
||||
const thinkingToks = op.thinkingTokens || usage.thinking_tokens || 0;
|
||||
const totalToks = usage.total_tokens || 0;
|
||||
|
||||
const navigableRunID = isRun ? op.runID : (isSubagent ? op.runID : '');
|
||||
const clickable = navigableRunID ? ` clickable" data-run-id="${escapeHTML(navigableRunID)}` : '';
|
||||
|
||||
return `
|
||||
<div class="thinking-op ${kindClass}${clickable ? ' thinking-op-link' : ''}"${clickable ? ` data-run-id="${escapeHTML(navigableRunID)}"` : ''}>
|
||||
<div class="thinking-op-header">
|
||||
<span class="thinking-op-icon${isRun && !isEnded ? ' spin' : ''}">${icon}</span>
|
||||
<span class="thinking-op-kind">${escapeHTML(kindLabel)}</span>
|
||||
<span class="thinking-op-name">${escapeHTML(op.name)}</span>
|
||||
<span class="thinking-op-elapsed${isEnded ? '' : ' live'}" data-start="${op.startedAt}" data-ended="${op.endedAt || ''}">
|
||||
${isEnded ? formatElapsed(elapsed) : `<span class="active-op-time" data-start="${op.startedAt}">${formatElapsed(elapsed)}</span>`}
|
||||
</span>
|
||||
${navigableRunID ? '<span class="thinking-op-arrow">→</span>' : ''}
|
||||
</div>
|
||||
${preview ? `<div class="thinking-op-preview">${escapeHTML(preview.length > 180 ? preview.slice(0, 180) + '…' : preview)}</div>` : ''}
|
||||
${result ? `<div class="thinking-op-result">${escapeHTML(result.length > 180 ? result.slice(0, 180) + '…' : result)}</div>` : ''}
|
||||
${(thinkingToks || totalToks) ? `<div class="thinking-op-tokens">${thinkingToks ? `<span class="thinking-tok-badge">🧠 ${formatTokenCount(thinkingToks)} thinking</span>` : ''}${totalToks ? `<span class="thinking-tok-badge">⚡ ${formatTokenCount(totalToks)} total</span>` : ''}</div>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderAgentsLive() {
|
||||
const contentEl = document.getElementById('agents-content');
|
||||
if (!contentEl) return;
|
||||
|
||||
const agentKeys = getSortedAgentKeys();
|
||||
const selectedKey = ensureSelectedAgentKey();
|
||||
if (!selectedKey || agentKeys.length === 0) {
|
||||
contentEl.innerHTML = '<p class="empty-state">No recent agent activity</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = agentsState.agents[selectedKey];
|
||||
const summary = getAgentLiveSummary(selected);
|
||||
const recent = selected.events.slice(-80).reverse();
|
||||
const runGroups = groupAgentEventsByRun(recent);
|
||||
|
||||
contentEl.innerHTML = `
|
||||
<div class="agents-live-layout">
|
||||
<aside class="agents-live-sidebar">
|
||||
<div class="section-title">Agents</div>
|
||||
<div class="agent-picker" id="agent-picker">
|
||||
${agentKeys.map(key => {
|
||||
const agent = agentsState.agents[key];
|
||||
const active = key === selectedKey ? ' active' : '';
|
||||
const online = isAgentOnline(agent) ? 'online' : 'offline';
|
||||
const sessions = Object.keys(agent.sessions).length;
|
||||
const ops = getAgentDisplayOps(agent).length;
|
||||
return `
|
||||
<button class="agent-picker-item${active}" data-agent-key="${escapeHTML(key)}" type="button">
|
||||
<span class="agent-picker-dot ${online}"></span>
|
||||
<span class="agent-picker-main">
|
||||
<span class="agent-picker-name">${escapeHTML(getAgentLabel(agent))}</span>
|
||||
<span class="agent-picker-meta">${escapeHTML(agent.framework || 'unknown')} · ${sessions} sessions · ${ops} ops</span>
|
||||
</span>
|
||||
</button>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</aside>
|
||||
<section class="agents-live-main">
|
||||
<div class="agents-live-header">
|
||||
<div>
|
||||
<div class="agent-lane-name"><span class="agent-lane-dot ${isAgentOnline(selected) ? 'online' : 'offline'}"></span>${escapeHTML(getAgentLabel(selected))}</div>
|
||||
<div class="agent-lane-meta">${escapeHTML(selected.framework || 'unknown')}${selected.host && selected.host !== selected.name ? ' · ' + escapeHTML(selected.host) : ''}</div>
|
||||
</div>
|
||||
<div class="agents-live-badges">
|
||||
<span class="agents-live-badge">${summary.sessionIDs.length} sessions</span>
|
||||
<span class="agents-live-badge">${summary.activeSubagents.length} subagents</span>
|
||||
<span class="agents-live-badge">${summary.activeTools.length} tools</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="agents-live-cards">
|
||||
<div class="agents-live-card thinking-stream-card">
|
||||
<div class="agents-live-card-title">
|
||||
Live Operations
|
||||
${summary.activeOps.length > 0 ? `<span class="live-indicator" style="margin-left:0.5rem"><span class="live-dot"></span>${summary.activeOps.length} active</span>` : ''}
|
||||
</div>
|
||||
<div class="thinking-stream" id="thinking-stream-${escapeHTML(selectedKey)}">
|
||||
${renderThinkingStream(selected)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="agents-live-card">
|
||||
<div class="agents-live-card-title">Last Run</div>
|
||||
<div class="live-kv"><span>Status</span><strong>${escapeHTML(summary.latestRunStatus || '—')}</strong></div>
|
||||
<div class="live-kv"><span>Model</span><strong>${escapeHTML(summary.latestModel || '—')}</strong></div>
|
||||
<div class="live-kv"><span>Tokens</span><strong>${escapeHTML(formatTokenCount(summary.latestUsage ? summary.latestUsage.total_tokens : null))}</strong></div>
|
||||
<div class="live-kv"><span>Thinking</span><strong class="thinking-toks">${escapeHTML(formatTokenCount(summary.latestUsage ? summary.latestUsage.thinking_tokens : null))}</strong></div>
|
||||
<div class="live-kv"><span>Cost</span><strong>${escapeHTML(formatCost(summary.latestUsage ? summary.latestUsage.total_cost : null))}</strong></div>
|
||||
${summary.latestError ? `<div class="live-kv error-kv"><span>Error</span><strong>${escapeHTML(summary.latestError)}</strong></div>` : ''}
|
||||
</div>
|
||||
<div class="agents-live-card">
|
||||
<div class="agents-live-card-title">Context Window</div>
|
||||
<div class="live-kv"><span>Input</span><strong>${escapeHTML(formatTokenCount(summary.latestContextWindow ? summary.latestContextWindow.input_tokens : null))}</strong></div>
|
||||
<div class="live-kv"><span>Output</span><strong>${escapeHTML(formatTokenCount(summary.latestContextWindow ? summary.latestContextWindow.output_tokens : null))}</strong></div>
|
||||
<div class="live-kv"><span>Used</span><strong>${escapeHTML(formatTokenCount(summary.latestContextWindow ? summary.latestContextWindow.used_tokens : null))}</strong></div>
|
||||
<div class="live-kv"><span>Remaining</span><strong>${escapeHTML(formatTokenCount(summary.latestContextWindow ? summary.latestContextWindow.tokens_remaining : null))}</strong></div>
|
||||
${summary.latestContextWindow && summary.latestContextWindow.max_tokens ? `
|
||||
<div class="context-bar">
|
||||
<div class="context-bar-fill" style="width:${Math.min(100, ((summary.latestContextWindow.used_tokens || 0) / summary.latestContextWindow.max_tokens * 100)).toFixed(1)}%"></div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="agents-live-timeline">
|
||||
${runGroups.length > 0 ? runGroups.map(group => `
|
||||
<section class="live-run-group">
|
||||
<div class="live-run-group-header">
|
||||
<div class="live-run-group-title">${escapeHTML(getRunGroupLabel(group.runID, group.events))}</div>
|
||||
<div class="live-run-group-meta">
|
||||
<span>${escapeHTML(group.runID ? `run ${group.runID.slice(0, 12)}...` : 'session-only')}</span>
|
||||
<span>${escapeHTML(group.subagents.size > 0 ? `${group.subagents.size} subagents` : '0 subagents')}</span>
|
||||
<span>${escapeHTML(group.tools.size > 0 ? `${group.tools.size} tools` : '0 tools')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="live-run-events">
|
||||
${group.events.map(evt => `
|
||||
<div class="timeline-event live-event">
|
||||
<div class="timeline-event-header">
|
||||
${getEventIcon(getEnvelopeType(evt))}
|
||||
<span class="timeline-event-type">${escapeHTML(getEventLabel(getEnvelopeType(evt)))}</span>
|
||||
<span class="timeline-event-time">${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())}</span>
|
||||
</div>
|
||||
${getEventBody(evt)}
|
||||
<div class="live-detail-grid">${buildLiveEventContext(evt)}</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</section>
|
||||
`).join('') : '<p class="empty-state">No recent activity</p>'}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
|
||||
contentEl.querySelectorAll('[data-agent-key]').forEach(button => {
|
||||
button.addEventListener('click', () => selectAgent(button.dataset.agentKey || '', 'live'));
|
||||
});
|
||||
|
||||
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', '<span class="active-op-stale">(stale?)</span>');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addAgentEvents(events) {
|
||||
let changed = false;
|
||||
|
||||
for (const evt of events) {
|
||||
const id = getRecordID(evt);
|
||||
const agent = getAgentBucket(evt);
|
||||
if (!id || !agent || agent.eventIDs.has(id)) continue;
|
||||
processAgentEvent(evt);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
for (const agent of Object.values(agentsState.agents)) {
|
||||
agent.events.sort((a, b) => new Date(getEnvelopeTS(a)).getTime() - new Date(getEnvelopeTS(b)).getTime());
|
||||
}
|
||||
recomputeAgentStats();
|
||||
}
|
||||
}
|
||||
|
||||
function recomputeAgentStats() {
|
||||
const stats = { messages: 0, tools: 0, errors: 0, toolCounts: {} };
|
||||
|
||||
for (const agent of Object.values(agentsState.agents)) {
|
||||
for (const evt of agent.events) {
|
||||
const eventType = getEnvelopeType(evt);
|
||||
const attrs = getEnvelopeAttributes(evt);
|
||||
|
||||
if (eventType === 'run.start' || eventType === 'run.end') stats.messages++;
|
||||
if (eventType === 'span.end' && attrs.span_kind === 'tool') {
|
||||
stats.tools++;
|
||||
const toolName = attrs.name || 'unknown';
|
||||
stats.toolCounts[toolName] = (stats.toolCounts[toolName] || 0) + 1;
|
||||
}
|
||||
if (eventType === 'error') stats.errors++;
|
||||
}
|
||||
}
|
||||
|
||||
agentsState.stats = stats;
|
||||
}
|
||||
|
||||
function 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 = `
|
||||
<div class="page-header">
|
||||
<h2>Agents <span class="live-indicator"><span class="live-dot"></span>Live</span></h2>
|
||||
</div>
|
||||
<div class="agents-toolbar">
|
||||
<div class="view-toggle" id="agents-view-toggle">
|
||||
<button class="view-toggle-btn active" data-mode="overview" type="button">Overview</button>
|
||||
<button class="view-toggle-btn" data-mode="live" type="button">Live</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="agents-summary-row" id="agents-summary"></div>
|
||||
<div id="agents-content">${agentsSkeleton()}</div>
|
||||
`;
|
||||
|
||||
bindAgentViewToggle();
|
||||
|
||||
try {
|
||||
const [snapshots, events, summaryData] = await Promise.all([
|
||||
api('/v1/events?event_type=openclaw.snapshot&limit=100').catch(() => ({ events: [] })),
|
||||
api('/v1/events?limit=300'),
|
||||
api('/v1/stats/summary').catch(() => null),
|
||||
]);
|
||||
|
||||
if ((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 =
|
||||
`<p class="empty-state">Error loading agent activity: ${escapeHTML(e.message)}</p>`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 `<span class="uptime-badge ${cls}">${pct.toFixed(0)}% / 24h</span>`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<div class="service-card-header">
|
||||
<div>
|
||||
<div class="service-card-name">${escapeHTML(svc.name)}${uptimeBadge ? ' ' + uptimeBadge : ''}</div>
|
||||
<div class="service-role-tag">${escapeHTML(svc.role || '')}</div>
|
||||
</div>
|
||||
<span class="service-badge ${escapeHTML(svc.status || 'down')}">${escapeHTML(svc.status || 'down')}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function serviceStatRow(label, value, valueClass) {
|
||||
return `
|
||||
<div class="service-stat-row">
|
||||
<span class="service-stat-label">${escapeHTML(label)}</span>
|
||||
<span class="service-stat-value${valueClass ? ' ' + valueClass : ''}">${value}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderVMCard(name) {
|
||||
const evt = openclawState.instances[name];
|
||||
const payload = getEnvelopePayload(evt);
|
||||
const inst = payload.instance || {};
|
||||
const host = payload.host || {};
|
||||
const guest = payload.guest;
|
||||
const issues = payload.issues;
|
||||
|
||||
return `
|
||||
<div class="vm-card">
|
||||
<div class="vm-card-header">
|
||||
<h3>${escapeHTML(inst.name || name)}</h3>
|
||||
<div class="vm-status ${host.state === 'running' ? 'running' : 'stopped'}">
|
||||
${host.state === 'running' ? 'Running' : 'Stopped'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="vm-updated"><span class="freshness-timer" data-ts="${escapeHTML(getEnvelopeTS(evt))}">Updated ${escapeHTML(relativeTime(getEnvelopeTS(evt)))}</span></div>
|
||||
<table class="vm-stats">
|
||||
<tr><td>Host</td><td>${escapeHTML(inst.host || '-')}</td></tr>
|
||||
<tr><td>Domain</td><td>${escapeHTML(inst.domain || '-')}</td></tr>
|
||||
<tr><td>vCPUs</td><td>${host.vcpus || '-'}</td></tr>
|
||||
<tr><td>Memory</td><td>${escapeHTML(formatBytes(host.memory_kib ? host.memory_kib * 1024 : 0) || '-')}</td></tr>
|
||||
<tr><td>Disk</td><td>${escapeHTML(formatBytes(host.disk_actual_bytes) || '-')}</td></tr>
|
||||
<tr><td>Autostart</td><td>${host.autostart ? 'Yes' : 'No'}</td></tr>
|
||||
</table>
|
||||
${guest ? `
|
||||
<div class="vm-card-divider"></div>
|
||||
<table class="vm-stats">
|
||||
<tr><td>Gateway</td><td style="${guest.service_active ? 'color:var(--success)' : 'color:var(--error)'}">${guest.service_active ? 'Active' : 'Inactive'}</td></tr>
|
||||
<tr><td>HTTP</td><td style="${guest.http_status === 200 ? 'color:var(--success)' : 'color:var(--error)'}">${guest.http_status || 'N/A'}</td></tr>
|
||||
<tr><td>Version</td><td>${escapeHTML(guest.version || '-')}</td></tr>
|
||||
<tr><td>Guest Mem</td><td>${guest.memory_percent !== undefined ? guest.memory_percent.toFixed(1) : '-'}%</td></tr>
|
||||
<tr><td>Guest Disk</td><td>${guest.disk_percent !== undefined ? guest.disk_percent.toFixed(1) : '-'}%</td></tr>
|
||||
<tr><td>Load</td><td>${guest.load_average !== undefined ? guest.load_average.toFixed(2) : '-'}</td></tr>
|
||||
<tr><td>Uptime</td><td>${escapeHTML(guest.service_uptime || '-')}</td></tr>
|
||||
</table>
|
||||
` : ''}
|
||||
${issues && Object.values(issues).some(Boolean) ? `
|
||||
<div class="vm-card-divider"></div>
|
||||
<div class="vm-issues-label">Issues</div>
|
||||
<div class="vm-issues">
|
||||
${Object.entries(issues).filter(([, value]) => value).map(([key]) => `
|
||||
<span class="issue ${escapeHTML(key)}">${escapeHTML(key.replace(/_/g, ' '))}</span>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderLLMProxyCard(svc) {
|
||||
const extra = svc.extra || {};
|
||||
const modelCount = extra.model_count;
|
||||
const cooldowns = extra.cooldown_count || 0;
|
||||
const httpStatus = svc.http_status;
|
||||
const httpClass = httpStatus === 200 ? 'ok' : httpStatus ? 'bad' : '';
|
||||
|
||||
return `
|
||||
<div class="service-card">
|
||||
${serviceCardHeader(svc)}
|
||||
<div style="display:flex;align-items:baseline;gap:0.5rem">
|
||||
<span class="llm-model-count">${modelCount !== undefined ? modelCount : '-'}</span>
|
||||
<span class="llm-model-label">models</span>
|
||||
</div>
|
||||
${cooldowns > 0 ? `<div class="llm-cooldown-banner">⚠ ${cooldowns} model${cooldowns > 1 ? 's' : ''} in cooldown</div>` : ''}
|
||||
<div class="service-stats">
|
||||
${serviceStatRow('HTTP', httpStatus ? String(httpStatus) : '-', httpClass)}
|
||||
${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
|
||||
${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDBCard(svc) {
|
||||
const healthClass = svc.health_state === 'healthy' ? 'ok' : svc.health_state === 'unhealthy' ? 'bad' : '';
|
||||
return `
|
||||
<div class="service-card">
|
||||
${serviceCardHeader(svc)}
|
||||
<div class="service-stats">
|
||||
${serviceStatRow('Health', escapeHTML(svc.health_state || 'none'), healthClass)}
|
||||
${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
|
||||
${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSearchCard(svc) {
|
||||
const extra = svc.extra || {};
|
||||
const ms = extra.response_ms;
|
||||
const httpStatus = svc.http_status;
|
||||
const httpClass = httpStatus === 200 ? 'ok' : httpStatus ? 'bad' : '';
|
||||
return `
|
||||
<div class="service-card">
|
||||
${serviceCardHeader(svc)}
|
||||
<div class="service-stats">
|
||||
${serviceStatRow('HTTP', httpStatus ? String(httpStatus) : '-', httpClass)}
|
||||
${ms !== undefined ? serviceStatRow('Response', ms + 'ms', ms < 500 ? 'ok' : 'warn') : ''}
|
||||
${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMCPCard(svc) {
|
||||
const extra = svc.extra || {};
|
||||
const reachable = extra.port_reachable;
|
||||
return `
|
||||
<div class="service-card">
|
||||
${serviceCardHeader(svc)}
|
||||
<div class="service-stats">
|
||||
${reachable !== undefined ? serviceStatRow('Port', reachable ? 'reachable' : 'unreachable', reachable ? 'ok' : 'bad') : ''}
|
||||
${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')}
|
||||
${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderVoiceCard(svc) {
|
||||
const healthClass = svc.health_state === 'healthy' ? 'ok' : svc.health_state === 'unhealthy' ? 'bad' : '';
|
||||
return `
|
||||
<div class="service-card">
|
||||
${serviceCardHeader(svc)}
|
||||
<div class="service-stats">
|
||||
${serviceStatRow('Health', escapeHTML(svc.health_state || 'none'), healthClass)}
|
||||
${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')}
|
||||
${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAutomationCard(svc) {
|
||||
const healthClass = svc.health_state === 'healthy' ? 'ok' : svc.health_state === 'unhealthy' ? 'bad' : '';
|
||||
return `
|
||||
<div class="service-card">
|
||||
${serviceCardHeader(svc)}
|
||||
<div class="service-stats">
|
||||
${serviceStatRow('Health', escapeHTML(svc.health_state || 'none'), healthClass)}
|
||||
${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')}
|
||||
${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAPICard(svc) {
|
||||
const httpStatus = svc.http_status;
|
||||
const httpClass = httpStatus === 200 ? 'ok' : httpStatus ? 'bad' : '';
|
||||
return `
|
||||
<div class="service-card">
|
||||
${serviceCardHeader(svc)}
|
||||
<div class="service-stats">
|
||||
${serviceStatRow('HTTP', httpStatus ? String(httpStatus) : '-', httpClass)}
|
||||
${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
|
||||
${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderWorkerCard(svc) {
|
||||
return `
|
||||
<div class="service-card">
|
||||
${serviceCardHeader(svc)}
|
||||
<div class="service-stats">
|
||||
${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')}
|
||||
${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderGenericServiceCard(svc) {
|
||||
return `
|
||||
<div class="service-card">
|
||||
${serviceCardHeader(svc)}
|
||||
<div class="service-stats">
|
||||
${serviceStatRow('Container', escapeHTML(svc.container_state || '-'), svc.container_state === 'running' ? 'ok' : 'bad')}
|
||||
${serviceStatRow('Uptime', formatUptime(svc.uptime_sec), '')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function 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 `
|
||||
<div class="service-card">
|
||||
${serviceCardHeader(svc)}
|
||||
<div class="service-stats">
|
||||
${serviceStatRow('Endpoint', escapeHTML(svc.endpoint || '-'), '')}
|
||||
${serviceStatRow('Bucket', escapeHTML(svc.bucket ? `${svc.bucket}/${svc.prefix || ''}` : '-'), '')}
|
||||
${serviceStatRow('Usage', escapeHTML(formatBytes(svc.totalBytes) || '-'), '')}
|
||||
${serviceStatRow('Objects', escapeHTML(svc.objectCount !== undefined ? String(svc.objectCount) : '-'), '')}
|
||||
${serviceStatRow('HTTP', svc.httpStatus ? String(svc.httpStatus) : '-', httpClass)}
|
||||
${serviceStatRow('Source', escapeHTML(svc.sourceInstance || '-'), '')}
|
||||
${serviceStatRow('Latest', escapeHTML(svc.latestBackup ? relativeTime(svc.latestBackup) : '-'), '')}
|
||||
${svc.error ? serviceStatRow('Error', escapeHTML(svc.error), 'bad') : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderInfraGrid() {
|
||||
const vmNames = Object.keys(openclawState.instances).sort();
|
||||
const allServices = Object.values(swarmState.services);
|
||||
const agentmonServices = allServices.filter(s => s.group === 'agentmon');
|
||||
const swarmServices = allServices.filter(s => s.group !== 'agentmon');
|
||||
const homelabServices = getK8sHomelabServices();
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div class="page-header-row">
|
||||
<h2>Infrastructure <span class="live-indicator"><span class="live-dot"></span>Live</span></h2>
|
||||
<button class="refresh-btn" id="infra-refresh-btn" type="button" title="Refresh infrastructure data">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13.5 2.5A7 7 0 1 0 14 9"/><polyline points="14 2.5 14 6 10.5 6"/></svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="infra-section">
|
||||
<p class="infra-section-title">VMs</p>
|
||||
${vmNames.length === 0
|
||||
? '<p class="empty-state">No VM data</p>'
|
||||
: `<div class="vm-grid">${vmNames.map(name => renderVMCard(name)).join('')}</div>`
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="infra-section">
|
||||
<p class="infra-section-title">Swarm Services</p>
|
||||
${swarmServices.length === 0
|
||||
? '<p class="empty-state">No swarm service data</p>'
|
||||
: `<div class="service-grid">${swarmServices.map(svc => renderServiceCard(svc)).join('')}</div>`
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="infra-section">
|
||||
<p class="infra-section-title">K8s Homelab</p>
|
||||
${homelabServices.length === 0
|
||||
? '<p class="empty-state">No k8s homelab service data</p>'
|
||||
: `<div class="service-grid">${homelabServices.map(svc => renderHomelabServiceCard(svc)).join('')}</div>`
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="infra-section">
|
||||
<p class="infra-section-title">Agentmon</p>
|
||||
${agentmonServices.length === 0
|
||||
? '<p class="empty-state">No agentmon service data</p>'
|
||||
: `<div class="service-grid">${agentmonServices.map(svc => renderServiceCard(svc)).join('')}</div>`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 = `<div class="page-header"><h2>Infrastructure</h2></div><div style="margin-top:1rem">${infrastructureSkeleton()}</div>`;
|
||||
|
||||
infraUnsubscribe = subscribeWS(handleInfraWS);
|
||||
|
||||
try {
|
||||
await loadInfrastructureSnapshots();
|
||||
if (routeToken && !isRouteCurrent(routeToken)) return;
|
||||
|
||||
if (isCurrentPath('/infrastructure')) {
|
||||
renderInfraGrid();
|
||||
}
|
||||
} catch (e) {
|
||||
if (isCurrentPath('/infrastructure')) {
|
||||
app.innerHTML = `<div class="page-header"><h2>Infrastructure</h2></div><p class="empty-state">Error: ${escapeHTML(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(`<div class="span-kv"><span class="span-kv-key">Input</span><pre class="span-kv-val span-kv-raw">${escapeHTML(inputStr)}</pre></div>`);
|
||||
}
|
||||
if (inner.result_preview !== undefined) {
|
||||
parts.push(`<div class="span-kv"><span class="span-kv-key">Result</span><pre class="span-kv-val span-kv-raw">${escapeHTML(String(inner.result_preview))}</pre></div>`);
|
||||
}
|
||||
} else if (sp.kind === 'agent') {
|
||||
if (inner.prompt_preview) {
|
||||
parts.push(`<div class="span-kv"><span class="span-kv-key">Prompt</span><pre class="span-kv-val span-kv-raw">${escapeHTML(String(inner.prompt_preview))}</pre></div>`);
|
||||
}
|
||||
if (inner.usage) {
|
||||
const u = inner.usage;
|
||||
const tokens = [
|
||||
u.total_tokens != null ? `${u.total_tokens} total` : null,
|
||||
u.input_tokens != null ? `${u.input_tokens} in` : null,
|
||||
u.output_tokens != null ? `${u.output_tokens} out` : null,
|
||||
].filter(Boolean).join(' · ');
|
||||
if (tokens) parts.push(`<div class="span-kv"><span class="span-kv-key">Tokens</span><span class="span-kv-val">${escapeHTML(tokens)}</span></div>`);
|
||||
if (u.total_cost != null) {
|
||||
parts.push(`<div class="span-kv"><span class="span-kv-key">Cost</span><span class="span-kv-val">${escapeHTML(formatCost(u.total_cost))}</span></div>`);
|
||||
}
|
||||
}
|
||||
if (inner.model) {
|
||||
parts.push(`<div class="span-kv"><span class="span-kv-key">Model</span><span class="span-kv-val">${escapeHTML(String(inner.model))}</span></div>`);
|
||||
}
|
||||
} else {
|
||||
const raw = Object.keys(inner).length > 0 ? inner : (Object.keys(outer).length > 0 ? outer : null);
|
||||
if (raw) {
|
||||
parts.push(`<pre class="span-kv-raw">${escapeHTML(JSON.stringify(raw, null, 2))}</pre>`);
|
||||
}
|
||||
}
|
||||
|
||||
if (sp.duration_ms != null) {
|
||||
parts.push(`<div class="span-kv"><span class="span-kv-key">Duration</span><span class="span-kv-val">${escapeHTML(formatDuration(sp.duration_ms))}</span></div>`);
|
||||
}
|
||||
|
||||
return parts.length > 0
|
||||
? parts.join('')
|
||||
: '<span style="font-size:0.75rem;color:var(--text-dim)">No payload data</span>';
|
||||
}
|
||||
|
||||
function renderTimescale(totalMS) {
|
||||
const ticks = 5;
|
||||
return Array.from({ length: ticks + 1 }, (_, i) => {
|
||||
const pct = (i / ticks * 100).toFixed(0);
|
||||
return `<span style="left:${pct}%">${escapeHTML(formatDuration(totalMS * i / ticks))}</span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderSpanWaterfall(spans, runStartedAt, runDurationMS) {
|
||||
if (!spans || spans.length === 0) return '<p class="empty-state">No spans</p>';
|
||||
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 `
|
||||
<div class="waterfall">
|
||||
<div class="waterfall-header">
|
||||
<div class="waterfall-name-col">Span</div>
|
||||
<div class="waterfall-bar-col">
|
||||
<div class="waterfall-timescale">${renderTimescale(totalMS)}</div>
|
||||
</div>
|
||||
</div>
|
||||
${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 `
|
||||
<div class="waterfall-row">
|
||||
<div class="waterfall-name-col">
|
||||
<span class="span-kind-badge ${escapeHTML(kindClass)}">${escapeHTML(sp.kind || '?')}</span>
|
||||
<span class="waterfall-name" title="${escapeHTML(sp.name || '')}">${escapeHTML((sp.name || '(unnamed)').slice(0, 40))}</span>
|
||||
</div>
|
||||
<div class="waterfall-bar-col">
|
||||
<div class="waterfall-bar-track">
|
||||
<div class="waterfall-bar${statusClass}" style="left:${leftPct}%;width:${widthPct}%" title="${escapeHTML(formatDuration(spDur))}">
|
||||
<span class="waterfall-bar-label">${spDur > totalMS * 0.05 ? escapeHTML(formatDuration(spDur)) : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderRunSpansRows(spans) {
|
||||
if (!spans || spans.length === 0) {
|
||||
return '<tr><td colspan="4" class="empty-state">No spans</td></tr>';
|
||||
}
|
||||
return spans.map((sp, i) => {
|
||||
const kindClass = sp.kind || 'unknown';
|
||||
return `
|
||||
<tr class="expandable run-span-row ${sp.status === 'error' ? 'tr-error' : ''}" data-index="${i}">
|
||||
<td>
|
||||
<span class="expand-icon"></span>
|
||||
<span class="span-kind-badge ${escapeHTML(kindClass)}">${escapeHTML(sp.kind || '?')}</span>
|
||||
${escapeHTML(sp.name || '(unnamed)')}
|
||||
</td>
|
||||
<td>${escapeHTML(sp.kind || '-')}</td>
|
||||
<td>${statusIcon(sp.status)}</td>
|
||||
<td>${escapeHTML(formatDuration(sp.duration_ms))}</td>
|
||||
</tr>
|
||||
<tr class="span-detail-row" data-index="${i}" style="display:none">
|
||||
<td colspan="4">
|
||||
<div class="span-details-structured">${renderSpanPayload(sp)}</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderRunSpansTable(spans) {
|
||||
return `
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Kind</th>
|
||||
<th>Status</th>
|
||||
<th>Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="spans-body">
|
||||
${renderRunSpansRows(spans)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 = `<div class="run-live-ops-inner">${ops.map(op => {
|
||||
const elapsed = Math.floor((Date.now() - op.startedAt) / 1000);
|
||||
const isSubagent = op.kind === 'agent' || op.subType === 'subagent';
|
||||
const icon = isSubagent ? '◎' : op.kind === 'run' ? '◌' : '▸';
|
||||
const label = isSubagent ? 'subagent' : op.kind === 'run' ? 'thinking' : 'tool';
|
||||
const preview = op.promptPreview || op.inputPreview || '';
|
||||
return `
|
||||
<div class="run-live-op-pill ${label}">
|
||||
<span class="run-live-op-spin">${icon}</span>
|
||||
<span class="run-live-op-name">${escapeHTML(op.name)}</span>
|
||||
${preview ? `<span class="run-live-op-preview">${escapeHTML(preview.length > 60 ? preview.slice(0, 60) + '…' : preview)}</span>` : ''}
|
||||
<span class="run-live-op-time active-op-time" data-start="${op.startedAt}">${formatElapsed(elapsed)}</span>
|
||||
</div>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
|
||||
function handleRunWS(runID, msg) {
|
||||
if (msg.type !== 'message') return;
|
||||
const correlation = getEnvelopeCorrelation(msg.data);
|
||||
if (correlation?.run_id !== runID) return;
|
||||
|
||||
// Track live ops from WS without full reload
|
||||
const eventType = getEnvelopeType(msg.data);
|
||||
const attrs = getEnvelopeAttributes(msg.data);
|
||||
const payload = getEnvelopePayload(msg.data);
|
||||
const spanID = correlation.span_id;
|
||||
|
||||
if (eventType === 'span.start' && spanID) {
|
||||
runLiveOps[spanID] = {
|
||||
name: attrs.name || attrs.span_kind || 'span',
|
||||
kind: attrs.span_kind || '',
|
||||
subType: attrs.type || '',
|
||||
startedAt: Date.now(),
|
||||
promptPreview: payload.prompt_preview || '',
|
||||
inputPreview: payload.input ? (typeof payload.input === 'string' ? payload.input.slice(0, 100) : '') : '',
|
||||
};
|
||||
renderRunLiveOps();
|
||||
}
|
||||
if (eventType === 'span.end' && spanID) {
|
||||
delete runLiveOps[spanID];
|
||||
renderRunLiveOps();
|
||||
}
|
||||
if (eventType === 'run.start') {
|
||||
runLiveOps['__run__'] = {
|
||||
name: 'Thinking…',
|
||||
kind: 'run',
|
||||
startedAt: Date.now(),
|
||||
promptPreview: payload.prompt_preview || payload.message_preview || payload.message || '',
|
||||
inputPreview: '',
|
||||
};
|
||||
renderRunLiveOps();
|
||||
}
|
||||
if (eventType === 'run.end') {
|
||||
delete runLiveOps['__run__'];
|
||||
runLiveOps = {};
|
||||
renderRunLiveOps();
|
||||
}
|
||||
|
||||
clearTimeout(_runReloadTimer);
|
||||
_runReloadTimer = setTimeout(() => loadRunDetailData(runID), 500);
|
||||
}
|
||||
|
||||
async function loadRunDetailData(runID) {
|
||||
if (!isCurrentPath('/runs/' + runID)) return;
|
||||
try {
|
||||
const data = await api('/v1/runs/' + runID);
|
||||
const spans = data.spans || [];
|
||||
const r = data.run;
|
||||
const 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 = '<div style="padding:2rem"><div class="skeleton-line" style="width:40%;height:1.5rem;margin-bottom:1rem"></div><div class="skeleton-line" style="width:60%;margin-bottom:2rem"></div>' + '<div class="table-container"><table><thead><tr><th>Name</th><th>Kind</th><th>Status</th><th>Duration</th></tr></thead><tbody>' + skeletonRows(5, 4) + '</tbody></table></div></div>';
|
||||
runLiveOps = {};
|
||||
runSpansViewMode = 'table';
|
||||
let data;
|
||||
try {
|
||||
data = await api('/v1/runs/' + runID);
|
||||
} catch (e) {
|
||||
if (routeToken && !isRouteCurrent(routeToken)) return;
|
||||
app.innerHTML = `<p class="empty-state">Error loading run: ${escapeHTML(e.message)}</p>`;
|
||||
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 = `
|
||||
<a href="/sessions/${escapeHTML(r.session_id)}" class="back-link">← Back to Session</a>
|
||||
<div class="page-header">
|
||||
<h2>Run <span style="font-family:var(--font-mono);font-size:1.1rem;color:var(--accent)" title="${escapeHTML(runID)}">${escapeHTML(runID.substring(0, 16))}…</span>${renderCopyButton(runID)} ${statusIcon(r.status)}</h2>
|
||||
<div class="meta-tiles">
|
||||
<div class="meta-tile">
|
||||
<div class="meta-tile-label">Started</div>
|
||||
<div class="meta-tile-value">${escapeHTML(new Date(r.started_at).toLocaleString())}</div>
|
||||
</div>
|
||||
<div class="meta-tile">
|
||||
<div class="meta-tile-label">Duration</div>
|
||||
<div class="meta-tile-value" id="run-detail-duration">${escapeHTML(duration)}</div>
|
||||
</div>
|
||||
${r.model ? `<div class="meta-tile"><div class="meta-tile-label">Model</div><div class="meta-tile-value" style="font-size:0.78rem">${escapeHTML(r.model.replace(/^claude-/, ''))}</div></div>` : ''}
|
||||
${r.tool_count ? `<div class="meta-tile"><div class="meta-tile-label">Tool Calls</div><div class="meta-tile-value">${r.tool_count}</div></div>` : ''}
|
||||
${runUsage ? `<div class="meta-tile"><div class="meta-tile-label">Tokens</div><div class="meta-tile-value">${escapeHTML(formatTokenCount(runUsage.totalTokens))}</div>${(runUsage.inputTokens || runUsage.outputTokens) ? `<div class="meta-tile-sub">${escapeHTML(formatTokenCount(runUsage.inputTokens))} in · ${escapeHTML(formatTokenCount(runUsage.outputTokens))} out</div>` : ''}</div>` : ''}
|
||||
${runUsage ? `<div class="meta-tile"><div class="meta-tile-label">Cost</div><div class="meta-tile-value">${escapeHTML(formatCost(runUsage.totalCost))}</div></div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${!r.ended_at ? '<div class="run-live-ops" id="run-live-ops"></div>' : ''}
|
||||
<div class="section-title">
|
||||
Spans <span class="count" id="run-detail-span-count">${spans.length}</span>
|
||||
${!r.ended_at ? '<span class="live-indicator" style="margin-left:0.5rem"><span class="live-dot"></span>Live</span>' : ''}
|
||||
<div class="view-toggle" style="margin-left:auto">
|
||||
<button class="view-toggle-btn active" id="spans-view-table" type="button">Table</button>
|
||||
<button class="view-toggle-btn" id="spans-view-waterfall" type="button">Waterfall</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="spans-container">
|
||||
${renderRunSpansTable(spans)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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 '<tr><td colspan="7" class="empty-state">No runs</td></tr>';
|
||||
}
|
||||
|
||||
return runs.map((r, i) => {
|
||||
const runDuration = r.ended_at
|
||||
? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
|
||||
: '-';
|
||||
const modelLabel = r.model ? escapeHTML(r.model.replace(/^claude-/, '')) : '-';
|
||||
const spans = r.spans || [];
|
||||
const spansHTML = spans.length > 0 ? `
|
||||
<div class="session-run-spans">
|
||||
${spans.map(sp => {
|
||||
const body = getSessionSpanSummary(sp);
|
||||
return `
|
||||
<div class="session-span-pill ${escapeHTML(sp.kind || 'unknown')}">
|
||||
<span class="session-span-name">${escapeHTML(sp.name || sp.kind || 'span')}</span>
|
||||
<span class="session-span-meta">${escapeHTML(body)}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
` : '<div class="empty-state" style="padding:0.5rem 0">No spans yet</div>';
|
||||
|
||||
return `
|
||||
<tr class="clickable expandable-run ${r.status === 'error' ? 'tr-error' : ''}" data-run="${escapeHTML(r.run_id)}" data-index="${i}">
|
||||
<td class="id-cell" title="${escapeHTML(r.run_id)}"><span class="expand-icon"></span>${escapeHTML(r.run_id.substring(0, 12))}...${renderCopyButton(r.run_id)}</td>
|
||||
<td>${statusIcon(r.status)}</td>
|
||||
<td><span class="model-badge">${modelLabel}</span></td>
|
||||
<td>${r.tool_count || 0}</td>
|
||||
<td>${r.span_count}</td>
|
||||
<td>${escapeHTML(runDuration)}</td>
|
||||
<td>${escapeHTML(new Date(r.started_at).toLocaleTimeString())}</td>
|
||||
</tr>
|
||||
<tr class="span-detail-row" data-index="${i}" style="display:none">
|
||||
<td colspan="7">
|
||||
<div class="session-run-detail">
|
||||
<div class="section-title" style="margin-bottom:0.5rem">Spans <span class="count">${spans.length}</span></div>
|
||||
${spansHTML}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function getSessionSpanSummary(sp) {
|
||||
const payload = sp.payload || {};
|
||||
const innerPayload = payload.payload || {};
|
||||
if (sp.kind === 'tool') {
|
||||
const result = innerPayload.result_preview || '';
|
||||
const duration = sp.duration_ms !== undefined && sp.duration_ms !== null ? formatDuration(sp.duration_ms) : '-';
|
||||
return result ? `${duration} · ${String(result).slice(0, 80)}` : duration;
|
||||
}
|
||||
if (sp.kind === 'agent') {
|
||||
const usage = innerPayload.usage || {};
|
||||
const totalTokens = usage.total_tokens !== undefined ? `${usage.total_tokens} tok` : '';
|
||||
const duration = sp.duration_ms !== undefined && sp.duration_ms !== null ? formatDuration(sp.duration_ms) : '-';
|
||||
return totalTokens ? `${duration} · ${totalTokens}` : duration;
|
||||
}
|
||||
return sp.duration_ms !== undefined && sp.duration_ms !== null ? formatDuration(sp.duration_ms) : '-';
|
||||
}
|
||||
|
||||
function bindSessionRunRows() {
|
||||
document.querySelectorAll('tr.expandable-run').forEach(row => {
|
||||
row.addEventListener('click', event => {
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
navigate('/runs/' + row.dataset.run);
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = row.dataset.index;
|
||||
const detailRow = document.querySelector(`tr.span-detail-row[data-index="${idx}"]`);
|
||||
const icon = row.querySelector('.expand-icon');
|
||||
if (!detailRow) return;
|
||||
|
||||
if (detailRow.style.display === 'none') {
|
||||
detailRow.style.display = 'table-row';
|
||||
if (icon) icon.style.transform = 'rotate(45deg)';
|
||||
} else {
|
||||
detailRow.style.display = 'none';
|
||||
if (icon) icon.style.transform = '';
|
||||
}
|
||||
});
|
||||
|
||||
row.addEventListener('dblclick', () => navigate('/runs/' + row.dataset.run));
|
||||
row.setAttribute('tabindex', '0');
|
||||
row.setAttribute('role', 'button');
|
||||
row.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
row.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 = `
|
||||
<a href="/sessions" class="back-link">← Back to Sessions</a>
|
||||
<div class="page-header">
|
||||
<h2>Session <span style="font-family:var(--font-mono);font-size:1.1rem;color:var(--accent)" title="${escapeHTML(sessionID)}">${escapeHTML(sessionID.substring(0, 16))}...</span>${renderCopyButton(sessionID)}</h2>
|
||||
<div class="session-status-line">
|
||||
<span class="fw-dot ${escapeHTML((s.framework || 'unknown').replace(/[^a-z0-9-]/g, '-'))} ${active ? 'active' : 'ended'}"></span>
|
||||
<span class="session-status-text">${active ? 'Active' : 'Ended'}</span>
|
||||
</div>
|
||||
<div class="meta-tiles">
|
||||
<div class="meta-tile">
|
||||
<div class="meta-tile-label">Started</div>
|
||||
<div class="meta-tile-value">${escapeHTML(new Date(s.started_at).toLocaleString())}</div>
|
||||
</div>
|
||||
<div class="meta-tile">
|
||||
<div class="meta-tile-label">Framework</div>
|
||||
<div class="meta-tile-value">${escapeHTML(s.framework || '-')}</div>
|
||||
</div>
|
||||
<div class="meta-tile">
|
||||
<div class="meta-tile-label">Host</div>
|
||||
<div class="meta-tile-value">${escapeHTML(s.host || '-')}</div>
|
||||
</div>
|
||||
<div class="meta-tile">
|
||||
<div class="meta-tile-label">Duration</div>
|
||||
<div class="meta-tile-value">${escapeHTML(duration)}</div>
|
||||
</div>
|
||||
${sessionTotalTokens > 0 ? `<div class="meta-tile"><div class="meta-tile-label">Total Tokens</div><div class="meta-tile-value">${escapeHTML(formatTokenCount(sessionTotalTokens))}</div></div>` : ''}
|
||||
${sessionTotalCost > 0 ? `<div class="meta-tile"><div class="meta-tile-label">Total Cost</div><div class="meta-tile-value">${escapeHTML(formatCost(sessionTotalCost))}</div></div>` : ''}
|
||||
${sessionTotalTools > 0 ? `<div class="meta-tile"><div class="meta-tile-label">Total Tools</div><div class="meta-tile-value">${sessionTotalTools}</div></div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-title">Runs <span class="count">${runs.length}</span></div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run ID</th>
|
||||
<th>Status</th>
|
||||
<th>Model</th>
|
||||
<th>Tools</th>
|
||||
<th>Spans</th>
|
||||
<th>Duration</th>
|
||||
<th>Started</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="session-runs-body">
|
||||
${renderSessionRunsRows(runs)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
bindSessionRunRows();
|
||||
|
||||
document.querySelector('.back-link').addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
navigate('/sessions');
|
||||
});
|
||||
|
||||
sessionDetailUnsubscribe = subscribeWS((msg) => handleSessionWS(sessionID, msg));
|
||||
}
|
||||
@@ -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 = '<tr><td colspan="6" class="empty-state">No sessions found</td></tr>';
|
||||
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 = `<span class="session-duration-bar" style="width:${barWidth.toFixed(0)}px"></span>`;
|
||||
const errorCell = (s._errorCount || 0) > 0
|
||||
? `<span class="error-count-badge">${s._errorCount}</span>`
|
||||
: '<span style="color:var(--text-dim)">—</span>';
|
||||
return `
|
||||
<tr class="${rowClass}" data-session="${escapeHTML(s.session_id)}">
|
||||
<td class="id-cell" title="${escapeHTML(s.session_id)}">${escapeHTML(s.session_id.substring(0, 12))}…${renderCopyButton(s.session_id)}</td>
|
||||
<td><span class="fw-dot ${escapeHTML(fwClass)} ${dotState}" title="${dotTitle}"></span>${escapeHTML(fw)}</td>
|
||||
<td>${escapeHTML(s.host || '-')}</td>
|
||||
<td>${s.run_count}</td>
|
||||
<td title="${escapeHTML(s.started_at)}">${escapeHTML(relativeTime(s.started_at))}${durationBar}</td>
|
||||
<td>${errorCell}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
return `<tr class="session-date-group"><td colspan="6">${escapeHTML(group.label)}</td></tr>${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
|
||||
? `<span class="error-count-badge">${s._errorCount}</span>`
|
||||
: '<span style="color:var(--text-dim)">—</span>';
|
||||
return `
|
||||
<td class="id-cell" title="${escapeHTML(s.session_id)}">${escapeHTML(s.session_id.substring(0, 12))}…${renderCopyButton(s.session_id)}</td>
|
||||
<td><span class="fw-dot ${escapeHTML(fwClass)} ${dotState}" title="${dotTitle}"></span>${escapeHTML(fw)}</td>
|
||||
<td>${escapeHTML(s.host || '-')}</td>
|
||||
<td>${s.run_count}</td>
|
||||
<td title="${escapeHTML(s.started_at)}">${escapeHTML(relativeTime(s.started_at))}</td>
|
||||
<td>${errorCell}</td>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateSessionTimers() {
|
||||
const tbody = document.getElementById('sessions-body');
|
||||
if (!tbody) return;
|
||||
sessionsState.sessions.forEach(s => {
|
||||
const row = tbody.querySelector(`[data-session="${s.session_id}"]`);
|
||||
if (row) {
|
||||
const td = row.cells[4];
|
||||
if (td) {
|
||||
// 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 = `
|
||||
<div class="page-header">
|
||||
<h2>Sessions</h2>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<label>From <input type="date" id="filter-from"></label>
|
||||
<label>To <input type="date" id="filter-to"></label>
|
||||
<label>Framework
|
||||
<select id="filter-framework">
|
||||
<option value="">All</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Host <input type="text" id="filter-host" placeholder="hostname"></label>
|
||||
</div>
|
||||
<div class="filter-pills" id="session-pills">
|
||||
<button class="filter-pill active" data-filter="all">All</button>
|
||||
<button class="filter-pill" data-filter="active">Active</button>
|
||||
<button class="filter-pill" data-filter="ended">Ended</button>
|
||||
<button class="filter-pill" data-filter="errored">With Errors</button>
|
||||
</div>
|
||||
<div class="pagination-info" id="pagination-info"></div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" data-sort="session_id">Session <span class="sort-icon"></span></th>
|
||||
<th class="sortable" data-sort="framework">Framework <span class="sort-icon"></span></th>
|
||||
<th class="sortable" data-sort="host">Host <span class="sort-icon"></span></th>
|
||||
<th class="sortable" data-sort="run_count">Runs <span class="sort-icon"></span></th>
|
||||
<th class="sortable" data-sort="started_at">Time <span class="sort-icon"></span></th>
|
||||
<th>Errors</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sessions-body">${sessionsSkeleton()}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button id="load-more" class="load-more" style="display:none">Load more</button>
|
||||
`;
|
||||
|
||||
// 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);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { app } from '../router.js';
|
||||
import { showToast } from '../api.js';
|
||||
|
||||
export async function renderSettings() {
|
||||
app.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h2>Settings</h2>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3 class="settings-section-title">Data Retention</h3>
|
||||
<p class="settings-section-desc">Delete events older than the specified number of days. This runs automatically every 24 hours. Currently configured via <code>RETENTION_DAYS</code> environment variable (default: 30 days).</p>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label" for="retention-days">Purge events older than</label>
|
||||
<div class="settings-input-group">
|
||||
<input type="number" id="retention-days" class="settings-input" min="1" max="365" value="30">
|
||||
<span class="settings-input-suffix">days</span>
|
||||
<button class="settings-btn" id="run-retention-btn" type="button">Run Now</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="retention-result" class="settings-result"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `<span class="settings-result-ok">Deleted ${data.deleted} events older than ${new Date(data.cutoff).toLocaleDateString()}.</span>`;
|
||||
showToast(`Deleted ${data.deleted} events`, 'success');
|
||||
} catch (e) {
|
||||
showToast('Retention failed: ' + e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Run Now';
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 = `
|
||||
<div class="page-header"><h2>Usage</h2></div>
|
||||
<div id="usage-content"><div class="usage-loading">Loading…</div></div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div class="usage-summary-tiles">
|
||||
<div class="meta-tile"><div class="meta-tile-label">Active Sessions</div><div class="meta-tile-value">${s.active_sessions || 0}</div></div>
|
||||
<div class="meta-tile"><div class="meta-tile-label">Runs Today</div><div class="meta-tile-value">${s.runs_today || 0}</div></div>
|
||||
<div class="meta-tile"><div class="meta-tile-label">Tool Calls Today</div><div class="meta-tile-value">${s.tool_calls_today || 0}</div></div>
|
||||
<div class="meta-tile"><div class="meta-tile-label">Errors Today</div><div class="meta-tile-value">${s.errors_today || 0}</div></div>
|
||||
<div class="meta-tile"><div class="meta-tile-label">Tokens Today</div><div class="meta-tile-value">${formatTokenCount(s.tokens_today || 0)}</div></div>
|
||||
<div class="meta-tile"><div class="meta-tile-label">Cost Today</div><div class="meta-tile-value">${formatCost(s.cost_today || 0)}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="usage-section-row">
|
||||
<div class="usage-panel">
|
||||
<div class="section-title">7-Day Totals</div>
|
||||
<div class="usage-7d-tiles">
|
||||
<div class="usage-7d-tile"><span class="usage-7d-label">Runs</span><strong>${totals7d.runs}</strong></div>
|
||||
<div class="usage-7d-tile"><span class="usage-7d-label">Tool Calls</span><strong>${totals7d.tools}</strong></div>
|
||||
<div class="usage-7d-tile"><span class="usage-7d-label">Errors</span><strong>${totals7d.errors}</strong></div>
|
||||
<div class="usage-7d-tile"><span class="usage-7d-label">Tokens</span><strong>${formatTokenCount(totals7d.tokens)}</strong></div>
|
||||
<div class="usage-7d-tile"><span class="usage-7d-label">Est. Cost</span><strong>${formatCost(totals7d.cost)}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="usage-section-row">
|
||||
<div class="usage-panel">
|
||||
<div class="section-title">Top Models <span class="count">${models.length}</span></div>
|
||||
${models.length === 0 ? '<p class="empty-state">No model data yet</p>' : `
|
||||
<ul class="stat-list" id="usage-models-list">
|
||||
${(() => {
|
||||
const max = models[0]?.count || 1;
|
||||
return models.map(m => {
|
||||
const pct = (m.count / max * 100).toFixed(1);
|
||||
return `<li>
|
||||
<div class="stat-list-header">
|
||||
<span class="stat-list-name">${escapeHTML(m.name)}</span>
|
||||
<span class="stat-list-count">${m.count}</span>
|
||||
</div>
|
||||
<div class="stat-list-bar-track">
|
||||
<div class="stat-list-bar-fill model" style="width:${pct}%"></div>
|
||||
</div>
|
||||
</li>`;
|
||||
}).join('');
|
||||
})()}
|
||||
</ul>`}
|
||||
</div>
|
||||
|
||||
<div class="usage-panel">
|
||||
<div class="section-title">Top Tools <span class="count">${tools.length}</span></div>
|
||||
${tools.length === 0 ? '<p class="empty-state">No tool data yet</p>' : `
|
||||
<ul class="stat-list" id="usage-tools-list">
|
||||
${(() => {
|
||||
const max = tools[0]?.count || 1;
|
||||
return tools.map(t => {
|
||||
const pct = (t.count / max * 100).toFixed(1);
|
||||
return `<li>
|
||||
<div class="stat-list-header">
|
||||
<span class="stat-list-name">${escapeHTML(t.name)}</span>
|
||||
<span class="stat-list-count">${t.count}</span>
|
||||
</div>
|
||||
<div class="stat-list-bar-track">
|
||||
<div class="stat-list-bar-fill tool" style="width:${pct}%"></div>
|
||||
</div>
|
||||
</li>`;
|
||||
}).join('');
|
||||
})()}
|
||||
</ul>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -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 = `
|
||||
<div class="cmd-palette">
|
||||
<div class="cmd-palette-input-wrap">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="5"/><line x1="11" y1="11" x2="14" y2="14"/></svg>
|
||||
<input class="cmd-palette-input" id="cmd-palette-input" type="text" placeholder="Type a command or search..." autofocus spellcheck="false" autocomplete="off">
|
||||
</div>
|
||||
<div class="cmd-palette-results" id="cmd-palette-results"></div>
|
||||
<div class="cmd-palette-footer">
|
||||
<span><kbd>↑↓</kbd> navigate</span>
|
||||
<span><kbd>↵</kbd> select</span>
|
||||
<span><kbd>esc</kbd> close</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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) => `
|
||||
<div class="cmd-palette-item${i === paletteSelectedIndex ? ' selected' : ''}" data-index="${i}">
|
||||
<div class="cmd-palette-icon">${item.icon}</div>
|
||||
<span class="cmd-palette-label"><strong>${escapeHTML(item.label)}</strong></span>
|
||||
${item.shortcut ? `<span class="cmd-palette-kbd">${item.shortcut}</span>` : ''}
|
||||
</div>
|
||||
`).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));
|
||||
}
|
||||
}
|
||||
@@ -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 = `
|
||||
<div class="error-boundary">
|
||||
<h2>Something went wrong</h2>
|
||||
<p>An error occurred while rendering this page.</p>
|
||||
<pre class="error-boundary-detail">${escapeHTML(err.message)}</pre>
|
||||
<a href="/" class="back-link">Back to Dashboard</a>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
};
|
||||
|
||||
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 = '<div class="not-found"><h2>Page not found</h2><p>The page you\'re looking for doesn\'t exist.</p><a href="/" class="back-link">Go to Dashboard</a></div>';
|
||||
}
|
||||
updateActiveNav();
|
||||
|
||||
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 <a> click handler in app.js picks these up.
|
||||
el.innerHTML = items.map(item => {
|
||||
if (item.current) {
|
||||
return `<span class="current">${escapeHTML(item.label)}</span>`;
|
||||
}
|
||||
return `<a href="${escapeHTML(item.path)}">${escapeHTML(item.label)}</a><span class="sep">/</span>`;
|
||||
}).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);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// ── theme.js — theme toggle, no imports ──────────────────
|
||||
|
||||
export const THEME_CYCLE = ['system', 'light', 'dark'];
|
||||
|
||||
export const THEME_ICONS = {
|
||||
system: '<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="2" width="14" height="10" rx="1.5"/><path d="M5 15h6M8 12v3"/></svg>',
|
||||
light: '<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="3"/><line x1="8" y1="1" x2="8" y2="3"/><line x1="8" y1="13" x2="8" y2="15"/><line x1="1" y1="8" x2="3" y2="8"/><line x1="13" y1="8" x2="15" y2="8"/><line x1="3.05" y1="3.05" x2="4.46" y2="4.46"/><line x1="11.54" y1="11.54" x2="12.95" y2="12.95"/><line x1="12.95" y1="3.05" x2="11.54" y2="4.46"/><line x1="4.46" y1="11.54" x2="3.05" y2="12.95"/></svg>',
|
||||
dark: '<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 10a5 5 0 1 1-7-7 6 6 0 0 0 7 7z"/></svg>',
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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, '"')
|
||||
.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 '<span class="status-badge status-success"><svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right:2px"><polyline points="13.5 4.5 6.5 11.5 2.5 7.5"/></svg>success</span>';
|
||||
if (status === 'error') return '<span class="status-badge status-error"><svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right:2px"><line x1="13.5" y1="2.5" x2="2.5" y2="13.5"/><line x1="2.5" y1="2.5" x2="13.5" y2="13.5"/></svg>error</span>';
|
||||
return '<span class="status-badge status-unknown"><span class="status-dot"></span>unknown</span>';
|
||||
}
|
||||
|
||||
export function skeletonRows(rows, cols) {
|
||||
return Array(rows).fill(0).map(() =>
|
||||
'<tr>' + Array(cols).fill('<td><div class="skeleton-line"></div></td>').join('') + '</tr>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
export function dashboardSkeleton() {
|
||||
return `
|
||||
<div class="skeleton-summary-row">${Array(4).fill('<div class="skeleton-card"><div class="skeleton-line" style="width:40%"></div><div class="skeleton-line" style="width:20%;height:2rem"></div></div>').join('')}</div>
|
||||
<div class="skeleton-line" style="width:100%;height:200px;border-radius:var(--radius-lg)"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 `<tr>
|
||||
<td><div style="display:flex;align-items:center;gap:0.6rem"><div class="skeleton-circle"></div><div style="flex:1"><div class="skeleton-line" style="width:${w1};margin-bottom:0.3rem"></div><div class="skeleton-line" style="width:${w2}"></div></div></div></td>
|
||||
<td><div class="skeleton-line" style="width:60%"></div></td>
|
||||
<td><div class="skeleton-line" style="width:55%"></div></td>
|
||||
<td><div class="skeleton-line" style="width:30%"></div></td>
|
||||
<td><div class="skeleton-line" style="width:45%"></div></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
export function agentsSkeleton() {
|
||||
return `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1rem">
|
||||
${Array(4).fill('<div class="skeleton-card"><div class="skeleton-line" style="width:50%"></div><div class="skeleton-line" style="width:70%"></div><div class="skeleton-line" style="width:30%"></div></div>').join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function infrastructureSkeleton() {
|
||||
return `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem">
|
||||
${Array(6).fill('<div class="skeleton-card"><div class="skeleton-line" style="width:40%"></div><div class="skeleton-line" style="width:60%"></div><div class="skeleton-line" style="width:25%"></div></div>').join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── 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 '<div class="event-icon message-in">↓</div>';
|
||||
case 'run.end': return '<div class="event-icon message-out">↑</div>';
|
||||
case 'span.start':
|
||||
case 'span.end': return '<div class="event-icon tool">⚙</div>';
|
||||
case 'error': return '<div class="event-icon error">!</div>';
|
||||
case 'session.start':
|
||||
case 'session.end': return '<div class="event-icon session">○</div>';
|
||||
default: return '<div class="event-icon internal">·</div>';
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
? ` <span class="timeline-duration">${escapeHTML(formatDuration(payload.duration_ms))}</span>`
|
||||
: '';
|
||||
const detailClass = attrs.span_kind === 'agent' || attrs.type === 'subagent' ? ' subagent-name' : ' tool-name';
|
||||
const prefix = attrs.span_kind === 'agent' || attrs.type === 'subagent' ? 'subagent ' : '';
|
||||
return `<div class="timeline-event-body${detailClass}">${escapeHTML(prefix + name)}${duration}</div>`;
|
||||
}
|
||||
|
||||
if (eventType === 'run.start') {
|
||||
const preview = payload.prompt_preview || payload.message_preview || payload.message || '';
|
||||
if (!preview) return '';
|
||||
const trimmed = preview.length > 140 ? preview.slice(0, 140) + '...' : preview;
|
||||
return `<div class="timeline-event-body message-preview">"${escapeHTML(trimmed)}"</div>`;
|
||||
}
|
||||
|
||||
if (eventType === 'run.end') {
|
||||
return `<div class="timeline-event-body">${statusIcon(payload.status || 'unknown')}</div>`;
|
||||
}
|
||||
|
||||
if (eventType === 'error') {
|
||||
const errPayload = payload.error || {};
|
||||
const errType = errPayload.type || 'error';
|
||||
const message = errPayload.message || payload.message || 'unknown';
|
||||
return `<div class="timeline-event-body error-message">${escapeHTML(errType + ': ' + message)}</div>`;
|
||||
}
|
||||
|
||||
if (eventType === 'session.start' || eventType === 'session.end') {
|
||||
return correlation.session_id
|
||||
? `<div class="timeline-event-body">session ${escapeHTML(correlation.session_id)}</div>`
|
||||
: '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
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 `<button class="copy-btn" type="button" title="Copy to clipboard" data-copy="${escapeHTML(text)}">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="5.5" y="5.5" width="9" height="9" rx="1.5"/><path d="M10.5 1.5H2.5A1.5 1.5 0 0 0 1 3v8"/></svg>
|
||||
</button>`;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
+243
-36
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user