fester/ui/replay.html

574 lines
22 KiB
HTML
Raw Permalink 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">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fester — Replay Observatory</title>
<link rel="stylesheet" href="/style.css">
<style>
body { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
#layout {
flex: 1;
display: grid;
grid-template-columns: 280px 1fr 340px;
gap: 12px;
padding: 12px;
overflow: hidden;
min-height: 0;
}
#layout > section { display: flex; flex-direction: column; min-height: 0; }
#layout > section > .body { flex: 1; overflow: auto; min-height: 0; }
/* center graph */
#graph {
position: relative;
flex: 1;
background:
radial-gradient(circle at 1px 1px, rgba(255,255,255,0.04) 1px, transparent 1px) 0 0 / 24px 24px,
var(--bg-0);
overflow: hidden;
min-height: 0;
}
.timeline-track {
height: 36px;
background: var(--bg-0);
border: 1px solid var(--line);
border-radius: var(--radius);
position: relative;
overflow: hidden;
margin-bottom: 8px;
}
.timeline-track .event {
position: absolute;
top: 4px;
width: 4px;
height: 28px;
background: var(--fg-3);
border-radius: 2px;
cursor: pointer;
}
.timeline-track .event.pipeline { background: var(--warn); }
.timeline-track .event.failure { background: var(--err); }
.timeline-track .event.cache { background: var(--info); }
.timeline-track .event.node { background: var(--ok); }
.timeline-track .event.done { background: var(--ok); }
.timeline-track .cursor {
position: absolute;
top: 0; bottom: 0;
width: 2px;
background: var(--accent);
pointer-events: none;
box-shadow: 0 0 6px var(--accent);
}
.controls-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}
.speed-row {
display: flex;
gap: 4px;
margin-top: 6px;
}
.speed-row button {
flex: 1;
padding: 4px 6px;
font-size: 10px;
font-family: var(--font-mono);
}
.speed-row button.active { background: var(--info); color: #061018; border-color: var(--info); }
/* autopsy panel — full width below the layout grid */
#autopsy-bar {
padding: 8px 12px;
background: var(--bg-2);
border-top: 1px solid var(--line);
display: flex;
gap: 8px;
align-items: center;
}
#autopsy-bar input { max-width: 220px; }
#autopsy-output {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
background: var(--bg-0);
border-top: 1px solid var(--line);
}
#autopsy-output.open { max-height: 220px; overflow: auto; }
#autopsy-output pre { padding: 10px 14px; }
/* node in replay graph */
.rnode {
position: absolute;
min-width: 110px;
padding: 6px 10px;
font-size: 11px;
text-align: center;
border-radius: var(--radius);
background: var(--bg-3);
border: 1px solid var(--line-2);
color: var(--fg-0);
cursor: pointer;
z-index: 2;
transition: transform 0.15s, border-color 0.15s;
}
.rnode:hover { transform: translateY(-1px); }
.rnode.pending { border-color: var(--fg-3); }
.rnode.running { border-color: var(--warn); box-shadow: 0 0 10px rgba(255,179,0,0.3); }
.rnode.done { border-color: var(--ok); }
.rnode.failed { border-color: var(--err); background: var(--err-bg); }
.rnode.cache { border-color: var(--info); }
.rnode.critical { outline: 2px solid var(--accent); outline-offset: 2px; }
.rnode.active { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (max-width: 1100px) {
#layout { grid-template-columns: 240px 1fr; }
#layout > section.right { display: none; }
}
@media (max-width: 700px) {
#layout { grid-template-columns: 1fr; padding: 8px; }
body { overflow: auto; height: auto; }
#layout { height: auto; }
#graph { height: 400px; }
}
</style>
</head>
<body data-fester-shell="replay">
<div id="layout">
<!-- LEFT: Controls -->
<section class="panel">
<div class="head"><h3>Replay Controls</h3></div>
<div class="body">
<div class="field">
<label>Session</label>
<select id="session-select">
<option value="">— select session —</option>
</select>
</div>
<button class="primary full" id="btn-load">Load Replay</button>
<hr>
<div class="controls-grid">
<button id="btn-back" title="Step back">⏮ Back</button>
<button id="btn-step" title="Step forward">⏭ Step</button>
<button id="btn-play" title="Play">▶ Play</button>
<button id="btn-pause" title="Pause">⏸ Pause</button>
<button id="btn-reset" title="Reset to start">⟲ Reset</button>
<button id="btn-end" title="Jump to end">⏭ End</button>
</div>
<label style="margin-top:12px;">Timeline</label>
<div class="timeline-track" id="timeline-track">
<div class="cursor" id="cursor" style="left:0;"></div>
</div>
<input id="timeline" type="range" min="0" max="0" value="0" step="1">
<div class="row" style="justify-content:space-between;font-family:var(--font-mono);font-size:11px;color:var(--fg-2);">
<span id="time-label">0 / 0</span>
<span id="event-type-label"></span>
</div>
<label style="margin-top:12px;">Speed</label>
<div class="speed-row">
<button data-speed="600">0.5×</button>
<button data-speed="300" class="active">1×</button>
<button data-speed="150">2×</button>
<button data-speed="60">5×</button>
</div>
<hr>
<label>Highlight critical path</label>
<label style="display:flex;align-items:center;gap:6px;text-transform:none;letter-spacing:0;font-family:var(--font-mono);color:var(--fg-1);cursor:pointer;">
<input type="checkbox" id="toggle-critical" checked style="width:auto;">
<span>Show critical path overlay</span>
</label>
</div>
</section>
<!-- CENTER: Graph -->
<section class="panel">
<div class="head">
<h3>Execution Graph</h3>
<div class="row" style="gap:6px;">
<span class="badge" id="replay-status">idle</span>
</div>
</div>
<div class="body" style="padding:0;">
<div id="graph">
<svg id="edges" class="edges">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z"></path>
</marker>
</defs>
</svg>
</div>
</div>
</section>
<!-- RIGHT: Inspector -->
<section class="panel right">
<div class="head"><h3>Inspector</h3></div>
<div class="body">
<pre class="json" id="inspector">Click a node to inspect.</pre>
</div>
</section>
</div>
<!-- Autopsy bar (full width) -->
<div id="autopsy-bar">
<strong style="font-size:12px;">Failure Autopsy:</strong>
<input id="fail-action" type="text" placeholder="action name">
<button id="btn-autopsy">Analyze</button>
<button class="ghost" id="btn-autopsy-toggle"></button>
<span class="dim" style="font-size:11px;">Traces dependency chain backward + last scheduler decision</span>
</div>
<div id="autopsy-output">
<pre class="json" id="autopsy-out"></pre>
</div>
<script src="/ui/app.js"></script>
<script>
(function () {
'use strict';
const $ = (id) => document.getElementById(id);
const graphEl = $('graph');
const edgesSvg = $('edges');
let sessionId = null;
let events = [];
let index = -1;
let timer = null;
let speed = 300;
let showCritical = true;
const nodeMap = {}; // name -> element
/* ---------- Sessions ---------- */
async function loadSessions() {
try {
const data = await Fester.api('/api/sessions');
const sel = $('session-select');
sel.innerHTML = '<option value="">— select session —</option>' +
(data.sessions || []).map(s => `<option value="${s.id}">${s.id} · ${s.action || 'session'} · ${Fester.fmtRel(s.ts)}</option>`).join('');
} catch (e) {
console.warn('failed to load sessions', e);
}
}
/* ---------- Load replay ---------- */
async function loadReplay() {
const chosen = $('session-select').value;
try {
const startRes = await Fester.api('/replay/start', {
method: 'POST',
body: JSON.stringify({ journal: chosen || 'latest' }),
});
sessionId = startRes.session_id;
const journal = await Fester.api('/replay/events').catch(() => ({ events: [] }));
events = journal.events || [];
$('timeline').max = Math.max(0, events.length - 1);
$('timeline').value = 0;
renderTimelineTrack();
renderFrame(0);
Fester.toast(`Loaded ${events.length} events`, 'ok');
} catch (e) {
Fester.toast(`Replay load failed: ${e.message}`, 'err');
}
}
/* ---------- Apply event ---------- */
function applyEvent(e) {
if (!e) return;
// Events may be flat (schema.FesterEvent: top-level node/action/state)
// or legacy (nested under .data). Normalize.
const d = e.data && typeof e.data === 'object' ? { ...e.data } : {};
// Copy top-level fields if not already in d
['node', 'action', 'state', 'target', 'cache', 'critical', 'reason'].forEach(k => {
if (d[k] == null && e[k] != null) d[k] = e[k];
});
// deps + critical may be nested under meta (real backend shape)
if (d.deps == null && e.meta?.deps) d.deps = e.meta.deps;
if (d.critical == null && e.meta?.critical) d.critical = e.meta.critical;
if (d.target == null && e.meta?.target) d.target = e.meta.target;
if (d.cache == null && e.meta?.cache) d.cache = e.meta.cache;
const name = d.action || d.node || d.name;
if (!name) return;
if (!nodeMap[name]) {
const el = document.createElement('div');
el.className = 'rnode pending';
el.dataset.name = name;
el.innerHTML = `<div class="label">${name}</div><div class="meta">${d.target || ''}</div>`;
el.addEventListener('click', () => inspect(d, name));
graphEl.appendChild(el);
nodeMap[name] = el;
}
const el = nodeMap[name];
el.classList.remove('pending', 'running', 'done', 'failed', 'cache', 'active');
if (d.state) el.classList.add(d.state);
if (d.cache === 'hit') el.classList.add('cache');
if (showCritical && d.critical) el.classList.add('critical');
// store deps for edge drawing
el._deps = d.deps || [];
el._data = d;
}
function layoutNodes() {
const names = Object.keys(nodeMap);
if (!names.length) return;
// simple layered layout based on deps
const layers = {};
const computed = {};
function getLayer(name) {
if (computed[name] != null) return computed[name];
const el = nodeMap[name];
const deps = el?._deps || [];
if (!deps.length) {
computed[name] = 0;
} else {
computed[name] = Math.max(...deps.map(getLayer)) + 1;
}
return computed[name];
}
names.forEach(getLayer);
names.forEach(n => {
const l = computed[n];
(layers[l] = layers[l] || []).push(n);
});
const layerKeys = Object.keys(layers).map(Number).sort((a, b) => a - b);
const stageW = graphEl.clientWidth || 800;
const stageH = graphEl.clientHeight || 600;
const layerGap = Math.max(80, (stageH - 60) / Math.max(1, layerKeys.length));
layerKeys.forEach((l, li) => {
const ns = layers[l];
const gap = Math.max(130, (stageW - 80) / Math.max(1, ns.length));
const startX = (stageW - (ns.length - 1) * gap) / 2 - 55;
ns.forEach((name, i) => {
const el = nodeMap[name];
el.style.left = (startX + i * gap) + 'px';
el.style.top = (24 + li * layerGap) + 'px';
});
});
}
function drawEdges() {
Array.from(edgesSvg.querySelectorAll('line')).forEach(n => n.remove());
const sr = edgesSvg.getBoundingClientRect();
Object.keys(nodeMap).forEach(name => {
const el = nodeMap[name];
const deps = el._deps || [];
deps.forEach(dep => {
const fromEl = nodeMap[dep];
if (!fromEl) return;
const fr = fromEl.getBoundingClientRect();
const tr = el.getBoundingClientRect();
const x1 = fr.left + fr.width / 2 - sr.left;
const y1 = fr.bottom - sr.top;
const x2 = tr.left + tr.width / 2 - sr.left;
const y2 = tr.top - sr.top;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', x1);
line.setAttribute('y1', y1);
line.setAttribute('x2', x2);
line.setAttribute('y2', y2);
if (showCritical && (el._data?.critical || fromEl._data?.critical)) {
line.classList.add('critical');
}
line.setAttribute('marker-end', 'url(#arrow)');
edgesSvg.appendChild(line);
});
});
}
/* ---------- Render frame ---------- */
function renderFrame(i) {
index = Math.max(0, Math.min(events.length - 1, i));
$('timeline').value = index;
$('time-label').textContent = `${index + 1} / ${events.length}`;
$('cursor').style.left = (events.length > 1 ? (index / (events.length - 1)) * 100 : 0) + '%';
const ev = events[index];
if (ev) {
const t = ev.type || 'event';
$('event-type-label').textContent = t.toUpperCase();
}
// reset
graphEl.querySelectorAll('.rnode').forEach(n => n.remove());
Object.keys(nodeMap).forEach(k => delete nodeMap[k]);
for (let j = 0; j <= index; j++) applyEvent(events[j]);
// mark active
const active = events[index];
if (active) {
const an = active.action || active.node || (active.data && (active.data.action || active.data.node)) || active.data?.name;
if (an && nodeMap[an]) nodeMap[an].classList.add('active');
}
layoutNodes();
drawEdges();
}
/* ---------- Timeline track markers ---------- */
function renderTimelineTrack() {
const track = $('timeline-track');
track.querySelectorAll('.event').forEach(n => n.remove());
if (events.length < 2) return;
events.forEach((e, i) => {
const t = e.type || 'event';
if (t === 'pipeline' || t === 'failure' || t === 'cache' || t === 'node' || t === 'done') {
const dot = document.createElement('div');
dot.className = 'event ' + t;
dot.style.left = (i / (events.length - 1)) * 100 + '%';
dot.title = `${i}: ${t} ${e.data?.action || e.data?.node || ''}`;
dot.addEventListener('click', () => renderFrame(i));
track.appendChild(dot);
}
});
}
/* ---------- Inspector ---------- */
function inspect(data, name) {
// Pretty-print structured info instead of raw JSON
const deps = data.deps || [];
const state = data.state || '—';
const cache = data.cache === 'hit' ? '<span class="badge info">cache hit</span>' : '';
const critical = data.critical ? '<span class="badge accent">critical path</span>' : '';
const target = data.target ? `<div class="row" style="margin-top:6px;"><span class="dim">target:</span><span class="mono">${data.target}</span></div>` : '';
const node = data.node ? `<div class="row"><span class="dim">node:</span><span class="mono">${data.node}</span></div>` : '';
const reason = data.reason ? `<div class="row"><span class="dim">reason:</span><span class="mono" style="color:var(--err);">${data.reason}</span></div>` : '';
const depsHtml = deps.length
? `<div class="row" style="margin-top:6px;align-items:flex-start;"><span class="dim">deps:</span><div style="display:flex;flex-wrap:wrap;gap:4px;">${deps.map(d => `<span class="badge">${d}</span>`).join('')}</div></div>`
: '<div class="row"><span class="dim">deps:</span><span class="dim">none</span></div>';
$('inspector').innerHTML = `
<div style="padding:8px 10px;">
<div style="font-size:14px;font-weight:600;color:var(--fg-0);margin-bottom:6px;">${name}</div>
<div class="row">
<span class="dim">state:</span>
${Fester.stateBadge(state)}
${cache}
${critical}
</div>
${node}
${target}
${reason}
${depsHtml}
<hr>
<details>
<summary class="dim" style="cursor:pointer;font-size:11px;">raw event</summary>
<pre class="json" style="margin-top:6px;">${JSON.stringify({ node: name, ...data }, null, 2)}</pre>
</details>
</div>
`;
}
/* ---------- Playback ---------- */
function play() {
pause();
$('replay-status').textContent = 'playing';
$('replay-status').className = 'badge warn';
timer = setInterval(() => {
if (index < events.length - 1) {
renderFrame(index + 1);
} else {
pause();
$('replay-status').textContent = 'complete';
$('replay-status').className = 'badge ok';
}
}, speed);
}
function pause() {
clearInterval(timer);
timer = null;
if ($('replay-status').textContent === 'playing') {
$('replay-status').textContent = 'paused';
$('replay-status').className = 'badge';
}
}
function step() { pause(); if (index < events.length - 1) renderFrame(index + 1); }
function back() { pause(); if (index > 0) renderFrame(index - 1); }
function reset() { pause(); renderFrame(0); $('replay-status').textContent = 'paused'; }
function end() { pause(); renderFrame(events.length - 1); $('replay-status').textContent = 'complete'; }
/* ---------- Autopsy ---------- */
async function runAutopsy() {
const action = $('fail-action').value.trim();
if (!action) { Fester.toast('Enter an action name', 'warn'); return; }
if (!sessionId) { Fester.toast('Load a replay first', 'warn'); return; }
try {
const data = await Fester.api(`/autopsy/${sessionId}/${encodeURIComponent(action)}`);
$('autopsy-out').textContent = JSON.stringify(data, null, 2);
$('autopsy-output').classList.add('open');
} catch (e) {
$('autopsy-out').textContent = e.message;
$('autopsy-output').classList.add('open');
}
}
/* ---------- Wire up controls ---------- */
$('btn-load').addEventListener('click', loadReplay);
$('btn-play').addEventListener('click', play);
$('btn-pause').addEventListener('click', pause);
$('btn-step').addEventListener('click', step);
$('btn-back').addEventListener('click', back);
$('btn-reset').addEventListener('click', reset);
$('btn-end').addEventListener('click', end);
$('timeline').addEventListener('input', (e) => renderFrame(parseInt(e.target.value)));
$('btn-autopsy').addEventListener('click', runAutopsy);
$('btn-autopsy-toggle').addEventListener('click', () => {
const out = $('autopsy-output');
out.classList.toggle('open');
$('btn-autopsy-toggle').textContent = out.classList.contains('open') ? '▴' : '▾';
});
$('toggle-critical').addEventListener('change', (e) => {
showCritical = e.target.checked;
if (events.length) renderFrame(index);
});
document.querySelectorAll('.speed-row button').forEach(b => {
b.addEventListener('click', () => {
document.querySelectorAll('.speed-row button').forEach(x => x.classList.remove('active'));
b.classList.add('active');
speed = parseInt(b.dataset.speed);
if (timer) { pause(); play(); }
});
});
/* ---------- Resize ---------- */
let rt;
window.addEventListener('resize', () => {
clearTimeout(rt);
rt = setTimeout(() => { if (events.length) renderFrame(index); }, 200);
});
/* ---------- Init ---------- */
loadSessions();
$('replay-status').textContent = 'idle';
})();
</script>
</body>
</html>