From 435b009d9b0d8a4c048af26686d9398c41fdcb1e Mon Sep 17 00:00:00 2001 From: Zachery Aaron Shores-Chmielewski Date: Mon, 9 Feb 2026 16:03:16 +0700 Subject: [PATCH] fix: gossip dashboard Better display and more metrics for larger node graphs. --- .../configs/partitioned_800_heal.toml | 4 +- crates/gossip-dashboard/src/dashboard_html.rs | 813 ++++++++++++++++-- 2 files changed, 759 insertions(+), 58 deletions(-) diff --git a/crates/gossip-dashboard/examples/configs/partitioned_800_heal.toml b/crates/gossip-dashboard/examples/configs/partitioned_800_heal.toml index d376853..6943d35 100644 --- a/crates/gossip-dashboard/examples/configs/partitioned_800_heal.toml +++ b/crates/gossip-dashboard/examples/configs/partitioned_800_heal.toml @@ -1,6 +1,6 @@ -name = "Partition & Heal" +name = "Partition & Heal (100 nodes)" topology = "partitioned" -num_nodes = 800 +num_nodes = 100 num_rounds = 10 ticks_per_round = 5 num_threads = 4 diff --git a/crates/gossip-dashboard/src/dashboard_html.rs b/crates/gossip-dashboard/src/dashboard_html.rs index 0c22a2a..fb5fb84 100644 --- a/crates/gossip-dashboard/src/dashboard_html.rs +++ b/crates/gossip-dashboard/src/dashboard_html.rs @@ -53,6 +53,35 @@ pub const DASHBOARD_HTML: &str = r##" .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; } + .tab-bar { display: flex; border-bottom: 1px solid #2a2d3a; background: #161822; flex-shrink: 0; } + .tab-btn { flex: 1; padding: 8px 0; font-size: 13px; font-weight: 500; color: #6b7280; background: none; border: none; border-bottom: 2px solid transparent; cursor: pointer; text-align: center; } + .tab-btn:hover { color: #9ca3af; } + .tab-btn.active { color: #818cf8; border-bottom-color: #818cf8; } + .tab-content { display: none; flex: 1; flex-direction: column; overflow: hidden; min-height: 0; } + .tab-content.active { display: flex; } + .analytics-scroll { flex: 1; overflow-y: auto; padding: 12px 16px; } + .badge-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 16px; } + .badge { background: #1a1d2e; border-radius: 6px; padding: 10px 12px; border-left: 3px solid #6b7280; } + .badge.green { border-left-color: #22c55e; } + .badge.yellow { border-left-color: #f59e0b; } + .badge.red { border-left-color: #ef4444; } + .badge .badge-val { font-size: 18px; font-weight: 700; color: #e5e7eb; } + .badge .badge-lbl { font-size: 11px; color: #6b7280; text-transform: uppercase; } + .chart-section { margin-bottom: 16px; } + .chart-section h3 { font-size: 12px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; } + .chart-section canvas { width: 100%; border-radius: 4px; background: #1a1d2e; display: block; } + .chart-empty { padding: 24px; text-align: center; color: #4b5563; font-size: 13px; } + .node-detail { position: absolute; top: 60px; left: 20px; width: 280px; background: rgba(22,24,34,0.95); border: 1px solid #2a2d3a; border-radius: 8px; padding: 14px; z-index: 20; display: none; font-size: 12px; } + .node-detail h3 { font-size: 14px; color: #c7d2fe; margin-bottom: 8px; } + .node-detail .close-btn { position: absolute; top: 8px; right: 10px; background: none; border: none; color: #6b7280; cursor: pointer; font-size: 16px; } + .node-detail .close-btn:hover { color: #e0e0e0; } + .node-detail .nd-row { display: flex; justify-content: space-between; padding: 3px 0; border-bottom: 1px solid #1a1d2e; } + .node-detail .nd-key { color: #6b7280; } + .node-detail .nd-val { color: #e5e7eb; font-weight: 500; } + .node-detail .nd-events { max-height: 160px; overflow-y: auto; margin-top: 8px; } + .node-detail .nd-ev { color: #9ca3af; padding: 1px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 11px; } + .heatmap-wrap { position: relative; } + .heatmap-tooltip { position: absolute; display: none; background: rgba(22,24,34,0.95); border: 1px solid #2a2d3a; border-radius: 4px; padding: 4px 8px; font-size: 11px; color: #e0e0e0; white-space: nowrap; pointer-events: none; z-index: 5; } @@ -71,41 +100,73 @@ pub const DASHBOARD_HTML: &str = r##"
Computing layout...
+
+ +

+
+
+
-
-

Stats

-
-
0
Nodes
-
0
Edges
-
0
Messages
-
0/0
Round
+ +
+ + +
+
+
+

Stats

