swactor/crates/dashboard/examples/replay_viewer.html
Zachery Aaron Shores-Chmielewski c67ce181c2 feat(distribution): add n3 observability introspectors
Relay/subprocess/registry introspectors, build-time dep-version + git-sha, postproc
RTT/inference render, new swactor-iroh-relay binary; dashboard replay-viewer, sim SWIM
tuning report + repro tests, N3 postmortem docs.


Signed-off-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
2026-05-25 22:19:06 +04:00

657 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Replay Viewer</title>
<style>
:root {
--bg: #0f1117;
--panel: #13151c;
--panel2: #161822;
--border: #2a2d3e;
--text: #e0e0e0;
--dim: #8a8fa3;
--accent: #4a90e2;
--notable: #ffb74d;
--error: #f44336;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font: 13px/1.4 Menlo, Consolas, monospace;
height: 100vh;
display: grid;
grid-template-columns: 220px 1fr 380px;
grid-template-rows: 52px 1fr;
grid-template-areas:
"toolbar toolbar toolbar"
"rail stage stream";
}
.toolbar {
grid-area: toolbar;
display: flex;
gap: 12px;
align-items: center;
padding: 0 16px;
background: var(--panel2);
border-bottom: 1px solid var(--border);
}
.toolbar button {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
padding: 6px 12px;
font: inherit;
cursor: pointer;
border-radius: 3px;
}
.toolbar button:hover { background: #1c1f2b; }
.toolbar button.primary { background: var(--accent); border-color: var(--accent); color: white; }
.toolbar select {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
padding: 6px 8px;
font: inherit;
border-radius: 3px;
}
.toolbar input[type=range] { flex: 1; min-width: 200px; }
.toolbar .now { color: var(--dim); min-width: 100px; text-align: right; font-variant-numeric: tabular-nums; }
.toolbar .run-id { color: var(--dim); margin-left: 8px; font-size: 11px; }
.rail {
grid-area: rail;
background: var(--panel);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 12px;
}
.rail h3 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--dim);
margin: 12px 0 6px 0;
font-weight: 600;
}
.rail h3:first-child { margin-top: 0; }
.chip {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 6px;
margin: 2px 0;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
user-select: none;
}
.chip:hover { background: #1c1f2b; }
.chip.off { opacity: 0.35; }
.chip .swatch {
width: 10px;
height: 10px;
border-radius: 2px;
flex-shrink: 0;
}
.chip .count { color: var(--dim); margin-left: auto; font-size: 10px; }
.chip .lbl { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sev-floor {
display: flex;
gap: 4px;
margin-top: 4px;
}
.sev-floor button {
flex: 1;
background: var(--bg);
color: var(--dim);
border: 1px solid var(--border);
padding: 4px 6px;
font: inherit;
font-size: 10px;
cursor: pointer;
border-radius: 2px;
}
.sev-floor button.active { background: var(--accent); color: white; border-color: var(--accent); }
#stage {
grid-area: stage;
position: relative;
overflow: hidden;
background: var(--bg);
}
#swim { width: 100%; height: 100%; display: block; cursor: grab; }
#swim.dragging { cursor: grabbing; }
#stage .tooltip {
position: absolute;
background: var(--panel2);
border: 1px solid var(--border);
padding: 6px 8px;
font-size: 11px;
border-radius: 3px;
pointer-events: none;
display: none;
max-width: 280px;
z-index: 10;
}
#stage .tooltip .k { color: var(--dim); }
.stream {
grid-area: stream;
background: var(--panel);
border-left: 1px solid var(--border);
overflow-y: auto;
font-size: 11px;
}
.stream .header {
padding: 8px 12px;
color: var(--dim);
border-bottom: 1px solid var(--border);
background: var(--panel2);
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
gap: 8px;
}
.stream .header .stick {
margin-left: auto;
font-size: 10px;
cursor: pointer;
color: var(--dim);
}
.stream .header .stick.on { color: var(--accent); }
.evt {
padding: 6px 10px;
border-bottom: 1px solid #1f2230;
cursor: pointer;
border-left: 3px solid transparent;
}
.evt:hover { background: #1c1f2b; }
.evt.active { background: #1e2434; }
.evt.dimmed { opacity: 0.3; }
.evt .meta {
display: flex;
gap: 8px;
align-items: baseline;
font-size: 10px;
color: var(--dim);
}
.evt .t { font-variant-numeric: tabular-nums; color: var(--accent); }
.evt .node { color: var(--text); }
.evt .kind { color: var(--text); font-weight: 600; }
.evt pre {
margin: 4px 0 0 0;
color: #b8bcd0;
font-size: 10px;
white-space: pre-wrap;
word-break: break-all;
max-height: 8em;
overflow: hidden;
}
.sev-info { border-left-color: var(--accent); }
.sev-notable { border-left-color: var(--notable); }
.sev-error { border-left-color: var(--error); }
</style>
</head>
<body>
<div class="toolbar">
<button id="play" class="primary">▶ Play</button>
<label style="color: var(--dim); font-size: 11px;">speed</label>
<select id="speed">
<option value="0.25">0.25×</option>
<option value="1">1×</option>
<option value="4" selected>4×</option>
<option value="16">16×</option>
<option value="64">64×</option>
<option value="instant">instant</option>
</select>
<input id="scrub" type="range" min="0" max="10000" value="0">
<span class="now" id="now">0.0 s</span>
<span class="run-id" id="run-id"></span>
</div>
<aside class="rail">
<h3>Nodes</h3>
<div id="nodes"></div>
<h3>Severity floor</h3>
<div class="sev-floor" id="sev-floor">
<button data-sev="info" class="active">info</button>
<button data-sev="notable">notable</button>
<button data-sev="error">error</button>
</div>
<h3>Event kinds</h3>
<div id="kinds"></div>
</aside>
<main id="stage">
<svg id="swim" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none"></svg>
<div class="tooltip" id="tooltip"></div>
</main>
<aside class="stream">
<div class="header">
<span>raw event stream</span>
<span id="stream-count"></span>
<span class="stick on" id="stick">⤓ follow</span>
</div>
<div id="stream-list"></div>
</aside>
<script>
"use strict";
const SEV_ORDER = { info: 0, notable: 1, error: 2 };
const state = {
bundle: null, // BundleView from /api/bundle
playhead: 0, // ms (timeline space, 0 = bundle t_start)
playing: false,
speed: 4,
lastFrameWall: 0,
view: { x: 0, w: 0 }, // x = leftmost t_ms shown, w = visible width in t_ms
lane: { count: 0, h: 0, top: 24 },
hiddenNodes: new Set(),
hiddenKinds: new Set(),
sevFloor: 'info',
followStream: true,
laneRows: new Map(), // node_label -> { y_center }
};
const SVG_NS = 'http://www.w3.org/2000/svg';
const svg = document.getElementById('swim');
const stage = document.getElementById('stage');
const tooltip = document.getElementById('tooltip');
const streamList = document.getElementById('stream-list');
const streamCount = document.getElementById('stream-count');
const playBtn = document.getElementById('play');
const scrub = document.getElementById('scrub');
const nowLabel = document.getElementById('now');
const speedSel = document.getElementById('speed');
const stickBtn = document.getElementById('stick');
async function load() {
const r = await fetch('/api/bundle');
const b = await r.json();
state.bundle = b;
document.getElementById('run-id').textContent =
`${b.run_id} · ${b.source_kind} · ${b.nodes.length} nodes · ${b.events.length} events`;
state.view.x = 0;
state.view.w = Math.max(b.t_end_ms, 1000);
scrub.max = Math.max(b.t_end_ms, 1);
buildRail();
layoutSvg();
renderSwim();
renderStream();
}
function buildRail() {
const nodesEl = document.getElementById('nodes');
nodesEl.innerHTML = '';
for (const n of state.bundle.nodes) {
const chip = document.createElement('div');
chip.className = 'chip';
chip.dataset.label = n.label;
chip.innerHTML = `<span class="swatch" style="background:${n.color}"></span>` +
`<span class="lbl">${n.label}</span>` +
`<span class="count">${n.role ?? ''}</span>`;
chip.onclick = () => {
if (state.hiddenNodes.has(n.label)) state.hiddenNodes.delete(n.label);
else state.hiddenNodes.add(n.label);
chip.classList.toggle('off', state.hiddenNodes.has(n.label));
renderSwim();
renderStream();
};
nodesEl.appendChild(chip);
}
const kindsEl = document.getElementById('kinds');
kindsEl.innerHTML = '';
for (const k of state.bundle.event_kinds) {
const chip = document.createElement('div');
chip.className = 'chip';
chip.innerHTML = `<span class="swatch" style="background:${k.color}"></span>` +
`<span class="lbl">${k.kind}</span>` +
`<span class="count">${k.count}</span>`;
chip.onclick = () => {
if (state.hiddenKinds.has(k.kind)) state.hiddenKinds.delete(k.kind);
else state.hiddenKinds.add(k.kind);
chip.classList.toggle('off', state.hiddenKinds.has(k.kind));
renderSwim();
renderStream();
};
kindsEl.appendChild(chip);
}
const floor = document.getElementById('sev-floor');
floor.querySelectorAll('button').forEach(b => {
b.onclick = () => {
state.sevFloor = b.dataset.sev;
floor.querySelectorAll('button').forEach(x => x.classList.toggle('active', x === b));
renderSwim();
renderStream();
};
});
}
function layoutSvg() {
const rect = stage.getBoundingClientRect();
const w = Math.max(rect.width, 100);
const h = Math.max(rect.height, 100);
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
svg.setAttribute('width', w);
svg.setAttribute('height', h);
const visible = state.bundle.nodes.filter(n => !state.hiddenNodes.has(n.label));
state.lane.count = visible.length;
state.lane.top = 28;
const usable = h - state.lane.top - 12;
state.lane.h = state.lane.count > 0 ? usable / state.lane.count : usable;
state.laneRows.clear();
visible.forEach((n, i) => {
state.laneRows.set(n.label, {
yCenter: state.lane.top + state.lane.h * (i + 0.5),
color: n.color,
});
});
}
function tToX(t_ms, w) {
return ((t_ms - state.view.x) / state.view.w) * w;
}
function xToT(x, w) {
return state.view.x + (x / w) * state.view.w;
}
function severityPass(sev) {
return SEV_ORDER[sev] >= SEV_ORDER[state.sevFloor];
}
function eventVisible(ev) {
if (state.hiddenNodes.has(ev.node_label)) return false;
if (state.hiddenKinds.has(ev.kind)) return false;
if (!severityPass(ev.severity)) return false;
return true;
}
function kindColor(kind) {
const ki = state.bundle.event_kinds.find(k => k.kind === kind);
return ki ? ki.color : '#8a8fa3';
}
function renderSwim() {
while (svg.firstChild) svg.removeChild(svg.firstChild);
const rect = stage.getBoundingClientRect();
const w = Math.max(rect.width, 100);
const h = Math.max(rect.height, 100);
// Lane backgrounds + labels
for (const [label, row] of state.laneRows) {
const bg = document.createElementNS(SVG_NS, 'rect');
bg.setAttribute('x', 0);
bg.setAttribute('y', row.yCenter - state.lane.h / 2);
bg.setAttribute('width', w);
bg.setAttribute('height', state.lane.h);
bg.setAttribute('fill', '#13151c');
bg.setAttribute('opacity', '0.3');
svg.appendChild(bg);
const sep = document.createElementNS(SVG_NS, 'line');
sep.setAttribute('x1', 0);
sep.setAttribute('x2', w);
sep.setAttribute('y1', row.yCenter + state.lane.h / 2);
sep.setAttribute('y2', row.yCenter + state.lane.h / 2);
sep.setAttribute('stroke', '#2a2d3e');
svg.appendChild(sep);
const text = document.createElementNS(SVG_NS, 'text');
text.setAttribute('x', 8);
text.setAttribute('y', row.yCenter - state.lane.h / 2 + 14);
text.setAttribute('fill', row.color);
text.setAttribute('font-size', '11');
text.setAttribute('font-weight', '600');
text.textContent = label;
svg.appendChild(text);
}
// Time axis ticks
const tickCount = 8;
for (let i = 0; i <= tickCount; i++) {
const t = state.view.x + (state.view.w * i / tickCount);
const x = (i / tickCount) * w;
const tk = document.createElementNS(SVG_NS, 'line');
tk.setAttribute('x1', x); tk.setAttribute('x2', x);
tk.setAttribute('y1', 0); tk.setAttribute('y2', 16);
tk.setAttribute('stroke', '#2a2d3e');
svg.appendChild(tk);
const lbl = document.createElementNS(SVG_NS, 'text');
lbl.setAttribute('x', x + 3);
lbl.setAttribute('y', 12);
lbl.setAttribute('fill', '#8a8fa3');
lbl.setAttribute('font-size', '10');
lbl.textContent = formatT(t);
svg.appendChild(lbl);
}
// Snapshot ticks (thin marks at bottom of each lane)
for (const snap of state.bundle.snapshots) {
if (state.hiddenNodes.has(snap.node_label)) continue;
const row = state.laneRows.get(snap.node_label);
if (!row) continue;
const x = tToX(snap.t_ms, w);
if (x < -2 || x > w + 2) continue;
const m = document.createElementNS(SVG_NS, 'rect');
m.setAttribute('x', x - 0.5);
m.setAttribute('y', row.yCenter + state.lane.h / 2 - 4);
m.setAttribute('width', 1);
m.setAttribute('height', 3);
m.setAttribute('fill', '#aed581');
m.setAttribute('opacity', '0.8');
svg.appendChild(m);
}
// Event dots
for (let i = 0; i < state.bundle.events.length; i++) {
const ev = state.bundle.events[i];
if (!eventVisible(ev)) continue;
const row = state.laneRows.get(ev.node_label);
if (!row) continue;
const x = tToX(ev.t_ms, w);
if (x < -4 || x > w + 4) continue;
const dot = document.createElementNS(SVG_NS, 'circle');
dot.setAttribute('cx', x);
dot.setAttribute('cy', row.yCenter);
const r = ev.severity === 'error' ? 4 : ev.severity === 'notable' ? 3 : 2;
dot.setAttribute('r', r);
dot.setAttribute('fill', kindColor(ev.kind));
dot.setAttribute('opacity', ev.t_ms <= state.playhead ? '1' : '0.45');
dot.dataset.idx = i;
dot.style.cursor = 'pointer';
dot.addEventListener('mouseenter', e => showTooltip(e, ev));
dot.addEventListener('mouseleave', hideTooltip);
dot.addEventListener('click', () => jumpTo(ev.t_ms, i));
svg.appendChild(dot);
}
// Playhead
const phX = tToX(state.playhead, w);
if (phX >= 0 && phX <= w) {
const ph = document.createElementNS(SVG_NS, 'line');
ph.setAttribute('x1', phX); ph.setAttribute('x2', phX);
ph.setAttribute('y1', 16); ph.setAttribute('y2', h);
ph.setAttribute('stroke', '#ffb74d');
ph.setAttribute('stroke-width', '1.5');
svg.appendChild(ph);
}
}
function formatT(ms) {
if (ms < 1000) return `${ms.toFixed(0)}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const m = Math.floor(ms / 60_000);
const s = ((ms % 60_000) / 1000).toFixed(0);
return `${m}m${s}s`;
}
function showTooltip(e, ev) {
tooltip.style.display = 'block';
tooltip.innerHTML =
`<div><b>${ev.kind}</b> <span class="k">@ ${formatT(ev.t_ms)}</span></div>` +
`<div class="k">${ev.node_label} · ${ev.severity}</div>`;
const rect = stage.getBoundingClientRect();
tooltip.style.left = (e.clientX - rect.left + 10) + 'px';
tooltip.style.top = (e.clientY - rect.top + 10) + 'px';
}
function hideTooltip() { tooltip.style.display = 'none'; }
function renderStream() {
const filtered = state.bundle.events
.map((ev, i) => ({ ev, i }))
.filter(({ ev }) => eventVisible(ev));
streamCount.textContent = `(${filtered.length})`;
streamList.innerHTML = '';
let activeRow = null;
for (const { ev, i } of filtered) {
const row = document.createElement('div');
row.className = `evt sev-${ev.severity}`;
row.dataset.idx = i;
row.dataset.t = ev.t_ms;
if (ev.t_ms > state.playhead) row.classList.add('dimmed');
const fieldsPretty = JSON.stringify(ev.fields, null, 2);
row.innerHTML =
`<div class="meta">` +
`<span class="t">${formatT(ev.t_ms)}</span>` +
`<span class="node" style="color:${nodeColor(ev.node_label)}">${ev.node_label}</span>` +
`<span class="kind">${ev.kind}</span>` +
`</div>` +
`<pre>${escapeHtml(fieldsPretty)}</pre>`;
row.onclick = () => jumpTo(ev.t_ms, i);
streamList.appendChild(row);
if (ev.t_ms <= state.playhead) activeRow = row;
}
if (activeRow) {
activeRow.classList.add('active');
if (state.followStream) {
activeRow.scrollIntoView({ block: 'center', behavior: 'auto' });
}
}
}
function nodeColor(label) {
const n = state.bundle.nodes.find(x => x.label === label);
return n ? n.color : '#e0e0e0';
}
function escapeHtml(s) {
return s.replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
}
function jumpTo(t_ms, evIdx) {
state.playhead = t_ms;
scrub.value = t_ms;
nowLabel.textContent = formatT(t_ms);
renderSwim();
highlightStream(evIdx);
}
function highlightStream(evIdx) {
streamList.querySelectorAll('.evt').forEach(r => {
r.classList.toggle('active', Number(r.dataset.idx) === evIdx);
r.classList.toggle('dimmed', Number(r.dataset.t) > state.playhead);
});
const target = streamList.querySelector(`.evt[data-idx="${evIdx}"]`);
if (target && state.followStream) target.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
// ─── playback ─────────────────────────────────────────────────────────────
function tick(wallNow) {
if (!state.playing) return;
const dt = wallNow - state.lastFrameWall;
state.lastFrameWall = wallNow;
if (state.speed === 'instant') {
state.playhead = state.bundle.t_end_ms;
state.playing = false;
playBtn.textContent = '▶ Play';
} else {
state.playhead += dt * Number(state.speed);
if (state.playhead >= state.bundle.t_end_ms) {
state.playhead = state.bundle.t_end_ms;
state.playing = false;
playBtn.textContent = '▶ Play';
}
}
scrub.value = state.playhead;
nowLabel.textContent = formatT(state.playhead);
renderSwim();
renderStream();
if (state.playing) requestAnimationFrame(tick);
}
playBtn.onclick = () => {
if (state.playing) {
state.playing = false;
playBtn.textContent = '▶ Play';
} else {
if (state.playhead >= state.bundle.t_end_ms) state.playhead = 0;
state.playing = true;
state.lastFrameWall = performance.now();
playBtn.textContent = '⏸ Pause';
requestAnimationFrame(tick);
}
};
speedSel.onchange = () => { state.speed = speedSel.value === 'instant' ? 'instant' : Number(speedSel.value); };
scrub.oninput = () => {
state.playhead = Number(scrub.value);
nowLabel.textContent = formatT(state.playhead);
renderSwim();
renderStream();
};
stickBtn.onclick = () => {
state.followStream = !state.followStream;
stickBtn.classList.toggle('on', state.followStream);
};
// ─── zoom/pan ─────────────────────────────────────────────────────────────
svg.addEventListener('wheel', e => {
e.preventDefault();
const rect = stage.getBoundingClientRect();
const w = rect.width;
const mouseT = xToT(e.clientX - rect.left, w);
const factor = e.deltaY > 0 ? 1.2 : 0.8;
const newW = Math.max(50, Math.min(state.bundle.t_end_ms * 1.5, state.view.w * factor));
// Anchor zoom around mouseT.
state.view.x = mouseT - (mouseT - state.view.x) * (newW / state.view.w);
state.view.w = newW;
state.view.x = Math.max(-state.view.w * 0.05, Math.min(state.bundle.t_end_ms, state.view.x));
renderSwim();
}, { passive: false });
let dragStart = null;
svg.addEventListener('pointerdown', e => {
dragStart = { x: e.clientX, viewX: state.view.x };
svg.classList.add('dragging');
svg.setPointerCapture(e.pointerId);
});
svg.addEventListener('pointermove', e => {
if (!dragStart) return;
const rect = stage.getBoundingClientRect();
const dxT = ((e.clientX - dragStart.x) / rect.width) * state.view.w;
state.view.x = dragStart.viewX - dxT;
renderSwim();
});
svg.addEventListener('pointerup', e => {
dragStart = null;
svg.classList.remove('dragging');
try { svg.releasePointerCapture(e.pointerId); } catch (_) {}
});
window.addEventListener('resize', () => {
layoutSvg();
renderSwim();
});
load().catch(err => {
document.body.innerHTML = `<pre style="color:#f44336;padding:20px">failed to load bundle: ${err}</pre>`;
});
// Re-layout once after first paint to settle SVG dimensions.
setTimeout(() => { layoutSvg(); renderSwim(); }, 50);
</script>
</body>
</html>