284 lines
5.4 KiB
HTML
284 lines
5.4 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Fester Execution Replay Observatory</title>
|
|
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
font-family: sans-serif;
|
|
background: #0b0f14;
|
|
color: #d0d0d0;
|
|
}
|
|
|
|
#layout {
|
|
display: grid;
|
|
grid-template-columns: 280px 1fr 320px;
|
|
height: 100vh;
|
|
}
|
|
|
|
/* -------------------------
|
|
LEFT: CONTROLS
|
|
------------------------- */
|
|
#controls {
|
|
padding: 12px;
|
|
border-right: 1px solid #1f2a38;
|
|
background: #0e141d;
|
|
}
|
|
|
|
button {
|
|
width: 100%;
|
|
margin: 4px 0;
|
|
padding: 6px;
|
|
background: #1a2432;
|
|
color: #ddd;
|
|
border: 1px solid #2c3b4d;
|
|
}
|
|
|
|
/* timeline slider */
|
|
#timeline {
|
|
width: 100%;
|
|
}
|
|
|
|
/* -------------------------
|
|
CENTER: GRAPH
|
|
------------------------- */
|
|
#graph {
|
|
position: relative;
|
|
background: #05070b;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* -------------------------
|
|
RIGHT: DEBUG PANEL
|
|
------------------------- */
|
|
#debug {
|
|
padding: 12px;
|
|
border-left: 1px solid #1f2a38;
|
|
background: #0e141d;
|
|
font-size: 12px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
/* NODE */
|
|
.node {
|
|
position: absolute;
|
|
width: 120px;
|
|
padding: 6px;
|
|
font-size: 11px;
|
|
text-align: center;
|
|
border-radius: 6px;
|
|
background: #1b2430;
|
|
border: 1px solid #333;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
/* STATES */
|
|
.node.pending { border-color: #455a64; }
|
|
.node.running { border-color: #ffb300; box-shadow: 0 0 10px rgba(255,179,0,0.3); }
|
|
.node.done { border-color: #66bb6a; }
|
|
.node.failed { border-color: #ef5350; }
|
|
.node.cache { border-color: #42a5f5; }
|
|
|
|
/* highlight */
|
|
.node.active {
|
|
outline: 2px solid #ffd54f;
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<div id="layout">
|
|
|
|
<!-- LEFT -->
|
|
<div id="controls">
|
|
|
|
<h3>Replay Controls</h3>
|
|
|
|
<button onclick="loadReplay()">Load Replay</button>
|
|
<button onclick="play()">▶ Play</button>
|
|
<button onclick="pause()">⏸ Pause</button>
|
|
<button onclick="step()">⏭ Step</button>
|
|
<button onclick="back()">⏮ Back</button>
|
|
|
|
<hr>
|
|
|
|
<input id="timeline" type="range" min="0" max="1" value="0" step="1"
|
|
oninput="seek(this.value)">
|
|
|
|
<div id="timeLabel">0</div>
|
|
|
|
</div>
|
|
|
|
<!-- CENTER GRAPH -->
|
|
<div id="graph"></div>
|
|
|
|
<!-- RIGHT DEBUG -->
|
|
<div id="debug">
|
|
<h3>Inspector</h3>
|
|
<pre id="inspector">Click a node</pre>
|
|
</div>
|
|
|
|
<div id="autopsyPanel">
|
|
<h3>Failure Autopsy</h3>
|
|
<input id="failAction" placeholder="action name"/>
|
|
<button onclick="runAutopsy()">Analyze</button>
|
|
<pre id="autopsyOut"></pre>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
|
|
let sessionId = null;
|
|
let events = [];
|
|
let index = 0;
|
|
let timer = null;
|
|
|
|
const nodes = {};
|
|
|
|
// -------------------------
|
|
// LOAD REPLAY
|
|
// -------------------------
|
|
async function loadReplay() {
|
|
|
|
const res = await fetch("/replay/start", {
|
|
method: "POST"
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
sessionId = data.session_id;
|
|
|
|
// in real system: fetch full journal snapshot
|
|
const journal = await fetch("/replay/events")
|
|
.then(r => r.json())
|
|
.catch(() => ({ events: [] }));
|
|
|
|
events = journal.events || [];
|
|
|
|
document.getElementById("timeline").max = events.length - 1;
|
|
}
|
|
|
|
|
|
async function runAutopsy() {
|
|
|
|
const action = document.getElementById("failAction").value;
|
|
|
|
const res = await fetch(`/autopsy/${sessionId}/${action}`);
|
|
|
|
const data = await res.json();
|
|
|
|
document.getElementById("autopsyOut").innerText =
|
|
JSON.stringify(data, null, 2);
|
|
}
|
|
|
|
// -------------------------
|
|
// RENDER EVENT
|
|
// -------------------------
|
|
function applyEvent(e) {
|
|
|
|
if (!e || !e.data) return;
|
|
|
|
const d = e.data;
|
|
|
|
const name = d.node || d.action;
|
|
|
|
if (!nodes[name]) {
|
|
|
|
const el = document.createElement("div");
|
|
el.className = "node";
|
|
el.innerText = name;
|
|
|
|
// deterministic layout
|
|
const x = 80 + (Object.keys(nodes).length % 5) * 160;
|
|
const y = 80 + Math.floor(Object.keys(nodes).length / 5) * 120;
|
|
|
|
el.style.left = x + "px";
|
|
el.style.top = y + "px";
|
|
|
|
el.onclick = () => inspect(d);
|
|
|
|
nodes[name] = el;
|
|
|
|
document.getElementById("graph").appendChild(el);
|
|
}
|
|
|
|
const el = nodes[name];
|
|
|
|
el.classList.remove("pending","running","done","failed","cache");
|
|
|
|
if (d.state) el.classList.add(d.state);
|
|
if (d.cache === "hit") el.classList.add("cache");
|
|
}
|
|
|
|
// -------------------------
|
|
// INSPECTOR
|
|
// -------------------------
|
|
function inspect(data) {
|
|
|
|
document.getElementById("inspector").innerText =
|
|
JSON.stringify(data, null, 2);
|
|
}
|
|
|
|
// -------------------------
|
|
// PLAYBACK ENGINE
|
|
// -------------------------
|
|
function renderFrame(i) {
|
|
|
|
index = i;
|
|
|
|
document.getElementById("timeline").value = i;
|
|
document.getElementById("timeLabel").innerText = i;
|
|
|
|
// reset view
|
|
document.getElementById("graph").innerHTML = "";
|
|
Object.keys(nodes).forEach(k => delete nodes[k]);
|
|
|
|
// replay up to index
|
|
for (let j = 0; j <= i; j++) {
|
|
applyEvent(events[j]);
|
|
}
|
|
}
|
|
|
|
// -------------------------
|
|
// CONTROLS
|
|
// -------------------------
|
|
function play() {
|
|
pause();
|
|
timer = setInterval(() => {
|
|
if (index < events.length - 1) {
|
|
renderFrame(++index);
|
|
} else {
|
|
pause();
|
|
}
|
|
}, 300);
|
|
}
|
|
|
|
function pause() {
|
|
clearInterval(timer);
|
|
}
|
|
|
|
function step() {
|
|
if (index < events.length - 1) {
|
|
renderFrame(++index);
|
|
}
|
|
}
|
|
|
|
function back() {
|
|
if (index > 0) {
|
|
renderFrame(--index);
|
|
}
|
|
}
|
|
|
|
function seek(i) {
|
|
renderFrame(parseInt(i));
|
|
}
|
|
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|