+
+
0
Nodes
+
0
Edges
+
0
Messages
+
0/0
Round
+
+
+
+

Worker Logs

+
+
+
+

Event Log

+
+ + + +
SeqRoundThreadNodeEventDetails
+
-
-

Worker Logs

-
-
-
-

Event Log

- -
- - - -
SeqRoundThreadNodeEventDetails
+
+
+
+
+

Convergence Curve

+ +
+
+

Propagation Heatmap

+
+ +
+
+
+
+

Load Distribution

+ +
@@ -138,10 +199,45 @@ let replayCursor = 0; let workerLogs = {}; let lastTablePos = -1, lastWorkerPos = -1; +// ── Analytics data ─────────────────────────────────────────────── +let snapRounds = []; // unique tick numbers that have snapshots +let snapEntries = []; // snapEntries[roundIdx][nodeIdx] = entry count +let snapPeerCount = []; // snapPeerCount[roundIdx][nodeIdx] = peer count +let totalKeys = 0; // max entries seen across all snapshots +let numRounds = 0; // total rounds from trace + +let metricPushesSent = new Int32Array(0); +let metricPushesRecv = new Int32Array(0); +let metricRedundant = 0; +let metricTotalPushes = 0; +let metricNoPeers = 0; + +// cumulative push-recv per round: cumulPushRecv[roundIdx][nodeIdx] +let cumulPushRecv = []; +// round number for each event index: eventRoundIdx[evtIdx] = roundIdx into snapRounds +let eventRoundMap = []; // eventRoundMap[evtIdx] = tick + +// ── Community detection ────────────────────────────────────────── +let community = new Int32Array(0); // community[nodeIdx] = community id +let numCommunities = 0; +let communityHulls = []; // communityHulls[cid] = [[x,y], ...] +let communityColors = ['#6366f1','#22c55e','#f59e0b','#ef4444','#3b82f6','#a855f7','#ec4899','#14b8a6']; + // ── View transform ─────────────────────────────────────────────── let vx = 0, vy = 0, vs = 1; // view x, y, scale let isDragging = false, dragX = 0, dragY = 0, dragVx = 0, dragVy = 0; +// ── Tab switching ──────────────────────────────────────────────── +document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + btn.classList.add('active'); + document.getElementById('tab-' + btn.dataset.tab).classList.add('active'); + if (btn.dataset.tab === 'analytics') drawAllCharts(); + }); +}); + // ── Quadtree pool (flat Float64Array, reused across iterations) ── // Per node: [ox, oy, size, cx, cy, mass, c0, c1, c2, c3, body] const QF = 11; @@ -178,10 +274,12 @@ canvas.addEventListener('wheel', (e) => { drawGraph(); }, { passive: false }); +let clickStartX = 0, clickStartY = 0; canvas.addEventListener('mousedown', (e) => { if (e.button !== 0) return; isDragging = true; dragX = e.clientX; dragY = e.clientY; + clickStartX = e.clientX; clickStartY = e.clientY; dragVx = vx; dragVy = vy; canvas.style.cursor = 'grabbing'; }); @@ -191,8 +289,12 @@ window.addEventListener('mousemove', (e) => { vy = dragVy + (e.clientY - dragY); drawGraph(); }); -window.addEventListener('mouseup', () => { - if (isDragging) { isDragging = false; canvas.style.cursor = ''; } +window.addEventListener('mouseup', (e) => { + if (isDragging) { + const wasDrag = Math.abs(e.clientX - clickStartX) > 3 || Math.abs(e.clientY - clickStartY) > 3; + isDragging = false; canvas.style.cursor = ''; + if (!wasDrag) handleNodeClick(e); + } }); canvas.addEventListener('dblclick', () => { resetView(); drawGraph(); }); @@ -357,6 +459,452 @@ function runForceLayout(w, h) { }); } +// ── Node click → detail panel ──────────────────────────────────── +function handleNodeClick(e) { + const r = canvas.getBoundingClientRect(); + const mx = e.clientX - r.left, my = e.clientY - r.top; + const wx = (mx - vx) / vs, wy = (my - vy) / vs; + const baseR = Math.max(2, Math.min(8, 400/Math.sqrt(Math.max(N, 1)))); + const hitR = baseR * 2; + let best = -1, bestD = hitR * hitR; + for (let i = 0; i < N; i++) { + const dx = posX[i] - wx, dy = posY[i] - wy; + const d2 = dx*dx + dy*dy; + if (d2 < bestD) { bestD = d2; best = i; } + } + const panel = document.getElementById('nodeDetail'); + if (best < 0) { panel.style.display = 'none'; return; } + showNodeDetail(best); +} + +function showNodeDetail(ni) { + const panel = document.getElementById('nodeDetail'); + document.getElementById('ndName').textContent = nodeNames[ni]; + + // Find current round from replay position + let curRound = 0; + if (replayCursor > 0 && replayCursor <= allEvents.length) curRound = allEvents[replayCursor - 1].tick; + + // Key count from latest snapshot ≤ current round + let keyCount = 0, peerCount = 0; + for (let r = snapRounds.length - 1; r >= 0; r--) { + if (snapRounds[r] <= curRound) { keyCount = snapEntries[r][ni] || 0; peerCount = snapPeerCount[r][ni] || 0; break; } + } + + let html = ''; + html += '
Community' + (community[ni] !== undefined ? community[ni] : '-') + '
'; + html += '
Pushes Sent' + (metricPushesSent[ni] || 0) + '
'; + html += '
Pushes Recv' + (metricPushesRecv[ni] || 0) + '
'; + html += '
Keys' + keyCount + '/' + totalKeys + '
'; + html += '
Peers' + peerCount + '
'; + document.getElementById('ndStats').innerHTML = html; + + // Mini event log: last 20 events for this node up to cursor + const name = nodeNames[ni]; + let evHtml = '
Recent events:
'; + let count = 0; + for (let i = Math.min(replayCursor, allEvents.length) - 1; i >= 0 && count < 20; i--) { + if (allEvents[i].node === name) { + evHtml += '
' + allEvents[i].kind + (allEvents[i].detail ? ': ' + formatDetail(allEvents[i].kind, allEvents[i].detail).replace(/&[lr]arr;/g, '→') : '') + '
'; + count++; + } + } + document.getElementById('ndEvents').innerHTML = evHtml; + + panel.style.display = 'block'; +} + +document.getElementById('nodeDetailClose').addEventListener('click', () => { + document.getElementById('nodeDetail').style.display = 'none'; +}); + +// ── Community detection (BFS on visible edges) ────────────────── +function detectCommunities() { + community = new Int32Array(N).fill(-1); + numCommunities = 0; + const adj = new Array(N); + for (let i = 0; i < N; i++) adj[i] = []; + for (let i = 0; i < totalEdges; i++) { + adj[edgeSrc[i]].push(edgeDst[i]); + adj[edgeDst[i]].push(edgeSrc[i]); + } + const queue = []; + for (let i = 0; i < N; i++) { + if (community[i] >= 0) continue; + const cid = numCommunities++; + community[i] = cid; + queue.push(i); + while (queue.length > 0) { + const u = queue.pop(); + for (const v of adj[u]) { + if (community[v] < 0) { community[v] = cid; queue.push(v); } + } + } + } +} + +// ── Convex hull (Graham scan) ─────────────────────────────────── +function convexHull(points) { + if (points.length < 3) return points.slice(); + points.sort((a, b) => a[0] - b[0] || a[1] - b[1]); + const cross = (o, a, b) => (a[0]-o[0])*(b[1]-o[1]) - (a[1]-o[1])*(b[0]-o[0]); + const lower = []; + for (const p of points) { + while (lower.length >= 2 && cross(lower[lower.length-2], lower[lower.length-1], p) <= 0) lower.pop(); + lower.push(p); + } + const upper = []; + for (let i = points.length - 1; i >= 0; i--) { + const p = points[i]; + while (upper.length >= 2 && cross(upper[upper.length-2], upper[upper.length-1], p) <= 0) upper.pop(); + upper.push(p); + } + lower.pop(); upper.pop(); + return lower.concat(upper); +} + +function computeCommunityHulls() { + communityHulls = []; + for (let c = 0; c < numCommunities; c++) { + const pts = []; + for (let i = 0; i < N; i++) { + if (community[i] === c) pts.push([posX[i], posY[i]]); + } + communityHulls.push(pts.length >= 3 ? convexHull(pts) : pts); + } +} + +// ── Analytics charts ──────────────────────────────────────────── +function drawAllCharts() { + drawBadges(); + drawConvergenceChart(); + drawHeatmap(); + drawLoadHistogram(); +} + +function drawBadges() { + const grid = document.getElementById('badgeGrid'); + const hasSnaps = snapRounds.length > 0; + + // Delivery ratio + let delivery = 1.0; + if (hasSnaps) { + const last = snapEntries[snapEntries.length - 1]; + let full = 0; + for (let i = 0; i < N; i++) if (last[i] >= totalKeys && totalKeys > 0) full++; + delivery = N > 0 ? full / N : 1; + } + + // Convergence round + let convRound = hasSnaps ? -1 : 0; + if (hasSnaps && totalKeys > 0) { + for (let r = 0; r < snapRounds.length; r++) { + let allFull = true; + for (let i = 0; i < N; i++) { if (snapEntries[r][i] < totalKeys) { allFull = false; break; } } + if (allFull) { convRound = snapRounds[r]; break; } + } + } + + // Redundancy + const redundancy = metricTotalPushes > 0 ? metricRedundant / metricTotalPushes : 0; + + // Load balance CV + let mean = 0, variance = 0; + if (N > 0) { + for (let i = 0; i < N; i++) mean += metricPushesRecv[i]; + mean /= N; + for (let i = 0; i < N; i++) { const d = metricPushesRecv[i] - mean; variance += d * d; } + variance /= N; + } + const cv = mean > 0 ? Math.sqrt(variance) / mean : 0; + + // Amplification + const amp = N > 0 ? metricTotalPushes / N : 0; + + const logN = N > 1 ? Math.log2(N) : 1; + const delColor = delivery >= 0.99 ? 'green' : delivery >= 0.9 ? 'yellow' : 'red'; + const convColor = convRound < 0 ? 'red' : convRound <= 2 * logN ? 'green' : convRound <= 3 * logN ? 'yellow' : 'red'; + const redColor = redundancy < 0.2 ? 'green' : redundancy < 0.4 ? 'yellow' : 'red'; + const cvColor = cv < 0.3 ? 'green' : cv < 0.6 ? 'yellow' : 'red'; + + grid.innerHTML = + badge(delColor, (delivery * 100).toFixed(1) + '%', 'Delivery') + + badge(convColor, convRound < 0 ? 'Never' : 'Round ' + convRound, 'Convergence') + + badge(redColor, (redundancy * 100).toFixed(1) + '%', 'Redundancy') + + badge(cvColor, 'CV ' + cv.toFixed(2), 'Load Balance') + + badge('', metricTotalPushes.toLocaleString(), 'Total Pushes') + + badge('', amp.toFixed(2) + '\u00d7', 'Amplification'); +} + +function badge(color, val, label) { + return '
' + val + '
' + label + '
'; +} + +function drawConvergenceChart() { + const cv = document.getElementById('convCanvas'); + if (!snapRounds.length || totalKeys === 0) { + cv.style.display = 'none'; + const sec = document.getElementById('convSection'); + if (!sec.querySelector('.chart-empty')) { const d = document.createElement('div'); d.className = 'chart-empty'; d.textContent = 'No snapshot data'; sec.appendChild(d); } + return; + } + cv.style.display = 'block'; + const sec = document.getElementById('convSection'); + const emp = sec.querySelector('.chart-empty'); + if (emp) emp.remove(); + + const dpr = devicePixelRatio; + const w = cv.parentElement.clientWidth, h = 180; + cv.width = w * dpr; cv.height = h * dpr; + cv.style.width = w + 'px'; cv.style.height = h + 'px'; + const c = cv.getContext('2d'); + c.setTransform(dpr, 0, 0, dpr, 0, 0); + + const pad = { l: 45, r: 12, t: 12, b: 28 }; + const cw = w - pad.l - pad.r, ch = h - pad.t - pad.b; + const maxRound = snapRounds[snapRounds.length - 1] || 1; + + // Background + c.fillStyle = '#1a1d2e'; c.fillRect(0, 0, w, h); + + // Grid + c.strokeStyle = '#2a2d3a'; c.lineWidth = 1; + for (let pct = 0; pct <= 100; pct += 25) { + const y = pad.t + ch * (1 - pct / 100); + c.beginPath(); c.moveTo(pad.l, y); c.lineTo(pad.l + cw, y); c.stroke(); + } + + // Compute data points + const pts = []; + for (let r = 0; r < snapRounds.length; r++) { + let full = 0; + for (let i = 0; i < N; i++) if (snapEntries[r][i] >= totalKeys) full++; + pts.push({ round: snapRounds[r], pct: N > 0 ? full / N * 100 : 0 }); + } + + // Draw fill + c.beginPath(); + c.moveTo(pad.l, pad.t + ch); + for (const p of pts) { + const x = pad.l + (p.round / maxRound) * cw; + const y = pad.t + ch * (1 - p.pct / 100); + c.lineTo(x, y); + } + c.lineTo(pad.l + (pts[pts.length-1].round / maxRound) * cw, pad.t + ch); + c.closePath(); + c.fillStyle = 'rgba(99,102,241,0.2)'; c.fill(); + + // Draw line + c.beginPath(); + for (let i = 0; i < pts.length; i++) { + const x = pad.l + (pts[i].round / maxRound) * cw; + const y = pad.t + ch * (1 - pts[i].pct / 100); + i === 0 ? c.moveTo(x, y) : c.lineTo(x, y); + } + c.strokeStyle = '#6366f1'; c.lineWidth = 2; c.stroke(); + + // Current replay round marker + let curRound = 0; + if (replayCursor > 0 && replayCursor <= allEvents.length) curRound = allEvents[replayCursor - 1].tick; + const mx = pad.l + (curRound / maxRound) * cw; + c.setLineDash([4, 3]); c.strokeStyle = '#f59e0b'; c.lineWidth = 1; + c.beginPath(); c.moveTo(mx, pad.t); c.lineTo(mx, pad.t + ch); c.stroke(); + c.setLineDash([]); + + // Axes labels + c.fillStyle = '#6b7280'; c.font = '10px system-ui,sans-serif'; + c.textAlign = 'right'; + for (let pct = 0; pct <= 100; pct += 25) { + c.fillText(pct + '%', pad.l - 4, pad.t + ch * (1 - pct / 100) + 3); + } + c.textAlign = 'center'; + const step = Math.max(1, Math.ceil(maxRound / 8)); + for (let r = 0; r <= maxRound; r += step) { + c.fillText(r, pad.l + (r / maxRound) * cw, h - 6); + } +} + +function drawHeatmap() { + const cv = document.getElementById('heatCanvas'); + const tooltip = document.getElementById('heatTooltip'); + if (!snapRounds.length || totalKeys === 0) { + cv.style.display = 'none'; + const sec = document.getElementById('heatSection'); + if (!sec.querySelector('.chart-empty')) { const d = document.createElement('div'); d.className = 'chart-empty'; d.textContent = 'No snapshot data'; sec.appendChild(d); } + return; + } + cv.style.display = 'block'; + const sec = document.getElementById('heatSection'); + const emp = sec.querySelector('.chart-empty'); + if (emp) emp.remove(); + + const dpr = devicePixelRatio; + const w = cv.parentElement.clientWidth; + const padL = 60, padR = 8, padT = 4, padB = 24; + const cols = snapRounds.length, rows = N; + const cellW = Math.max(1, Math.floor((w - padL - padR) / Math.max(cols, 1))); + const cellH = N <= 50 ? 14 : Math.max(1, Math.min(4, Math.floor(220 / N))); + const h = padT + rows * cellH + padB; + cv.width = w * dpr; cv.height = h * dpr; + cv.style.width = w + 'px'; cv.style.height = h + 'px'; + const c = cv.getContext('2d'); + c.setTransform(dpr, 0, 0, dpr, 0, 0); + c.fillStyle = '#1a1d2e'; c.fillRect(0, 0, w, h); + + // Sort nodes by name + const sortedIdx = Array.from({length: N}, (_, i) => i); + sortedIdx.sort((a, b) => nodeNames[a].localeCompare(nodeNames[b])); + + // Draw cells + for (let ri = 0; ri < rows; ri++) { + const ni = sortedIdx[ri]; + for (let ci = 0; ci < cols; ci++) { + const frac = totalKeys > 0 ? (snapEntries[ci][ni] || 0) / totalKeys : 0; + const g = Math.round(frac * 200); + c.fillStyle = 'rgb(' + (255 - g) + ',' + (255 - Math.round(frac * 55)) + ',' + (255 - g) + ')'; + if (frac > 0) c.fillStyle = 'rgb(' + Math.round(30 + (1-frac)*225) + ',' + Math.round(80 + (1-frac)*175) + ',' + Math.round(30 + (1-frac)*225) + ')'; + else c.fillStyle = '#2a2d3a'; + c.fillRect(padL + ci * cellW, padT + ri * cellH, cellW - (cellW > 2 ? 1 : 0), cellH - (cellH > 2 ? 1 : 0)); + } + } + + // Node labels (only if space) + if (cellH >= 10) { + c.fillStyle = '#9ca3af'; c.font = '9px system-ui,sans-serif'; c.textAlign = 'right'; + for (let ri = 0; ri < rows; ri++) { + c.fillText(nodeNames[sortedIdx[ri]], padL - 3, padT + ri * cellH + cellH - 2); + } + } + + // Round labels + c.fillStyle = '#6b7280'; c.font = '9px system-ui,sans-serif'; c.textAlign = 'center'; + const labelStep = Math.max(1, Math.ceil(cols / 10)); + for (let ci = 0; ci < cols; ci += labelStep) { + c.fillText(snapRounds[ci], padL + ci * cellW + cellW / 2, h - 6); + } + + // Current round marker + let curRound = 0; + if (replayCursor > 0 && replayCursor <= allEvents.length) curRound = allEvents[replayCursor - 1].tick; + let markerCol = 0; + for (let ci = 0; ci < cols; ci++) { if (snapRounds[ci] <= curRound) markerCol = ci; } + const mx = padL + markerCol * cellW + cellW / 2; + c.setLineDash([3, 2]); c.strokeStyle = '#f59e0b'; c.lineWidth = 1; + c.beginPath(); c.moveTo(mx, padT); c.lineTo(mx, padT + rows * cellH); c.stroke(); + c.setLineDash([]); + + // Hover tooltip + cv.onmousemove = (e) => { + const rect = cv.getBoundingClientRect(); + const ex = e.clientX - rect.left, ey = e.clientY - rect.top; + const col = Math.floor((ex - padL) / cellW); + const row = Math.floor((ey - padT) / cellH); + if (col >= 0 && col < cols && row >= 0 && row < rows) { + const ni = sortedIdx[row]; + const entries = snapEntries[col][ni] || 0; + const pct = totalKeys > 0 ? Math.round(entries / totalKeys * 100) : 0; + tooltip.textContent = nodeNames[ni] + ' at round ' + snapRounds[col] + ': ' + entries + '/' + totalKeys + ' keys (' + pct + '%)'; + tooltip.style.display = 'block'; + tooltip.style.left = (ex + 12) + 'px'; tooltip.style.top = (ey - 20) + 'px'; + } else { tooltip.style.display = 'none'; } + }; + cv.onmouseleave = () => { tooltip.style.display = 'none'; }; +} + +function drawLoadHistogram() { + const cv = document.getElementById('loadCanvas'); + const dpr = devicePixelRatio; + const w = cv.parentElement.clientWidth, h = 150; + cv.width = w * dpr; cv.height = h * dpr; + cv.style.width = w + 'px'; cv.style.height = h + 'px'; + const c = cv.getContext('2d'); + c.setTransform(dpr, 0, 0, dpr, 0, 0); + c.fillStyle = '#1a1d2e'; c.fillRect(0, 0, w, h); + + if (N === 0) return; + + const pad = { l: 40, r: 8, t: 8, b: 24 }; + const cw = w - pad.l - pad.r, ch = h - pad.t - pad.b; + + // Get data up to current replay pos from cumulative + let data; + let curRound = 0; + if (replayCursor > 0 && replayCursor <= allEvents.length) curRound = allEvents[replayCursor - 1].tick; + + // Find the closest round in cumulPushRecv + let bestR = -1; + for (let r = 0; r < snapRounds.length; r++) { + if (snapRounds[r] <= curRound) bestR = r; + } + + if (bestR >= 0 && cumulPushRecv.length > bestR) { + data = cumulPushRecv[bestR]; + } else { + data = metricPushesRecv; // fallback: total + } + + // Compute stats + let maxVal = 0, mean = 0; + for (let i = 0; i < N; i++) { if (data[i] > maxVal) maxVal = data[i]; mean += data[i]; } + mean /= N; + if (maxVal === 0) maxVal = 1; + let std = 0; + for (let i = 0; i < N; i++) { const d = data[i] - mean; std += d * d; } + std = Math.sqrt(std / N); + + // Bin for large N + const useBins = N > 100; + let barData, barCount; + if (useBins) { + barCount = Math.min(50, N); + barData = new Float64Array(barCount); + const binCounts = new Int32Array(barCount); + for (let i = 0; i < N; i++) { + const bin = Math.min(barCount - 1, Math.floor(i / N * barCount)); + barData[bin] += data[i]; binCounts[bin]++; + } + for (let i = 0; i < barCount; i++) if (binCounts[i] > 0) barData[i] /= binCounts[i]; + // Recompute max + maxVal = 0; + for (let i = 0; i < barCount; i++) if (barData[i] > maxVal) maxVal = barData[i]; + if (maxVal === 0) maxVal = 1; + } else { + barCount = N; + barData = data; + } + + const barW = Math.max(1, cw / barCount - (barCount < 50 ? 1 : 0)); + + for (let i = 0; i < barCount; i++) { + const val = barData[i]; + const barH = (val / maxVal) * ch; + const dev = std > 0 ? Math.abs(val - mean) / std : 0; + // Color by deviation: green near mean, yellow moderate, red outlier + let r, g, b; + if (dev < 1) { r = 34; g = 197; b = 94; } + else if (dev < 2) { r = 245; g = 158; b = 11; } + else { r = 239; g = 68; b = 68; } + c.fillStyle = 'rgb(' + r + ',' + g + ',' + b + ')'; + c.fillRect(pad.l + i * (cw / barCount), pad.t + ch - barH, barW, barH); + } + + // Mean line + const meanY = pad.t + ch - (mean / maxVal) * ch; + c.setLineDash([4, 3]); c.strokeStyle = '#e0e0e0'; c.lineWidth = 1; + c.beginPath(); c.moveTo(pad.l, meanY); c.lineTo(pad.l + cw, meanY); c.stroke(); + c.setLineDash([]); + + // Labels + c.fillStyle = '#6b7280'; c.font = '10px system-ui,sans-serif'; + c.textAlign = 'right'; + c.fillText(maxVal, pad.l - 4, pad.t + 10); + c.fillText('0', pad.l - 4, pad.t + ch); + c.textAlign = 'left'; + c.fillText('mean: ' + mean.toFixed(1), pad.l + 4, meanY - 4); + c.textAlign = 'center'; + c.fillText(useBins ? 'nodes (binned)' : 'node index', pad.l + cw / 2, h - 4); +} + // ── Drawing ────────────────────────────────────────────────────── function drawGraph() { const r = canvas.parentElement.getBoundingClientRect(); @@ -371,20 +919,62 @@ function drawGraph() { // 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; + const showStroke = N <= 200; + + // Semantic zoom levels + const showHulls = vs < 0.5 && numCommunities > 1; + const showNodes = vs >= 0.2; + const showEdges = vs >= 0.2 && !(vs < 0.5 && N > 1000); + const showLabels = vs * baseR > 6; + const thickDetail = vs > 1.5; // Flash state const hasNodeFlash = flashNode >= 0 && now - flashNodeT < 400; const hasEdgeFlash = flashSrc >= 0 && now - flashEdgeT < 600; + // ── Community hulls (zoomed out) ── + if (showHulls) { + for (let c = 0; c < numCommunities; c++) { + const hull = communityHulls[c]; + if (!hull || hull.length < 2) continue; + const color = communityColors[c % communityColors.length]; + // Expand hull slightly for padding + let cx = 0, cy = 0; + for (const p of hull) { cx += p[0]; cy += p[1]; } + cx /= hull.length; cy /= hull.length; + const pad = 20 / vs; + + ctx2d.beginPath(); + for (let i = 0; i < hull.length; i++) { + const dx = hull[i][0] - cx, dy = hull[i][1] - cy; + const d = Math.sqrt(dx*dx + dy*dy) || 1; + const px = hull[i][0] + dx/d * pad, py = hull[i][1] + dy/d * pad; + i === 0 ? ctx2d.moveTo(px, py) : ctx2d.lineTo(px, py); + } + ctx2d.closePath(); + ctx2d.fillStyle = color + '18'; ctx2d.fill(); + ctx2d.strokeStyle = color + '60'; ctx2d.lineWidth = 2/vs; ctx2d.stroke(); + + // Label + if (vs < 0.5) { + let count = 0; + for (let i = 0; i < N; i++) if (community[i] === c) count++; + ctx2d.fillStyle = color; + ctx2d.font = Math.max(12, 16/vs) + 'px system-ui,sans-serif'; + ctx2d.textAlign = 'center'; + ctx2d.fillText('Community ' + c + ' (' + count + ' nodes)', cx, cy); + } + } + } + // ── Edges ── - if (!(vs < 0.15 && visEdges > 10000)) { + if (showEdges && !(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; + ctx2d.lineWidth = (thickDetail ? 1.5 : 1)/vs; let batched = 0; for (let i = 0; i < visEdges; i++) { @@ -408,30 +998,50 @@ function drawGraph() { } } - // ── 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(); } + // ── Nodes (community-colored) ── + if (showNodes) { + const useCommunityColor = numCommunities > 1; + let flashNodeI = -1; - 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(); } + if (useCommunityColor) { + // Batch by community color + for (let c = 0; c < numCommunities; c++) { + ctx2d.beginPath(); + for (let i = 0; i < N; i++) { + if (community[i] !== c) continue; + 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 = communityColors[c % communityColors.length]; ctx2d.fill(); + if (showStroke) { ctx2d.strokeStyle = '#1a1d2e'; ctx2d.lineWidth = 1/vs; ctx2d.stroke(); } + } + } else { + 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.font = Math.max(8, Math.min(thickDetail ? 13 : 11, (thickDetail ? 13 : 11)/vs)) + 'px system-ui,sans-serif'; ctx2d.textAlign = 'center'; for (let i = 0; i < N; i++) { const px = posX[i], py = posY[i]; @@ -595,6 +1205,12 @@ function replayToImpl(pos) { document.getElementById('replaySlider').value = pos; document.getElementById('replayPos').textContent = replayCursor + '/' + allEvents.length; drawGraph(); + // Update analytics charts if visible + if (document.getElementById('tab-analytics').classList.contains('active')) { + drawConvergenceChart(); + drawHeatmap(); + drawLoadHistogram(); + } } // ── Trace loading ──────────────────────────────────────────────── @@ -609,9 +1225,16 @@ function resetState() { allEvents = []; replayCursor = 0; workerLogs = {}; lastTablePos = -1; lastWorkerPos = -1; vx = 0; vy = 0; vs = 1; + snapRounds = []; snapEntries = []; snapPeerCount = []; totalKeys = 0; numRounds = 0; + metricPushesSent = new Int32Array(0); metricPushesRecv = new Int32Array(0); + metricRedundant = 0; metricTotalPushes = 0; metricNoPeers = 0; + cumulPushRecv = []; eventRoundMap = []; + community = new Int32Array(0); numCommunities = 0; communityHulls = []; document.getElementById('eventTableBody').textContent = ''; document.getElementById('workerColumns').innerHTML = ''; document.getElementById('layoutProgress').style.display = 'none'; + document.getElementById('nodeDetail').style.display = 'none'; + document.getElementById('badgeGrid').innerHTML = ''; } async function loadTrace(file) { @@ -643,12 +1266,26 @@ async function loadTrace(file) { if (!edgeKeys.has(key)) { edgeKeys.add(key); tempEdges.push(si, di, -1); } } - // Build events + // Build events + extract snapshots let seq = 0; allEvents = []; + numRounds = trace.num_rounds || 0; + + // First pass: collect snapshots grouped by tick + const snapByTick = new Map(); // tick -> Map(nodeIdx -> {entries, peer_count}) for (const ev of trace.events) { const isObj = typeof ev.kind === 'object'; - if (isObj && 'StateSnapshot' in ev.kind) continue; + if (isObj && 'StateSnapshot' in ev.kind) { + const snap = ev.kind.StateSnapshot.snapshot || ev.kind.StateSnapshot; + const ni = nodeIdx.get(ev.node_name); + if (ni === undefined) continue; + const entriesCount = snap.entries ? Object.keys(snap.entries).length : 0; + const peerCount = snap.peer_count || 0; + if (!snapByTick.has(ev.tick)) snapByTick.set(ev.tick, new Map()); + snapByTick.get(ev.tick).set(ni, { entries: entriesCount, peer_count: peerCount }); + if (entriesCount > totalKeys) totalKeys = entriesCount; + continue; + } if (!isObj && ev.kind === 'StateSnapshot') continue; let kind, detail; if (isObj) { for (kind in ev.kind) break; detail = ev.kind[kind] || null; } @@ -656,6 +1293,65 @@ async function loadTrace(file) { allEvents.push({ seq: seq++, tick: ev.tick, node: ev.node_name, thread: ev.thread_name, kind, detail }); } + // Build snapshot arrays sorted by round + snapRounds = Array.from(snapByTick.keys()).sort((a, b) => a - b); + snapEntries = []; snapPeerCount = []; + for (const tick of snapRounds) { + const eArr = new Int32Array(N); + const pArr = new Int32Array(N); + const m = snapByTick.get(tick); + for (const [ni, d] of m) { eArr[ni] = d.entries; pArr[ni] = d.peer_count; } + snapEntries.push(eArr); + snapPeerCount.push(pArr); + } + + // Compute per-node metrics from events + metricPushesSent = new Int32Array(N); + metricPushesRecv = new Int32Array(N); + metricRedundant = 0; metricTotalPushes = 0; metricNoPeers = 0; + + // Also build cumulative push-recv per round + const roundSet = new Set(snapRounds); + cumulPushRecv = []; + let runningRecv = new Int32Array(N); + + for (let i = 0; i < allEvents.length; i++) { + const ev = allEvents[i]; + const ni = nodeIdx.get(ev.node); + if (ni === undefined) continue; + if (ev.kind === 'GossipRoundStarted') { + metricPushesSent[ni]++; + metricTotalPushes++; + } + if (ev.kind === 'PushReceived') { + metricPushesRecv[ni]++; + runningRecv[ni]++; + if (ev.detail && ev.detail.keys_updated === 0) metricRedundant++; + } + if (ev.kind === 'GossipRoundNoPeers') { metricNoPeers++; } + } + + // Build cumulative recv snapshots aligned to snap rounds + // Re-scan to build per-round cumulative + if (snapRounds.length > 0) { + const cumRecv = new Int32Array(N); + let sri = 0; + for (let i = 0; i < allEvents.length && sri < snapRounds.length; i++) { + const ev = allEvents[i]; + if (ev.kind === 'PushReceived') { + const ni = nodeIdx.get(ev.node); + if (ni !== undefined) cumRecv[ni]++; + } + // When we pass a snapshot round boundary, save + while (sri < snapRounds.length && ev.tick >= snapRounds[sri]) { + cumulPushRecv.push(new Int32Array(cumRecv)); + sri++; + } + } + // Fill remaining + while (sri < snapRounds.length) { cumulPushRecv.push(new Int32Array(cumRecv)); sri++; } + } + // Index PeerAdded edges for (let i = 0; i < allEvents.length; i++) { const ev = allEvents[i]; @@ -694,9 +1390,14 @@ async function loadTrace(file) { const rect = canvas.parentElement.getBoundingClientRect(); await runForceLayout(rect.width, rect.height); + // Community detection + hulls + detectCommunities(); + computeCommunityHulls(); + dot.className = 'status-dot ready'; statusText.textContent = trace.name + ' (' + allEvents.length + ' events)'; drawGraph(); + drawAllCharts(); // Replay controls document.getElementById('replayControls').style.display = 'flex';