feat: display gossip dashboard from swactor (#4)
This commit is contained in:
parent
be53e9a52e
commit
d2e9f37e6e
4 changed files with 628 additions and 0 deletions
|
|
@ -67,6 +67,7 @@ async fn main() -> Result<()> {
|
|||
.route("/contact", get(serve_path("./routes/contact/index.html")?))
|
||||
.route("/contact/message", get(serve_path("./routes/contact/message/index.html")?))
|
||||
.route("/api/publish", post(publish_message))
|
||||
.nest_service("/gossip-dashboard", ServeDir::new("./routes/gossip-dashboard"))
|
||||
.nest_service("/routes", ServeDir::new("./routes"));
|
||||
|
||||
let addr = "0.0.0.0:3000";
|
||||
|
|
|
|||
11
deploy.sh
Executable file
11
deploy.sh
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
echo "=== Building ==="
|
||||
cargo run -- build
|
||||
echo "=== Stopping Service ==="
|
||||
ssh docean "systemctl stop zachery-web"
|
||||
echo "=== Copying to VPS ==="
|
||||
tar -cf - -C web-build . | ssh docean "tar -xf - -C /home/deploy/web-build/"
|
||||
echo "=== Starting Service ==="
|
||||
ssh docean "systemctl start zachery-web"
|
||||
echo "=== Done ==="
|
||||
615
routes/gossip-dashboard/index.html
Normal file
615
routes/gossip-dashboard/index.html
Normal file
|
|
@ -0,0 +1,615 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Gossip Simulation Dashboard</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; background: #0f1117; color: #e0e0e0; }
|
||||
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3a;
|
||||
}
|
||||
.header h1 { font-size: 18px; font-weight: 600; color: #c0c6d4; }
|
||||
.status-badge {
|
||||
display: flex; align-items: center; gap: 6px; font-size: 13px; color: #9ca3af;
|
||||
}
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #3b82f6; }
|
||||
.status-dot.done { background: #22c55e; }
|
||||
.status-dot.replay { background: #f59e0b; }
|
||||
|
||||
.main { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: auto 1fr; height: calc(100vh - 48px); }
|
||||
|
||||
.graph-panel {
|
||||
grid-row: 1 / 3; border-right: 1px solid #2a2d3a; position: relative;
|
||||
}
|
||||
canvas { width: 100%; height: 100%; display: block; }
|
||||
|
||||
.side-panel { display: flex; flex-direction: column; overflow: hidden; min-height: 0; }
|
||||
|
||||
.stats-panel {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 16px; border-bottom: 1px solid #2a2d3a; background: #161822;
|
||||
}
|
||||
.stats-panel h2 { font-size: 13px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.stat-item .stat-value { font-size: 22px; font-weight: 700; color: #e5e7eb; }
|
||||
.stat-item .stat-label { font-size: 11px; color: #6b7280; text-transform: uppercase; }
|
||||
|
||||
.worker-panel {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 16px; border-bottom: 1px solid #2a2d3a; height: 180px; overflow-y: auto;
|
||||
}
|
||||
.worker-panel h2 { font-size: 13px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; }
|
||||
.worker-columns { display: flex; gap: 8px; overflow-x: auto; }
|
||||
.worker-col {
|
||||
flex: 1; min-width: 160px; background: #1a1d2e; border-radius: 6px; padding: 8px;
|
||||
font-size: 11px; max-height: 140px; overflow-y: auto;
|
||||
}
|
||||
.worker-col-header { font-weight: 600; color: #818cf8; margin-bottom: 4px; font-size: 12px; }
|
||||
.worker-entry { color: #9ca3af; padding: 1px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.event-panel {
|
||||
flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
.event-panel h2 {
|
||||
font-size: 13px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
padding: 12px 16px 8px;
|
||||
}
|
||||
.event-table-wrap { flex: 1; overflow-y: auto; padding: 0 16px 8px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
th { text-align: left; color: #6b7280; font-weight: 500; padding: 4px 8px; border-bottom: 1px solid #2a2d3a; position: sticky; top: 0; background: #0f1117; }
|
||||
td { padding: 3px 8px; border-bottom: 1px solid #1a1d2e; white-space: nowrap; }
|
||||
tr.highlight-push td { background: rgba(59,130,246,0.1); }
|
||||
tr.highlight-set td { background: rgba(34,197,94,0.1); }
|
||||
|
||||
.replay-controls {
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 16px;
|
||||
background: #161822; border-bottom: 1px solid #2a2d3a;
|
||||
}
|
||||
.replay-controls button {
|
||||
background: #2a2d3a; color: #e0e0e0; border: none; border-radius: 4px;
|
||||
padding: 4px 10px; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.replay-controls button:hover { background: #3b3f52; }
|
||||
.replay-controls input[type=range] { flex: 1; }
|
||||
.replay-controls .replay-pos { font-size: 12px; color: #9ca3af; min-width: 60px; text-align: right; }
|
||||
.replay-controls .speed-group { display: flex; align-items: center; gap: 4px; margin-left: 8px; }
|
||||
.replay-controls .speed-group input {
|
||||
width: 64px; background: #1a1d2e; color: #e0e0e0; border: 1px solid #2a2d3a;
|
||||
border-radius: 4px; padding: 2px 6px; font-size: 12px; text-align: right;
|
||||
}
|
||||
.replay-controls .speed-group label { font-size: 11px; color: #6b7280; white-space: nowrap; }
|
||||
|
||||
.node-flash { animation: flash 0.4s ease-out; }
|
||||
@keyframes flash { 0% { filter: brightness(2); } 100% { filter: brightness(1); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>Gossip Simulation Dashboard</h1>
|
||||
<div class="status-badge">
|
||||
<div class="status-dot" id="statusDot"></div>
|
||||
<span id="statusText">Connecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div class="graph-panel">
|
||||
<canvas id="graphCanvas"></canvas>
|
||||
</div>
|
||||
<div class="side-panel">
|
||||
<div class="stats-panel">
|
||||
<h2>Stats</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item"><div class="stat-value" id="statNodes">0</div><div class="stat-label">Nodes</div></div>
|
||||
<div class="stat-item"><div class="stat-value" id="statEdges">0</div><div class="stat-label">Edges</div></div>
|
||||
<div class="stat-item"><div class="stat-value" id="statMessages">0</div><div class="stat-label">Messages</div></div>
|
||||
<div class="stat-item"><div class="stat-value" id="statRound">0/0</div><div class="stat-label">Round</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="worker-panel">
|
||||
<h2>Worker Logs</h2>
|
||||
<div class="worker-columns" id="workerColumns"></div>
|
||||
</div>
|
||||
<div class="event-panel">
|
||||
<h2>Event Log</h2>
|
||||
<div class="replay-controls" id="replayControls" style="display:none">
|
||||
<button id="btnFirst" title="First">|◀</button>
|
||||
<button id="btnPrev" title="Previous">◀</button>
|
||||
<button id="btnPlay" title="Play">▶</button>
|
||||
<button id="btnNext" title="Next">▶</button>
|
||||
<button id="btnLast" title="Last">▶|</button>
|
||||
<input type="range" id="replaySlider" min="0" max="0" value="0">
|
||||
<span class="replay-pos" id="replayPos">0/0</span>
|
||||
<span class="speed-group">
|
||||
<input type="number" id="speedInput" value="100" min="10" max="2000" step="10">
|
||||
<label>ms/event</label>
|
||||
</span>
|
||||
</div>
|
||||
<div class="event-table-wrap" id="eventTableWrap">
|
||||
<table>
|
||||
<thead><tr><th>Seq</th><th>Round</th><th>Thread</th><th>Node</th><th>Event</th><th>Details</th></tr></thead>
|
||||
<tbody id="eventTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const MODE = "replay"; // replaced by server: "live" or "replay"
|
||||
const MAX_TABLE_ROWS = 2000;
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────
|
||||
let nodes = [];
|
||||
let edges = [];
|
||||
let nodePositions = {};
|
||||
let allEvents = [];
|
||||
let replayCursor = 0;
|
||||
let workerLogs = {};
|
||||
let isDone = false;
|
||||
|
||||
// ── Canvas / Graph ─────────────────────────────────────────────────
|
||||
const canvas = document.getElementById('graphCanvas');
|
||||
const ctx2d = canvas.getContext('2d');
|
||||
let nodeFlash = {}; // nodeName -> timestamp
|
||||
let edgeFlash = {}; // "a->b" -> timestamp
|
||||
|
||||
function resizeCanvas() {
|
||||
const rect = canvas.parentElement.getBoundingClientRect();
|
||||
canvas.width = rect.width * devicePixelRatio;
|
||||
canvas.height = rect.height * devicePixelRatio;
|
||||
canvas.style.width = rect.width + 'px';
|
||||
canvas.style.height = rect.height + 'px';
|
||||
ctx2d.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
|
||||
}
|
||||
window.addEventListener('resize', () => { resizeCanvas(); drawGraph(); });
|
||||
|
||||
function initPositions() {
|
||||
const rect = canvas.parentElement.getBoundingClientRect();
|
||||
const cx = rect.width / 2, cy = rect.height / 2;
|
||||
const r = Math.min(cx, cy) * 0.7;
|
||||
const n = nodes.length;
|
||||
nodes.forEach((node, i) => {
|
||||
const angle = (2 * Math.PI * i) / n - Math.PI / 2;
|
||||
nodePositions[node.name] = { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) };
|
||||
});
|
||||
// Run force simulation
|
||||
runForceLayout(rect.width, rect.height);
|
||||
}
|
||||
|
||||
function runForceLayout(w, h) {
|
||||
const pos = nodePositions;
|
||||
const cx = w / 2, cy = h / 2;
|
||||
const k = Math.sqrt((w * h) / Math.max(nodes.length, 1));
|
||||
|
||||
for (let iter = 0; iter < 300; iter++) {
|
||||
const disp = {};
|
||||
nodes.forEach(n => { disp[n.name] = { x: 0, y: 0 }; });
|
||||
|
||||
// Repulsion
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const a = nodes[i].name, b = nodes[j].name;
|
||||
let dx = pos[a].x - pos[b].x, dy = pos[a].y - pos[b].y;
|
||||
let dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
||||
let force = (k * k) / dist;
|
||||
let fx = (dx / dist) * force, fy = (dy / dist) * force;
|
||||
disp[a].x += fx; disp[a].y += fy;
|
||||
disp[b].x -= fx; disp[b].y -= fy;
|
||||
}
|
||||
}
|
||||
|
||||
// Attraction (edges)
|
||||
edges.forEach(([a, b]) => {
|
||||
if (!pos[a] || !pos[b]) return;
|
||||
let dx = pos[a].x - pos[b].x, dy = pos[a].y - pos[b].y;
|
||||
let dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
||||
let force = (dist * dist) / k;
|
||||
let fx = (dx / dist) * force, fy = (dy / dist) * force;
|
||||
disp[a].x -= fx; disp[a].y -= fy;
|
||||
disp[b].x += fx; disp[b].y += fy;
|
||||
});
|
||||
|
||||
// Gravity toward center
|
||||
nodes.forEach(n => {
|
||||
let dx = pos[n.name].x - cx, dy = pos[n.name].y - cy;
|
||||
let dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
||||
disp[n.name].x -= dx * 0.01;
|
||||
disp[n.name].y -= dy * 0.01;
|
||||
});
|
||||
|
||||
// Apply with damping
|
||||
const temp = Math.max(0.1, 1 - iter / 300);
|
||||
nodes.forEach(n => {
|
||||
let d = disp[n.name];
|
||||
let dist = Math.sqrt(d.x * d.x + d.y * d.y) || 0.01;
|
||||
let cap = Math.min(dist, 10 * temp);
|
||||
pos[n.name].x += (d.x / dist) * cap;
|
||||
pos[n.name].y += (d.y / dist) * cap;
|
||||
// Keep within bounds
|
||||
pos[n.name].x = Math.max(40, Math.min(w - 40, pos[n.name].x));
|
||||
pos[n.name].y = Math.max(40, Math.min(h - 40, pos[n.name].y));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function drawGraph() {
|
||||
const rect = canvas.parentElement.getBoundingClientRect();
|
||||
const w = rect.width, h = rect.height;
|
||||
ctx2d.clearRect(0, 0, w, h);
|
||||
const now = performance.now();
|
||||
|
||||
// Draw edges
|
||||
edges.forEach(([a, b]) => {
|
||||
const pa = nodePositions[a], pb = nodePositions[b];
|
||||
if (!pa || !pb) return;
|
||||
const key = a + '->' + b;
|
||||
const flash = edgeFlash[key];
|
||||
let alpha = 0.25;
|
||||
if (flash && now - flash < 600) {
|
||||
alpha = 0.25 + 0.75 * (1 - (now - flash) / 600);
|
||||
}
|
||||
ctx2d.beginPath();
|
||||
ctx2d.moveTo(pa.x, pa.y);
|
||||
ctx2d.lineTo(pb.x, pb.y);
|
||||
ctx2d.strokeStyle = `rgba(99, 102, 241, ${alpha})`;
|
||||
ctx2d.lineWidth = flash && now - flash < 600 ? 2.5 : 1;
|
||||
ctx2d.stroke();
|
||||
});
|
||||
|
||||
// Draw nodes
|
||||
nodes.forEach(node => {
|
||||
const p = nodePositions[node.name];
|
||||
if (!p) return;
|
||||
const flash = nodeFlash[node.name];
|
||||
let radius = 8;
|
||||
let color = '#6366f1';
|
||||
if (flash && now - flash < 400) {
|
||||
const t = 1 - (now - flash) / 400;
|
||||
radius = 8 + 6 * t;
|
||||
color = '#818cf8';
|
||||
}
|
||||
ctx2d.beginPath();
|
||||
ctx2d.arc(p.x, p.y, radius, 0, 2 * Math.PI);
|
||||
ctx2d.fillStyle = color;
|
||||
ctx2d.fill();
|
||||
ctx2d.strokeStyle = '#4f46e5';
|
||||
ctx2d.lineWidth = 1.5;
|
||||
ctx2d.stroke();
|
||||
|
||||
ctx2d.fillStyle = '#c7d2fe';
|
||||
ctx2d.font = '11px system-ui, sans-serif';
|
||||
ctx2d.textAlign = 'center';
|
||||
ctx2d.fillText(node.name, p.x, p.y + radius + 14);
|
||||
});
|
||||
|
||||
// Continue animation if flashes active
|
||||
let anyFlash = false;
|
||||
for (const t of Object.values(nodeFlash)) { if (now - t < 400) anyFlash = true; }
|
||||
for (const t of Object.values(edgeFlash)) { if (now - t < 600) anyFlash = true; }
|
||||
if (anyFlash) requestAnimationFrame(drawGraph);
|
||||
}
|
||||
|
||||
// ── UI updates ─────────────────────────────────────────────────────
|
||||
function updateStats(s) {
|
||||
document.getElementById('statNodes').textContent = s.total_nodes;
|
||||
document.getElementById('statEdges').textContent = s.total_edges;
|
||||
document.getElementById('statMessages').textContent = s.total_messages;
|
||||
document.getElementById('statRound').textContent = s.current_round + '/' + s.total_rounds;
|
||||
}
|
||||
|
||||
function makeRow(ev) {
|
||||
const tr = document.createElement('tr');
|
||||
if (ev.kind === 'GossipRoundStarted') tr.className = 'highlight-push';
|
||||
if (ev.kind === 'LocalSet') tr.className = 'highlight-set';
|
||||
const detailStr = formatDetail(ev.kind, ev.detail);
|
||||
tr.innerHTML = `<td>${ev.seq}</td><td>${ev.tick}</td><td>${ev.thread || '-'}</td><td>${ev.node}</td><td>${ev.kind}</td><td>${detailStr}</td>`;
|
||||
return tr;
|
||||
}
|
||||
|
||||
function addEventToTable(ev) {
|
||||
const tbody = document.getElementById('eventTableBody');
|
||||
if (tbody.children.length >= MAX_TABLE_ROWS) {
|
||||
tbody.removeChild(tbody.firstChild);
|
||||
}
|
||||
tbody.appendChild(makeRow(ev));
|
||||
|
||||
const wrap = document.getElementById('eventTableWrap');
|
||||
wrap.scrollTop = wrap.scrollHeight;
|
||||
}
|
||||
|
||||
function formatDetail(kind, detail) {
|
||||
if (!detail) return '';
|
||||
switch (kind) {
|
||||
case 'GossipRoundStarted': return '→ ' + detail.target;
|
||||
case 'PushReceived': return '← ' + detail.from + ' (' + detail.keys_updated + ' keys)';
|
||||
case 'LocalSet': return 'key=' + detail.key;
|
||||
case 'PeerAdded': return '+ ' + detail.peer;
|
||||
case 'PeerRemoved': return '- ' + detail.peer;
|
||||
case 'QueryReceived': return 'key=' + detail.key;
|
||||
case 'StateSnapshot': return detail.entries + ' entries, ' + detail.peer_count + ' peers';
|
||||
default: return JSON.stringify(detail);
|
||||
}
|
||||
}
|
||||
|
||||
function addWorkerEntry(thread, text) {
|
||||
if (!thread) return;
|
||||
if (!workerLogs[thread]) {
|
||||
workerLogs[thread] = [];
|
||||
rebuildWorkerColumns();
|
||||
}
|
||||
workerLogs[thread].push(text);
|
||||
if (workerLogs[thread].length > 50) workerLogs[thread].shift();
|
||||
updateWorkerColumn(thread);
|
||||
}
|
||||
|
||||
function rebuildWorkerColumns() {
|
||||
const container = document.getElementById('workerColumns');
|
||||
container.innerHTML = '';
|
||||
for (const thread of Object.keys(workerLogs).sort()) {
|
||||
const col = document.createElement('div');
|
||||
col.className = 'worker-col';
|
||||
col.id = 'worker-' + thread;
|
||||
col.innerHTML = '<div class="worker-col-header">' + thread + '</div>';
|
||||
container.appendChild(col);
|
||||
}
|
||||
}
|
||||
|
||||
function updateWorkerColumn(thread) {
|
||||
const col = document.getElementById('worker-' + thread);
|
||||
if (!col) return;
|
||||
const entries = workerLogs[thread];
|
||||
// Keep header + entries
|
||||
let html = '<div class="worker-col-header">' + thread + '</div>';
|
||||
for (const e of entries) {
|
||||
html += '<div class="worker-entry">' + e + '</div>';
|
||||
}
|
||||
col.innerHTML = html;
|
||||
col.scrollTop = col.scrollHeight;
|
||||
}
|
||||
|
||||
function processEvent(ev) {
|
||||
addEventToTable(ev);
|
||||
|
||||
// Flash node
|
||||
nodeFlash[ev.node] = performance.now();
|
||||
|
||||
// Flash edges on Push
|
||||
if (ev.kind === 'GossipRoundStarted' && ev.detail && ev.detail.target) {
|
||||
edgeFlash[ev.node + '->' + ev.detail.target] = performance.now();
|
||||
edgeFlash[ev.detail.target + '->' + ev.node] = performance.now();
|
||||
}
|
||||
if (ev.kind === 'PushReceived' && ev.detail && ev.detail.from) {
|
||||
edgeFlash[ev.detail.from + '->' + ev.node] = performance.now();
|
||||
edgeFlash[ev.node + '->' + ev.detail.from] = performance.now();
|
||||
}
|
||||
|
||||
// Worker log
|
||||
let text = ev.node + ': ' + ev.kind;
|
||||
if (ev.kind === 'GossipRoundStarted') text += ' -> ' + ev.detail.target;
|
||||
if (ev.kind === 'PushReceived') text += ' <- ' + ev.detail.from;
|
||||
addWorkerEntry(ev.thread, text);
|
||||
|
||||
requestAnimationFrame(drawGraph);
|
||||
}
|
||||
|
||||
// ── Live mode (SSE) ────────────────────────────────────────────────
|
||||
function startLive() {
|
||||
const dot = document.getElementById('statusDot');
|
||||
const statusText = document.getElementById('statusText');
|
||||
|
||||
const es = new EventSource('/events');
|
||||
|
||||
es.addEventListener('init', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
nodes = data.nodes;
|
||||
edges = data.edges;
|
||||
statusText.textContent = 'Live: ' + data.name;
|
||||
dot.className = 'status-dot';
|
||||
resizeCanvas();
|
||||
initPositions();
|
||||
drawGraph();
|
||||
});
|
||||
|
||||
es.addEventListener('gossip', (e) => {
|
||||
const ev = JSON.parse(e.data);
|
||||
allEvents.push(ev);
|
||||
processEvent(ev);
|
||||
});
|
||||
|
||||
es.addEventListener('stats', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
updateStats(data);
|
||||
});
|
||||
|
||||
es.addEventListener('done', (e) => {
|
||||
isDone = true;
|
||||
dot.className = 'status-dot done';
|
||||
statusText.textContent += ' (done)';
|
||||
es.close();
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
if (!isDone) {
|
||||
statusText.textContent = 'Disconnected';
|
||||
dot.style.background = '#ef4444';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ── Replay mode ────────────────────────────────────────────────────
|
||||
async function startReplay() {
|
||||
const dot = document.getElementById('statusDot');
|
||||
const statusText = document.getElementById('statusText');
|
||||
dot.className = 'status-dot replay';
|
||||
|
||||
const resp = await fetch('/gossip-dashboard/trace.json');
|
||||
const trace = await resp.json();
|
||||
|
||||
nodes = trace.node_names.map((name, i) => ({
|
||||
name,
|
||||
addr: trace.node_addrs[i] ? JSON.stringify(trace.node_addrs[i]) : ''
|
||||
}));
|
||||
edges = trace.topology_edges.map(e => [e[0], e[1]]);
|
||||
|
||||
// Build events list (filter out StateSnapshot for display)
|
||||
let seq = 0;
|
||||
allEvents = trace.events
|
||||
.filter(ev => ev.kind !== 'StateSnapshot')
|
||||
.map(ev => {
|
||||
const kind = typeof ev.kind === 'string' ? ev.kind : Object.keys(ev.kind)[0];
|
||||
const detail = typeof ev.kind === 'string' ? {} : ev.kind[kind] || {};
|
||||
return {
|
||||
seq: seq++,
|
||||
tick: ev.tick,
|
||||
node: ev.node_name,
|
||||
thread: ev.thread_name,
|
||||
kind,
|
||||
detail
|
||||
};
|
||||
});
|
||||
|
||||
statusText.textContent = 'Replay: ' + trace.name + ' (' + allEvents.length + ' events)';
|
||||
updateStats({
|
||||
total_nodes: trace.node_names.length,
|
||||
total_edges: trace.topology_edges.length,
|
||||
total_messages: allEvents.length,
|
||||
current_round: trace.num_rounds,
|
||||
total_rounds: trace.num_rounds
|
||||
});
|
||||
|
||||
resizeCanvas();
|
||||
initPositions();
|
||||
drawGraph();
|
||||
|
||||
// Show replay controls
|
||||
const controls = document.getElementById('replayControls');
|
||||
controls.style.display = 'flex';
|
||||
const slider = document.getElementById('replaySlider');
|
||||
slider.max = allEvents.length;
|
||||
slider.value = 0;
|
||||
replayCursor = 0;
|
||||
updateReplayPos();
|
||||
|
||||
document.getElementById('btnFirst').onclick = () => { stopPlayback(); replayTo(0); };
|
||||
document.getElementById('btnPrev').onclick = () => { stopPlayback(); replayTo(Math.max(0, replayCursor - 1)); };
|
||||
document.getElementById('btnNext').onclick = () => { stopPlayback(); replayTo(Math.min(allEvents.length, replayCursor + 1)); };
|
||||
document.getElementById('btnLast').onclick = () => { stopPlayback(); replayTo(allEvents.length); };
|
||||
slider.oninput = () => { stopPlayback(); replayTo(parseInt(slider.value)); };
|
||||
|
||||
document.getElementById('btnPlay').onclick = () => {
|
||||
if (playTimer !== null) { stopPlayback(); } else { startPlayback(); }
|
||||
};
|
||||
document.getElementById('speedInput').onchange = () => {
|
||||
if (playTimer !== null) { startPlayback(); }
|
||||
};
|
||||
}
|
||||
|
||||
let replayRafId = 0;
|
||||
let playTimer = null;
|
||||
|
||||
function stopPlayback() {
|
||||
if (playTimer !== null) {
|
||||
clearInterval(playTimer);
|
||||
playTimer = null;
|
||||
document.getElementById('btnPlay').innerHTML = '▶';
|
||||
document.getElementById('btnPlay').title = 'Play';
|
||||
}
|
||||
}
|
||||
|
||||
function startPlayback() {
|
||||
stopPlayback();
|
||||
if (replayCursor >= allEvents.length) return; // already at end
|
||||
const speed = parseInt(document.getElementById('speedInput').value) || 100;
|
||||
document.getElementById('btnPlay').innerHTML = '⏸';
|
||||
document.getElementById('btnPlay').title = 'Pause';
|
||||
playTimer = setInterval(() => {
|
||||
if (replayCursor >= allEvents.length) {
|
||||
stopPlayback();
|
||||
return;
|
||||
}
|
||||
replayTo(replayCursor + 1);
|
||||
}, speed);
|
||||
}
|
||||
|
||||
function replayTo(pos) {
|
||||
// Debounce: only run once per animation frame
|
||||
replayCursor = pos;
|
||||
if (replayRafId) return;
|
||||
replayRafId = requestAnimationFrame(() => {
|
||||
replayRafId = 0;
|
||||
replayToImpl(replayCursor);
|
||||
});
|
||||
}
|
||||
|
||||
function replayToImpl(pos) {
|
||||
nodeFlash = {};
|
||||
edgeFlash = {};
|
||||
|
||||
// ── Build table rows into a fragment (no reflows) ──
|
||||
const frag = document.createDocumentFragment();
|
||||
const start = Math.max(0, pos - MAX_TABLE_ROWS);
|
||||
for (let i = start; i < pos && i < allEvents.length; i++) {
|
||||
frag.appendChild(makeRow(allEvents[i]));
|
||||
}
|
||||
const tbody = document.getElementById('eventTableBody');
|
||||
tbody.textContent = ''; // fast clear
|
||||
tbody.appendChild(frag);
|
||||
|
||||
const wrap = document.getElementById('eventTableWrap');
|
||||
wrap.scrollTop = wrap.scrollHeight;
|
||||
|
||||
// ── Rebuild worker logs in one pass ──
|
||||
workerLogs = {};
|
||||
const workerStart = Math.max(0, pos - 200); // only last ~200 events for worker logs
|
||||
for (let i = workerStart; i < pos && i < allEvents.length; i++) {
|
||||
const ev = allEvents[i];
|
||||
if (!ev.thread) continue;
|
||||
if (!workerLogs[ev.thread]) workerLogs[ev.thread] = [];
|
||||
let text = ev.node + ': ' + ev.kind;
|
||||
if (ev.kind === 'GossipRoundStarted' && ev.detail) text += ' -> ' + ev.detail.target;
|
||||
if (ev.kind === 'PushReceived' && ev.detail) text += ' <- ' + ev.detail.from;
|
||||
workerLogs[ev.thread].push(text);
|
||||
if (workerLogs[ev.thread].length > 50) workerLogs[ev.thread].shift();
|
||||
}
|
||||
rebuildWorkerColumns();
|
||||
for (const thread of Object.keys(workerLogs)) {
|
||||
updateWorkerColumn(thread);
|
||||
}
|
||||
|
||||
// Flash the last event
|
||||
if (pos > 0 && pos <= allEvents.length) {
|
||||
const ev = allEvents[pos - 1];
|
||||
nodeFlash[ev.node] = performance.now();
|
||||
if (ev.kind === 'GossipRoundStarted' && ev.detail && ev.detail.target) {
|
||||
edgeFlash[ev.node + '->' + ev.detail.target] = performance.now();
|
||||
}
|
||||
if (ev.kind === 'PushReceived' && ev.detail && ev.detail.from) {
|
||||
edgeFlash[ev.detail.from + '->' + ev.node] = performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('replaySlider').value = pos;
|
||||
updateReplayPos();
|
||||
drawGraph();
|
||||
}
|
||||
|
||||
function updateReplayPos() {
|
||||
document.getElementById('replayPos').textContent = replayCursor + '/' + allEvents.length;
|
||||
}
|
||||
|
||||
// ── Boot ───────────────────────────────────────────────────────────
|
||||
resizeCanvas();
|
||||
if (MODE === 'replay') {
|
||||
startReplay();
|
||||
} else {
|
||||
startLive();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
routes/gossip-dashboard/trace.json
Normal file
1
routes/gossip-dashboard/trace.json
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue