574 lines
22 KiB
HTML
574 lines
22 KiB
HTML
<!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>
|