736 lines
32 KiB
Rust
736 lines
32 KiB
Rust
pub const DASHBOARD_HTML: &str = r##"<!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; }
|
|
.trace-select { background: #1a1d2e; color: #e0e0e0; border: 1px solid #2a2d3a; border-radius: 4px; padding: 6px 12px; font-size: 13px; cursor: pointer; min-width: 240px; max-width: 420px; }
|
|
.trace-select:hover { border-color: #4f46e5; }
|
|
.trace-select:focus { outline: none; border-color: #6366f1; }
|
|
.trace-select:disabled { cursor: default; opacity: 0.5; }
|
|
.status-badge { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #9ca3af; }
|
|
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #f59e0b; }
|
|
.status-dot.ready { background: #22c55e; }
|
|
.status-dot.loading { background: #3b82f6; animation: pulse 1s infinite; }
|
|
.status-dot.error { background: #ef4444; }
|
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
.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; }
|
|
.layout-progress { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(22,24,34,0.9); border: 1px solid #2a2d3a; border-radius: 8px; padding: 16px 24px; text-align: center; font-size: 13px; color: #9ca3af; display: none; z-index: 10; }
|
|
.layout-progress .progress-bar { width: 200px; height: 4px; background: #2a2d3a; border-radius: 2px; margin-top: 8px; overflow: hidden; }
|
|
.layout-progress .progress-fill { height: 100%; background: #6366f1; border-radius: 2px; width: 0%; transition: width 0.1s; }
|
|
.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; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>Gossip Dashboard</h1>
|
|
<select id="traceSelect" class="trace-select" disabled><option value="">Loading traces...</option></select>
|
|
<div class="status-badge">
|
|
<div class="status-dot loading" id="statusDot"></div>
|
|
<span id="statusText">Loading traces...</span>
|
|
</div>
|
|
</div>
|
|
<div class="main">
|
|
<div class="graph-panel">
|
|
<canvas id="graphCanvas"></canvas>
|
|
<div class="layout-progress" id="layoutProgress">
|
|
<div>Computing layout...</div>
|
|
<div class="progress-bar"><div class="progress-fill" id="layoutProgressFill"></div></div>
|
|
</div>
|
|
</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 MAX_TABLE_ROWS = 2000;
|
|
|
|
// ── Node data (indexed by integer) ────────────────────────────────
|
|
let nodeNames = []; // nodeNames[i] = "node_42"
|
|
let nodeIdx = new Map(); // "node_42" -> 0
|
|
let N = 0;
|
|
let posX = new Float64Array(0);
|
|
let posY = new Float64Array(0);
|
|
|
|
// ── Edge data (flat typed arrays, sorted by appearance index) ─────
|
|
let totalEdges = 0;
|
|
let edgeSrc = new Int32Array(0); // source node index
|
|
let edgeDst = new Int32Array(0); // dest node index
|
|
let edgeAppear = new Int32Array(0); // appearance event index (-1 = initial)
|
|
let visEdges = 0; // edges[0..visEdges-1] are visible
|
|
|
|
// ── Flash state (at most 1 node + 1 edge at a time) ──────────────
|
|
let flashNode = -1, flashNodeT = 0;
|
|
let flashSrc = -1, flashDst = -1, flashEdgeT = 0;
|
|
|
|
// ── Events & replay ──────────────────────────────────────────────
|
|
let allEvents = [];
|
|
let replayCursor = 0;
|
|
let workerLogs = {};
|
|
let lastTablePos = -1, lastWorkerPos = -1;
|
|
|
|
// ── View transform ───────────────────────────────────────────────
|
|
let vx = 0, vy = 0, vs = 1; // view x, y, scale
|
|
let isDragging = false, dragX = 0, dragY = 0, dragVx = 0, dragVy = 0;
|
|
|
|
// ── Quadtree pool (flat Float64Array, reused across iterations) ──
|
|
// Per node: [ox, oy, size, cx, cy, mass, c0, c1, c2, c3, body]
|
|
const QF = 11;
|
|
let qt = new Float64Array(0);
|
|
let qtN = 0;
|
|
let _fx = 0, _fy = 0; // force accumulator (avoids object alloc)
|
|
|
|
let layoutAbortFlag = false;
|
|
|
|
// ── Canvas ───────────────────────────────────────────────────────
|
|
const canvas = document.getElementById('graphCanvas');
|
|
const ctx2d = canvas.getContext('2d');
|
|
|
|
function resizeCanvas() {
|
|
const r = canvas.parentElement.getBoundingClientRect();
|
|
canvas.width = r.width * devicePixelRatio;
|
|
canvas.height = r.height * devicePixelRatio;
|
|
canvas.style.width = r.width + 'px';
|
|
canvas.style.height = r.height + 'px';
|
|
}
|
|
window.addEventListener('resize', () => { resizeCanvas(); drawGraph(); });
|
|
|
|
// ── Zoom & pan ───────────────────────────────────────────────────
|
|
canvas.addEventListener('wheel', (e) => {
|
|
e.preventDefault();
|
|
const r = canvas.getBoundingClientRect();
|
|
const mx = e.clientX - r.left, my = e.clientY - r.top;
|
|
const f = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
|
const ns = Math.max(0.05, Math.min(5, vs * f));
|
|
const ratio = ns / vs;
|
|
vx = mx - (mx - vx) * ratio;
|
|
vy = my - (my - vy) * ratio;
|
|
vs = ns;
|
|
drawGraph();
|
|
}, { passive: false });
|
|
|
|
canvas.addEventListener('mousedown', (e) => {
|
|
if (e.button !== 0) return;
|
|
isDragging = true;
|
|
dragX = e.clientX; dragY = e.clientY;
|
|
dragVx = vx; dragVy = vy;
|
|
canvas.style.cursor = 'grabbing';
|
|
});
|
|
window.addEventListener('mousemove', (e) => {
|
|
if (!isDragging) return;
|
|
vx = dragVx + (e.clientX - dragX);
|
|
vy = dragVy + (e.clientY - dragY);
|
|
drawGraph();
|
|
});
|
|
window.addEventListener('mouseup', () => {
|
|
if (isDragging) { isDragging = false; canvas.style.cursor = ''; }
|
|
});
|
|
canvas.addEventListener('dblclick', () => { resetView(); drawGraph(); });
|
|
|
|
function resetView() {
|
|
if (N === 0) { vx = 0; vy = 0; vs = 1; return; }
|
|
const r = canvas.parentElement.getBoundingClientRect();
|
|
const w = r.width, h = r.height;
|
|
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
for (let i = 0; i < N; i++) {
|
|
if (posX[i] < x0) x0 = posX[i]; if (posY[i] < y0) y0 = posY[i];
|
|
if (posX[i] > x1) x1 = posX[i]; if (posY[i] > y1) y1 = posY[i];
|
|
}
|
|
if (!isFinite(x0)) { vx = 0; vy = 0; vs = 1; return; }
|
|
const pad = 40;
|
|
vs = Math.min(w / (x1 - x0 + pad * 2), h / (y1 - y0 + pad * 2), 2);
|
|
vx = (w - (x0 + x1) * vs) / 2;
|
|
vy = (h - (y0 + y1) * vs) / 2;
|
|
}
|
|
|
|
// ── Quadtree (flat array, zero-alloc per iteration) ──────────────
|
|
function qtAlloc(ox, oy, sz) {
|
|
if (qtN * QF >= qt.length) {
|
|
const nq = new Float64Array(Math.max(qt.length * 2, 256 * QF));
|
|
nq.set(qt); qt = nq;
|
|
}
|
|
const i = qtN++, o = i * QF;
|
|
qt[o]=ox; qt[o+1]=oy; qt[o+2]=sz;
|
|
qt[o+3]=0; qt[o+4]=0; qt[o+5]=0;
|
|
qt[o+6]=-1; qt[o+7]=-1; qt[o+8]=-1; qt[o+9]=-1;
|
|
qt[o+10]=-1;
|
|
return i;
|
|
}
|
|
|
|
function qtBuild() {
|
|
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
for (let i = 0; i < N; i++) {
|
|
if (posX[i] < x0) x0 = posX[i]; if (posY[i] < y0) y0 = posY[i];
|
|
if (posX[i] > x1) x1 = posX[i]; if (posY[i] > y1) y1 = posY[i];
|
|
}
|
|
const sz = Math.max(x1 - x0, y1 - y0, 1) + 2;
|
|
qtN = 0;
|
|
qtAlloc(x0 - 1, y0 - 1, sz);
|
|
for (let i = 0; i < N; i++) qtIns(0, i, posX[i], posY[i]);
|
|
}
|
|
|
|
function qtIns(ni, bi, bx, by) {
|
|
const o = ni * QF;
|
|
if (qt[o+5] === 0) {
|
|
qt[o+3] = bx; qt[o+4] = by; qt[o+5] = 1; qt[o+10] = bi;
|
|
return;
|
|
}
|
|
if (qt[o+10] >= 0) {
|
|
if (qt[o+2] < 0.001) { qt[o+5]++; return; } // cell too small, just accumulate
|
|
const eb = qt[o+10], ex = qt[o+3], ey = qt[o+4];
|
|
qt[o+10] = -1;
|
|
qtInsChild(ni, eb, ex, ey);
|
|
}
|
|
const m = qt[o+5];
|
|
qt[o+3] = (qt[o+3]*m + bx) / (m+1);
|
|
qt[o+4] = (qt[o+4]*m + by) / (m+1);
|
|
qt[o+5] = m + 1;
|
|
qtInsChild(ni, bi, bx, by);
|
|
}
|
|
|
|
function qtInsChild(ni, bi, bx, by) {
|
|
const o = ni * QF, hs = qt[o+2] / 2;
|
|
const mx = qt[o] + hs, my = qt[o+1] + hs;
|
|
const qx = bx < mx ? 0 : 1, qy = by < my ? 0 : 1;
|
|
const ci = o + 6 + qy * 2 + qx;
|
|
if (qt[ci] < 0) qt[ci] = qtAlloc(qx ? mx : qt[o], qy ? my : qt[o+1], hs);
|
|
qtIns(qt[ci], bi, bx, by);
|
|
}
|
|
|
|
function qtCalc(ni, px, py, k2, th2) {
|
|
if (ni < 0) return;
|
|
const o = ni * QF;
|
|
if (qt[o+5] === 0) return;
|
|
const dx = qt[o+3] - px, dy = qt[o+4] - py;
|
|
let d2 = dx*dx + dy*dy;
|
|
if (d2 < 0.0001) d2 = 0.0001;
|
|
if (qt[o+10] >= 0 || qt[o+2]*qt[o+2]/d2 < th2) {
|
|
const d = Math.sqrt(d2), f = -(k2 * qt[o+5]) / d2;
|
|
_fx += (dx/d)*f; _fy += (dy/d)*f;
|
|
return;
|
|
}
|
|
for (let c = 6; c < 10; c++) if (qt[o+c] >= 0) qtCalc(qt[o+c], px, py, k2, th2);
|
|
}
|
|
|
|
// ── Force layout ─────────────────────────────────────────────────
|
|
function initPositions() {
|
|
const r = canvas.parentElement.getBoundingClientRect();
|
|
const cx = r.width / 2, cy = r.height / 2;
|
|
const rad = Math.min(cx, cy) * 0.7;
|
|
for (let i = 0; i < N; i++) {
|
|
const a = (2 * Math.PI * i) / N - Math.PI / 2;
|
|
posX[i] = cx + rad * Math.cos(a);
|
|
posY[i] = cy + rad * Math.sin(a);
|
|
}
|
|
}
|
|
|
|
function runForceLayout(w, h) {
|
|
return new Promise(resolve => {
|
|
layoutAbortFlag = false;
|
|
const cx = w/2, cy = h/2;
|
|
const k = Math.sqrt(w*h / Math.max(N, 1)), k2 = k*k;
|
|
const totalIters = Math.min(300, Math.max(50, Math.floor(40000 / Math.max(N, 1))));
|
|
const CHUNK = 50, useBH = N > 100, th2 = 0.64;
|
|
const dx = new Float64Array(N), dy = new Float64Array(N);
|
|
const progressEl = document.getElementById('layoutProgress');
|
|
const fillEl = document.getElementById('layoutProgressFill');
|
|
if (N > 50) { progressEl.style.display = 'block'; fillEl.style.width = '0%'; }
|
|
let iter = 0;
|
|
|
|
function doChunk() {
|
|
if (layoutAbortFlag) { progressEl.style.display = 'none'; resolve(); return; }
|
|
const end = Math.min(iter + CHUNK, totalIters);
|
|
for (; iter < end; iter++) {
|
|
dx.fill(0); dy.fill(0);
|
|
|
|
if (useBH) {
|
|
qtBuild();
|
|
for (let i = 0; i < N; i++) {
|
|
_fx = 0; _fy = 0;
|
|
qtCalc(0, posX[i], posY[i], k2, th2);
|
|
dx[i] += _fx; dy[i] += _fy;
|
|
}
|
|
} else {
|
|
for (let i = 0; i < N; i++) for (let j = i+1; j < N; j++) {
|
|
let ddx = posX[i]-posX[j], ddy = posY[i]-posY[j];
|
|
let dist = Math.sqrt(ddx*ddx + ddy*ddy) || 0.01;
|
|
let f = k2/dist, fx = (ddx/dist)*f, fy = (ddy/dist)*f;
|
|
dx[i] += fx; dy[i] += fy; dx[j] -= fx; dy[j] -= fy;
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < visEdges; i++) {
|
|
const si = edgeSrc[i], di = edgeDst[i];
|
|
let ddx = posX[si]-posX[di], ddy = posY[si]-posY[di];
|
|
let dist = Math.sqrt(ddx*ddx + ddy*ddy) || 0.01;
|
|
let f = (dist*dist)/k, fx = (ddx/dist)*f, fy = (ddy/dist)*f;
|
|
dx[si] -= fx; dy[si] -= fy; dx[di] += fx; dy[di] += fy;
|
|
}
|
|
|
|
for (let i = 0; i < N; i++) {
|
|
dx[i] -= (posX[i]-cx)*0.01; dy[i] -= (posY[i]-cy)*0.01;
|
|
}
|
|
|
|
const temp = Math.max(0.1, 1 - iter/totalIters);
|
|
for (let i = 0; i < N; i++) {
|
|
let dist = Math.sqrt(dx[i]*dx[i] + dy[i]*dy[i]) || 0.01;
|
|
let cap = Math.min(dist, 10*temp);
|
|
posX[i] += (dx[i]/dist)*cap; posY[i] += (dy[i]/dist)*cap;
|
|
posX[i] = Math.max(40, Math.min(w-40, posX[i]));
|
|
posY[i] = Math.max(40, Math.min(h-40, posY[i]));
|
|
}
|
|
}
|
|
fillEl.style.width = Math.round(100*iter/totalIters) + '%';
|
|
if (iter < totalIters) { drawGraph(); setTimeout(doChunk, 0); }
|
|
else { progressEl.style.display = 'none'; resetView(); drawGraph(); resolve(); }
|
|
}
|
|
doChunk();
|
|
});
|
|
}
|
|
|
|
// ── Drawing ──────────────────────────────────────────────────────
|
|
function drawGraph() {
|
|
const r = canvas.parentElement.getBoundingClientRect();
|
|
const w = r.width, h = r.height, dpr = devicePixelRatio, now = performance.now();
|
|
|
|
ctx2d.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
ctx2d.clearRect(0, 0, w, h);
|
|
ctx2d.setTransform(dpr*vs, 0, 0, dpr*vs, dpr*vx, dpr*vy);
|
|
|
|
// Viewport in world coordinates
|
|
const v0x = -vx/vs, v0y = -vy/vs, v1x = (w-vx)/vs, v1y = (h-vy)/vs;
|
|
|
|
// Adaptive sizing
|
|
const baseR = Math.max(2, Math.min(8, 400/Math.sqrt(Math.max(N, 1))));
|
|
const showLabels = vs * baseR > 6, showStroke = N <= 200;
|
|
|
|
// Flash state
|
|
const hasNodeFlash = flashNode >= 0 && now - flashNodeT < 400;
|
|
const hasEdgeFlash = flashSrc >= 0 && now - flashEdgeT < 600;
|
|
|
|
// ── Edges ──
|
|
if (!(vs < 0.15 && visEdges > 10000)) {
|
|
const baseAlpha = Math.min(0.25, 40/Math.sqrt(Math.max(visEdges, 1)));
|
|
let flashEdgeI = -1;
|
|
|
|
ctx2d.beginPath();
|
|
ctx2d.strokeStyle = 'rgba(99,102,241,' + baseAlpha + ')';
|
|
ctx2d.lineWidth = 1/vs;
|
|
let batched = 0;
|
|
|
|
for (let i = 0; i < visEdges; i++) {
|
|
const si = edgeSrc[i], di = edgeDst[i];
|
|
const sx = posX[si], sy = posY[si], dx = posX[di], dy = posY[di];
|
|
if ((sx < v0x && dx < v0x) || (sx > v1x && dx > v1x) ||
|
|
(sy < v0y && dy < v0y) || (sy > v1y && dy > v1y)) continue;
|
|
if (hasEdgeFlash && si === flashSrc && di === flashDst) { flashEdgeI = i; continue; }
|
|
ctx2d.moveTo(sx, sy); ctx2d.lineTo(dx, dy); batched++;
|
|
}
|
|
if (batched > 0) ctx2d.stroke();
|
|
|
|
if (flashEdgeI >= 0) {
|
|
const si = edgeSrc[flashEdgeI], di = edgeDst[flashEdgeI];
|
|
const alpha = baseAlpha + (1-baseAlpha) * (1-(now-flashEdgeT)/600);
|
|
ctx2d.beginPath();
|
|
ctx2d.moveTo(posX[si], posY[si]); ctx2d.lineTo(posX[di], posY[di]);
|
|
ctx2d.strokeStyle = 'rgba(99,102,241,' + alpha + ')';
|
|
ctx2d.lineWidth = 2.5/vs;
|
|
ctx2d.stroke();
|
|
}
|
|
}
|
|
|
|
// ── Nodes (batched) ──
|
|
let flashNodeI = -1;
|
|
ctx2d.beginPath();
|
|
for (let i = 0; i < N; i++) {
|
|
const px = posX[i], py = posY[i];
|
|
if (px+baseR < v0x || px-baseR > v1x || py+baseR < v0y || py-baseR > v1y) continue;
|
|
if (hasNodeFlash && i === flashNode) { flashNodeI = i; continue; }
|
|
ctx2d.moveTo(px+baseR, py); ctx2d.arc(px, py, baseR, 0, 2*Math.PI);
|
|
}
|
|
ctx2d.fillStyle = '#6366f1'; ctx2d.fill();
|
|
if (showStroke) { ctx2d.strokeStyle = '#4f46e5'; ctx2d.lineWidth = 1.5/vs; ctx2d.stroke(); }
|
|
|
|
if (flashNodeI >= 0) {
|
|
const t = 1-(now-flashNodeT)/400, rad = baseR+6*t;
|
|
ctx2d.beginPath();
|
|
ctx2d.arc(posX[flashNodeI], posY[flashNodeI], rad, 0, 2*Math.PI);
|
|
ctx2d.fillStyle = '#818cf8'; ctx2d.fill();
|
|
if (showStroke) { ctx2d.strokeStyle = '#4f46e5'; ctx2d.lineWidth = 1.5/vs; ctx2d.stroke(); }
|
|
}
|
|
|
|
// ── Labels ──
|
|
if (showLabels) {
|
|
ctx2d.fillStyle = '#c7d2fe';
|
|
ctx2d.font = Math.max(8, Math.min(11, 11/vs)) + 'px system-ui,sans-serif';
|
|
ctx2d.textAlign = 'center';
|
|
for (let i = 0; i < N; i++) {
|
|
const px = posX[i], py = posY[i];
|
|
if (px < v0x || px > v1x || py < v0y || py > v1y) continue;
|
|
ctx2d.fillText(nodeNames[i], px, py + baseR + 14/vs);
|
|
}
|
|
}
|
|
|
|
if (hasNodeFlash || hasEdgeFlash) requestAnimationFrame(drawGraph);
|
|
}
|
|
|
|
// ── Edge visibility (binary search, zero allocation) ─────────────
|
|
function updateVisibleEdges(pos) {
|
|
let lo = 0, hi = totalEdges;
|
|
while (lo < hi) {
|
|
const mid = (lo + hi) >>> 1;
|
|
if (edgeAppear[mid] < pos) lo = mid + 1; else hi = mid;
|
|
}
|
|
visEdges = lo;
|
|
}
|
|
|
|
// ── UI helpers ───────────────────────────────────────────────────
|
|
function updateStats(nn, ne, nm, cr, tr) {
|
|
document.getElementById('statNodes').textContent = nn;
|
|
document.getElementById('statEdges').textContent = ne;
|
|
document.getElementById('statMessages').textContent = nm;
|
|
document.getElementById('statRound').textContent = cr + '/' + tr;
|
|
}
|
|
|
|
function formatDetail(kind, detail) {
|
|
if (!detail) return '';
|
|
switch (kind) {
|
|
case 'GossipRoundStarted': return '→ ' + (detail.target_name || detail.target);
|
|
case 'PushReceived': return '← ' + (detail.from_name || detail.from) + ' (' + detail.keys_updated + ' keys)';
|
|
case 'LocalSet': return 'key=' + detail.key;
|
|
case 'PeerAdded': return '+ ' + (detail.peer_name || detail.peer);
|
|
case 'PeerRemoved': return '- ' + (detail.peer_name || detail.peer);
|
|
case 'QueryReceived': return 'key=' + detail.key;
|
|
case 'StateSnapshot': return detail.entries + ' entries, ' + detail.peer_count + ' peers';
|
|
default: return JSON.stringify(detail);
|
|
}
|
|
}
|
|
|
|
function makeRow(ev) {
|
|
const tr = document.createElement('tr');
|
|
if (ev.kind === 'GossipRoundStarted') tr.className = 'highlight-push';
|
|
else if (ev.kind === 'LocalSet') tr.className = 'highlight-set';
|
|
tr.innerHTML = '<td>'+ev.seq+'</td><td>'+ev.tick+'</td><td>'+(ev.thread||'-')+'</td><td>'+ev.node+'</td><td>'+ev.kind+'</td><td>'+formatDetail(ev.kind, ev.detail)+'</td>';
|
|
return tr;
|
|
}
|
|
|
|
function fmtWorker(ev) {
|
|
let t = ev.node + ': ' + ev.kind;
|
|
if (ev.kind === 'GossipRoundStarted' && ev.detail) t += ' -> ' + (ev.detail.target_name || ev.detail.target);
|
|
if (ev.kind === 'PushReceived' && ev.detail) t += ' <- ' + (ev.detail.from_name || ev.detail.from);
|
|
return t;
|
|
}
|
|
|
|
function rebuildWorkerColumns() {
|
|
const c = document.getElementById('workerColumns');
|
|
c.innerHTML = '';
|
|
for (const t of Object.keys(workerLogs).sort()) {
|
|
const col = document.createElement('div');
|
|
col.className = 'worker-col'; col.id = 'worker-' + t;
|
|
col.innerHTML = '<div class="worker-col-header">' + t + '</div>';
|
|
c.appendChild(col);
|
|
}
|
|
}
|
|
|
|
function updateWorkerColumn(thread) {
|
|
const col = document.getElementById('worker-' + thread);
|
|
if (!col) return;
|
|
let h = '<div class="worker-col-header">' + thread + '</div>';
|
|
for (const e of workerLogs[thread]) h += '<div class="worker-entry">' + e + '</div>';
|
|
col.innerHTML = h;
|
|
col.scrollTop = col.scrollHeight;
|
|
}
|
|
|
|
// ── Incremental table/worker updates ─────────────────────────────
|
|
function updateEventTable(pos) {
|
|
const tbody = document.getElementById('eventTableBody');
|
|
const incr = pos > lastTablePos && pos - lastTablePos <= 100 && lastTablePos >= 0;
|
|
if (!incr) tbody.textContent = '';
|
|
const frag = document.createDocumentFragment();
|
|
const start = incr ? lastTablePos : Math.max(0, pos - MAX_TABLE_ROWS);
|
|
for (let i = start; i < pos && i < allEvents.length; i++) frag.appendChild(makeRow(allEvents[i]));
|
|
tbody.appendChild(frag);
|
|
if (incr) while (tbody.childNodes.length > MAX_TABLE_ROWS) tbody.removeChild(tbody.firstChild);
|
|
lastTablePos = pos;
|
|
document.getElementById('eventTableWrap').scrollTop = 1e9;
|
|
}
|
|
|
|
function updateWorkerPanel(pos) {
|
|
const incr = pos > lastWorkerPos && pos - lastWorkerPos <= 100 && lastWorkerPos >= 0;
|
|
const start = incr ? lastWorkerPos : Math.max(0, pos - 200);
|
|
if (!incr) workerLogs = {};
|
|
let needCols = !incr;
|
|
for (let i = start; i < pos && i < allEvents.length; i++) {
|
|
const ev = allEvents[i];
|
|
if (!ev.thread) continue;
|
|
if (!workerLogs[ev.thread]) { workerLogs[ev.thread] = []; needCols = true; }
|
|
workerLogs[ev.thread].push(fmtWorker(ev));
|
|
if (workerLogs[ev.thread].length > 50) workerLogs[ev.thread].shift();
|
|
}
|
|
if (needCols) rebuildWorkerColumns();
|
|
for (const t of Object.keys(workerLogs)) updateWorkerColumn(t);
|
|
lastWorkerPos = pos;
|
|
}
|
|
|
|
// ── Replay engine ────────────────────────────────────────────────
|
|
let replayRafId = 0, 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;
|
|
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) {
|
|
replayCursor = pos;
|
|
if (replayRafId) return;
|
|
replayRafId = requestAnimationFrame(() => { replayRafId = 0; replayToImpl(replayCursor); });
|
|
}
|
|
|
|
function replayToImpl(pos) {
|
|
flashNode = -1; flashSrc = -1; flashDst = -1;
|
|
|
|
updateVisibleEdges(pos);
|
|
updateEventTable(pos);
|
|
updateWorkerPanel(pos);
|
|
|
|
// Flash last event
|
|
if (pos > 0 && pos <= allEvents.length) {
|
|
const ev = allEvents[pos - 1];
|
|
flashNode = nodeIdx.get(ev.node) ?? -1;
|
|
flashNodeT = performance.now();
|
|
if (ev.kind === 'GossipRoundStarted' && ev.detail) {
|
|
const t = ev.detail.target_name || ev.detail.target;
|
|
if (t) { flashSrc = nodeIdx.get(ev.node) ?? -1; flashDst = nodeIdx.get(t) ?? -1; flashEdgeT = performance.now(); }
|
|
}
|
|
if (ev.kind === 'PushReceived' && ev.detail) {
|
|
const f = ev.detail.from_name || ev.detail.from;
|
|
if (f) { flashSrc = nodeIdx.get(f) ?? -1; flashDst = nodeIdx.get(ev.node) ?? -1; flashEdgeT = performance.now(); }
|
|
}
|
|
}
|
|
|
|
document.getElementById('replaySlider').value = pos;
|
|
document.getElementById('replayPos').textContent = replayCursor + '/' + allEvents.length;
|
|
drawGraph();
|
|
}
|
|
|
|
// ── Trace loading ────────────────────────────────────────────────
|
|
function resetState() {
|
|
stopPlayback();
|
|
layoutAbortFlag = true;
|
|
nodeNames = []; nodeIdx.clear(); N = 0;
|
|
posX = new Float64Array(0); posY = new Float64Array(0);
|
|
totalEdges = 0; visEdges = 0;
|
|
edgeSrc = new Int32Array(0); edgeDst = new Int32Array(0); edgeAppear = new Int32Array(0);
|
|
flashNode = -1; flashSrc = -1; flashDst = -1;
|
|
allEvents = []; replayCursor = 0; workerLogs = {};
|
|
lastTablePos = -1; lastWorkerPos = -1;
|
|
vx = 0; vy = 0; vs = 1;
|
|
document.getElementById('eventTableBody').textContent = '';
|
|
document.getElementById('workerColumns').innerHTML = '';
|
|
document.getElementById('layoutProgress').style.display = 'none';
|
|
}
|
|
|
|
async function loadTrace(file) {
|
|
const dot = document.getElementById('statusDot');
|
|
const statusText = document.getElementById('statusText');
|
|
dot.className = 'status-dot loading';
|
|
statusText.textContent = 'Loading...';
|
|
resetState();
|
|
|
|
const resp = await fetch('/trace.json?file=' + encodeURIComponent(file));
|
|
if (!resp.ok) { dot.className = 'status-dot error'; statusText.textContent = 'Failed to load trace'; return; }
|
|
const trace = await resp.json();
|
|
|
|
// Build node index
|
|
nodeNames = trace.node_names;
|
|
N = nodeNames.length;
|
|
nodeIdx = new Map();
|
|
for (let i = 0; i < N; i++) nodeIdx.set(nodeNames[i], i);
|
|
posX = new Float64Array(N);
|
|
posY = new Float64Array(N);
|
|
|
|
// Build edges (initial topology)
|
|
const edgeKeys = new Set();
|
|
const tempEdges = []; // flat triples: [src, dst, appear, ...]
|
|
for (const [a, b] of trace.topology_edges) {
|
|
const si = nodeIdx.get(a), di = nodeIdx.get(b);
|
|
if (si === undefined || di === undefined) continue;
|
|
const key = si + ',' + di;
|
|
if (!edgeKeys.has(key)) { edgeKeys.add(key); tempEdges.push(si, di, -1); }
|
|
}
|
|
|
|
// Build events
|
|
let seq = 0;
|
|
allEvents = [];
|
|
for (const ev of trace.events) {
|
|
const isObj = typeof ev.kind === 'object';
|
|
if (isObj && 'StateSnapshot' in ev.kind) continue;
|
|
if (!isObj && ev.kind === 'StateSnapshot') continue;
|
|
let kind, detail;
|
|
if (isObj) { for (kind in ev.kind) break; detail = ev.kind[kind] || null; }
|
|
else { kind = ev.kind; detail = null; }
|
|
allEvents.push({ seq: seq++, tick: ev.tick, node: ev.node_name, thread: ev.thread_name, kind, detail });
|
|
}
|
|
|
|
// Index PeerAdded edges
|
|
for (let i = 0; i < allEvents.length; i++) {
|
|
const ev = allEvents[i];
|
|
if (ev.kind !== 'PeerAdded' || !ev.detail) continue;
|
|
const peer = ev.detail.peer_name || ev.detail.peer;
|
|
if (!peer) continue;
|
|
const si = nodeIdx.get(ev.node), di = nodeIdx.get(peer);
|
|
if (si === undefined || di === undefined) continue;
|
|
const key = si + ',' + di;
|
|
if (!edgeKeys.has(key)) { edgeKeys.add(key); tempEdges.push(si, di, i); }
|
|
}
|
|
|
|
// Sort by appearance and build typed arrays
|
|
const nEdges = tempEdges.length / 3;
|
|
const sortIdx = new Array(nEdges);
|
|
for (let i = 0; i < nEdges; i++) sortIdx[i] = i;
|
|
sortIdx.sort((a, b) => tempEdges[a*3+2] - tempEdges[b*3+2]);
|
|
|
|
totalEdges = nEdges;
|
|
edgeSrc = new Int32Array(nEdges);
|
|
edgeDst = new Int32Array(nEdges);
|
|
edgeAppear = new Int32Array(nEdges);
|
|
for (let j = 0; j < nEdges; j++) {
|
|
const i = sortIdx[j];
|
|
edgeSrc[j] = tempEdges[i*3]; edgeDst[j] = tempEdges[i*3+1]; edgeAppear[j] = tempEdges[i*3+2];
|
|
}
|
|
|
|
updateVisibleEdges(0);
|
|
|
|
dot.className = 'status-dot loading';
|
|
statusText.textContent = 'Computing layout...';
|
|
updateStats(N, totalEdges, allEvents.length, trace.num_rounds, trace.num_rounds);
|
|
|
|
resizeCanvas();
|
|
initPositions();
|
|
const rect = canvas.parentElement.getBoundingClientRect();
|
|
await runForceLayout(rect.width, rect.height);
|
|
|
|
dot.className = 'status-dot ready';
|
|
statusText.textContent = trace.name + ' (' + allEvents.length + ' events)';
|
|
drawGraph();
|
|
|
|
// Replay controls
|
|
document.getElementById('replayControls').style.display = 'flex';
|
|
const slider = document.getElementById('replaySlider');
|
|
slider.max = allEvents.length; slider.value = 0;
|
|
replayCursor = 0;
|
|
document.getElementById('replayPos').textContent = '0/' + allEvents.length;
|
|
|
|
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 = () => { playTimer !== null ? stopPlayback() : startPlayback(); };
|
|
document.getElementById('speedInput').onchange = () => { if (playTimer !== null) startPlayback(); };
|
|
}
|
|
|
|
// ── Boot ─────────────────────────────────────────────────────────
|
|
resizeCanvas();
|
|
(async function() {
|
|
const select = document.getElementById('traceSelect');
|
|
const dot = document.getElementById('statusDot');
|
|
const statusText = document.getElementById('statusText');
|
|
let traces;
|
|
try { traces = await (await fetch('/traces')).json(); }
|
|
catch { dot.className='status-dot error'; statusText.textContent='Failed to fetch trace list'; select.innerHTML='<option value="">Error</option>'; return; }
|
|
if (!traces.length) { dot.className='status-dot error'; statusText.textContent='No traces found'; select.innerHTML='<option value="">No traces found</option>'; return; }
|
|
select.innerHTML = '';
|
|
traces.forEach(t => { const o = document.createElement('option'); o.value = t.file; o.textContent = t.name+' ('+t.nodes+' nodes, '+t.events+' events)'; select.appendChild(o); });
|
|
select.disabled = false;
|
|
select.onchange = () => { if (select.value) loadTrace(select.value); };
|
|
loadTrace(traces[0].file);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"##;
|