ebc944702f
Only the zap VM remains in the fleet. Remove orb/sun from the README architecture/config docs, the getVMClassName allowlist, and their .timeline-vm-tag color styles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
339 lines
14 KiB
JavaScript
339 lines
14 KiB
JavaScript
// ── 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'].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>`;
|
|
}
|