swactor/crates/dashboard/examples/replay_viewer.html

658 lines
20 KiB
HTML
Raw Normal View History

<!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>