diff --git a/src/psyc/cockpit/static/federation_network.js b/src/psyc/cockpit/static/federation_network.js index 2c1dbda..807ec3b 100644 --- a/src/psyc/cockpit/static/federation_network.js +++ b/src/psyc/cockpit/static/federation_network.js @@ -1,13 +1,18 @@ -/* psyc — federation network force-directed graph. +/* psyc — federation network force-directed graph (enriched detail layer). * * Self at center, direct peers around it, transitive peers (distance=2) on * the outer ring. Edges: vouch (solid), signal (dashed, animated, thickness - * ∝ weight), knows (dotted grey). + * ∝ weight), knows (dotted grey), corroborate (dotted accent, faint pulse). * - * Pure vanilla JS, no deps. Mirrors topology.js's proven force-sim loop so - * the two pages feel familiar; once both are stable the shared engine can - * factor out into a force_graph.js module — for now, a copy keeps the diff - * narrow. + * Compared to the previous version this file additionally renders: + * • per-peer compact stat badge below the sublabel + * • opacity-scaled fill based on log(signals_24h) for non-self nodes + * • a search/filter bar that dims non-matching nodes/edges + * • a hover tooltip with key stats + * • a much richer click-to-inspect detail panel + * • a 24h stacked timeline strip at the bottom of the page + * + * Pure vanilla JS, no deps. Mirrors topology.js's proven force-sim loop. */ (function () { @@ -18,10 +23,14 @@ const loadingEl = document.getElementById("fn-loading"); const errorEl = document.getElementById("fn-error"); const transitiveCountEl = document.getElementById("fn-transitive-count"); + const tooltipEl = document.getElementById("fn-tooltip"); + const searchEl = document.getElementById("fn-search"); + const searchCountEl = document.getElementById("fn-search-count"); + const timelineEl = document.getElementById("fn-timeline"); + const timelineAxisEl = document.getElementById("fn-timeline-axis"); + const timelineMetaEl = document.getElementById("fn-timeline-meta"); if (!svg) return; - // Fetch data, then build the graph. The /data endpoint includes transitive - // peers (mid-cost cached server-side at 5 min TTL). fetch("/admin/federation/network/data", { credentials: "same-origin" }) .then(r => { if (!r.ok) throw new Error("HTTP " + r.status); @@ -39,23 +48,68 @@ } }); + // ---------- shared escape ----------------------------------------------- + function esc(s) { + return String(s == null ? "" : s).replace(/[&<>"']/g, c => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); + } + function shortFp(fp) { + if (!fp) return "—"; + if (fp.length >= 16) return fp.slice(0, 8) + "…" + fp.slice(-8); + return fp; + } + function render(data) { const selfFp = data.self_fingerprint || ""; const nodesData = data.nodes || []; const edgesData = data.edges || []; + const topStats = data.stats || {}; if (transitiveCountEl) { const n = nodesData.filter(n => (n.distance || 0) >= 2).length; transitiveCountEl.textContent = String(n); } + // ---------- color palette for peers (timeline + tooltips) ------------- + // 12 evenly-spaced HSL hues, accent-leaning. Used in the timeline strip + // to colorize per-peer segments inside each bar. + const PEER_PALETTE = [ + "#1ec8ff", "#a78bfa", "#4ade80", "#fbbf24", "#f87171", "#34d399", + "#60a5fa", "#f472b6", "#fb923c", "#22d3ee", "#c084fc", "#facc15", + ]; + const peerColor = Object.create(null); + const peerOrder = nodesData + .filter(n => !n.is_self) + .map(n => n.fingerprint) + .sort(); + peerOrder.forEach((fp, i) => { + peerColor[fp] = PEER_PALETTE[i % PEER_PALETTE.length]; + }); + // ---------- build node + edge sim objects ----------------------------- + // Pre-compute the max 24h signal count so we can log-normalize fill + // opacity per node (busy peer → fully saturated, quiet → faint). + let maxSignals24h = 0; + for (const nd of nodesData) { + const s = (nd.stats && nd.stats.signals_24h) || 0; + if (s > maxSignals24h) maxSignals24h = s; + } + const nodes = []; const nodeByFp = Object.create(null); for (const nd of nodesData) { const isSelf = !!nd.is_self; const dist = Number(nd.distance || 0); const r = isSelf ? 38 : (dist >= 2 ? 9 : 16); + const stats = nd.stats || null; + let intensity = 1; + if (!isSelf && stats && maxSignals24h > 0) { + // log scale so a 100-signal peer doesn't blow out a 5-signal one. + const s = stats.signals_24h || 0; + const num = Math.log2(s + 1); + const den = Math.log2(maxSignals24h + 1) || 1; + intensity = 0.18 + 0.82 * (num / den); + } const n = { id: nd.fingerprint, fp: nd.fingerprint, @@ -64,9 +118,10 @@ status: nd.status || "unknown", is_self: isSelf, distance: dist, + stats, + intensity, r, x: 0, y: 0, vx: 0, vy: 0, fixed: false, - tooltip: buildTooltip(nd), }; nodes.push(n); nodeByFp[n.id] = n; @@ -87,15 +142,6 @@ }); } - function buildTooltip(nd) { - const lines = []; - lines.push((nd.is_self ? "self · " : "") + (nd.domain || nd.label || nd.fingerprint)); - lines.push("fp: " + nd.fingerprint); - lines.push("status: " + (nd.status || "unknown")); - lines.push("distance: " + (nd.distance || 0)); - return lines.join("\n"); - } - // ---------- viewport + seeding --------------------------------------- function viewport() { const W = svg.clientWidth || 900; @@ -108,10 +154,7 @@ (function seed() { const cx = W / 2, cy = H / 2; nodes.forEach((n, i) => { - if (n.is_self) { - n.x = cx; n.y = cy; - return; - } + if (n.is_self) { n.x = cx; n.y = cy; return; } const ring = n.distance >= 2 ? Math.min(W, H) * 0.40 : Math.min(W, H) * 0.22; const ang = (i / Math.max(1, nodes.length)) * Math.PI * 2; n.x = cx + ring * Math.cos(ang) + (Math.random() - 0.5) * 20; @@ -119,8 +162,6 @@ }); })(); - // Force-sim params — same shape as topology.js, slightly softer springs - // so the two rings settle visibly. const REPULSION = 1500; const SPRING_K = 0.035; const SPRING_REST_BASE = 110; @@ -144,7 +185,8 @@ for (const e of edges) { const a = nodeByFp[e.source], b = nodeByFp[e.target]; if (!a || !b) continue; - // "knows" edges (distance-2) rest longer so transitive bands stay clear. + // corroborate edges are decorative; don't pull on the layout. + if (e.kind === "corroborate") continue; const rest = e.kind === "knows" ? SPRING_REST_BASE + 60 : SPRING_REST_BASE; const dx = b.x - a.x, dy = b.y - a.y; const d = Math.sqrt(dx * dx + dy * dy) + 0.1; @@ -163,30 +205,38 @@ n.y = Math.max(n.r, Math.min(H - n.r, n.y)); } } - - // Pre-settle so the first frame isn't a glob. for (let i = 0; i < 280; i++) tick(); - // ---------- render ---------------------------------------------------- + // ---------- render SVG groups ---------------------------------------- + // Order matters: corroborate edges go first so they sit behind the + // primary edges, then the rest, then nodes on top. const ns = "http://www.w3.org/2000/svg"; + const corrG = document.createElementNS(ns, "g"); const edgesG = document.createElementNS(ns, "g"); const nodesG = document.createElementNS(ns, "g"); + corrG.setAttribute("class", "fn-edges fn-edges-corr"); edgesG.setAttribute("class", "fn-edges"); nodesG.setAttribute("class", "fn-nodes"); + svg.appendChild(corrG); svg.appendChild(edgesG); svg.appendChild(nodesG); const edgeEls = edges.map(e => { const grp = document.createElementNS(ns, "g"); grp.setAttribute("class", "fn-edge-grp fn-kind-" + e.kind); + grp.dataset.source = e.source; + grp.dataset.target = e.target; const ln = document.createElementNS(ns, "line"); ln.setAttribute("class", "fn-edge"); - // Signal weight controls stroke width; cap at 5px so a noisy peer - // doesn't blot out the layout. if (e.kind === "signal") { const w = Math.min(5, 1 + Math.log2(e.weight + 1) * 0.8); ln.setAttribute("stroke-width", w.toFixed(2)); } + if (e.kind === "corroborate") { + // Stroke width hints at how many shared hashes this pair has. + const w = Math.min(3, 0.8 + Math.log2(e.weight + 1) * 0.5); + ln.setAttribute("stroke-width", w.toFixed(2)); + } grp.appendChild(ln); if (e.label) { const lbl = document.createElementNS(ns, "text"); @@ -194,8 +244,9 @@ lbl.textContent = e.label; grp.appendChild(lbl); } - edgesG.appendChild(grp); - return { line: ln, label: grp.querySelector("text") }; + const host = e.kind === "corroborate" ? corrG : edgesG; + host.appendChild(grp); + return { line: ln, label: grp.querySelector("text"), grp }; }); function _classFor(n) { @@ -209,17 +260,20 @@ g.setAttribute("class", _classFor(n)); g.dataset.fp = n.fp; + let shape; if (n.is_self) { const sz = n.r; - const rect = document.createElementNS(ns, "rect"); - rect.setAttribute("x", -sz); rect.setAttribute("y", -sz); - rect.setAttribute("width", sz * 2); rect.setAttribute("height", sz * 2); - rect.setAttribute("rx", 10); rect.setAttribute("ry", 10); - g.appendChild(rect); + shape = document.createElementNS(ns, "rect"); + shape.setAttribute("x", -sz); shape.setAttribute("y", -sz); + shape.setAttribute("width", sz * 2); shape.setAttribute("height", sz * 2); + shape.setAttribute("rx", 10); shape.setAttribute("ry", 10); + g.appendChild(shape); } else { - const c = document.createElementNS(ns, "circle"); - c.setAttribute("r", n.r); - g.appendChild(c); + shape = document.createElementNS(ns, "circle"); + shape.setAttribute("r", n.r); + // Log-normalized fill opacity: quiet peer → faint, busy → full. + shape.setAttribute("fill-opacity", n.intensity.toFixed(2)); + g.appendChild(shape); } const text = document.createElementNS(ns, "text"); @@ -234,10 +288,25 @@ sub.setAttribute("dy", n.r + 24); sub.textContent = n.fp.slice(0, 8) + "…"; g.appendChild(sub); + + // Stat badge — compact "↓ signals · ✓ vouches-in · ⚡ quorum". + // Hidden for distance=2 nodes via CSS, since their data is sparse. + if (n.stats) { + const badge = document.createElementNS(ns, "text"); + badge.setAttribute("class", "fn-stat-badge"); + badge.setAttribute("dy", n.r + 36); + const s = n.stats; + badge.textContent = + "↓ " + (s.signals_24h || 0) + + " · ✓ " + (s.vouches_in_count || 0) + + " · ⚡ " + (s.quorum_contribution || 0); + g.appendChild(badge); + } } + // Native stays as an accessibility fallback for keyboard users. const title = document.createElementNS(ns, "title"); - title.textContent = n.tooltip; + title.textContent = (n.is_self ? "self · " : "") + (n.domain || n.label || n.fp); g.appendChild(title); nodesG.appendChild(g); @@ -263,7 +332,46 @@ } paint(); - // ---------- drag + click -------------------------------------------- + // ---------- tooltip -------------------------------------------------- + function showTooltip(n, clientX, clientY) { + if (!tooltipEl) return; + const s = n.stats || {}; + const rows = []; + rows.push(`<div class="fn-tooltip-title">${esc(n.domain || n.label || n.fp.slice(0,12))}</div>`); + rows.push(`<div class="fn-tooltip-row"><span class="k">status</span><span class="v">${esc(n.status)}</span></div>`); + if (!n.is_self) { + rows.push(`<div class="fn-tooltip-row"><span class="k">signals 24h</span><span class="v">${s.signals_24h || 0}</span></div>`); + rows.push(`<div class="fn-tooltip-row"><span class="k">last seen</span><span class="v">${esc(s.last_seen_relative || "—")}</span></div>`); + rows.push(`<div class="fn-tooltip-row"><span class="k">vouches</span><span class="v">in ${s.vouches_in_count || 0} · out ${s.vouches_out_count || 0}</span></div>`); + rows.push(`<div class="fn-tooltip-row"><span class="k">quorum</span><span class="v">${s.quorum_contribution || 0}</span></div>`); + } else { + rows.push(`<div class="fn-tooltip-row"><span class="k">peers</span><span class="v">${topStats.total_peers || 0}</span></div>`); + rows.push(`<div class="fn-tooltip-row"><span class="k">signals 24h</span><span class="v">${topStats.signals_buffered_24h || 0}</span></div>`); + rows.push(`<div class="fn-tooltip-row"><span class="k">quorum-met</span><span class="v">${topStats.quorum_met_count || 0}</span></div>`); + } + tooltipEl.innerHTML = rows.join(""); + tooltipEl.classList.add("is-visible"); + positionTooltip(clientX, clientY); + } + function positionTooltip(clientX, clientY) { + if (!tooltipEl) return; + const parent = svg.parentElement; + if (!parent) return; + const rect = parent.getBoundingClientRect(); + let x = clientX - rect.left + 14; + let y = clientY - rect.top + 14; + const tw = tooltipEl.offsetWidth || 240; + const th = tooltipEl.offsetHeight || 100; + if (x + tw > parent.clientWidth - 4) x = clientX - rect.left - tw - 14; + if (y + th > parent.clientHeight - 4) y = clientY - rect.top - th - 14; + tooltipEl.style.left = x + "px"; + tooltipEl.style.top = y + "px"; + } + function hideTooltip() { + if (tooltipEl) tooltipEl.classList.remove("is-visible"); + } + + // ---------- drag + click + hover ------------------------------------ let dragging = null, dragOffset = { x: 0, y: 0 }; let pressedNode = null, pressedAt = null, moved = false; function svgPoint(clientX, clientY) { @@ -271,17 +379,21 @@ return pt.matrixTransform(svg.getScreenCTM().inverse()); } nodeEls.forEach((g, i) => { + const n = nodes[i]; g.addEventListener("mousedown", ev => { ev.preventDefault(); - pressedNode = nodes[i]; + pressedNode = n; pressedAt = { x: ev.clientX, y: ev.clientY }; moved = false; - dragging = nodes[i]; + dragging = n; const p = svgPoint(ev.clientX, ev.clientY); dragOffset.x = p.x - dragging.x; dragOffset.y = p.y - dragging.y; if (currentLayout === "force") dragging.fixed = true; g.classList.add("dragging"); }); + g.addEventListener("mouseenter", ev => showTooltip(n, ev.clientX, ev.clientY)); + g.addEventListener("mousemove", ev => positionTooltip(ev.clientX, ev.clientY)); + g.addEventListener("mouseleave", hideTooltip); }); document.addEventListener("mousemove", ev => { if (pressedAt) { @@ -309,44 +421,166 @@ }); // ---------- detail panel -------------------------------------------- - function esc(s) { - return String(s == null ? "" : s).replace(/[&<>"']/g, c => - ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); - } - function selectNode(n) { nodeEls.forEach(el => el.classList.remove("selected")); const me = nodesG.querySelector(`[data-fp="${CSS.escape(n.fp)}"]`); if (me) me.classList.add("selected"); renderDetail(n); } - + function jumpToFp(fp) { + const target = nodeByFp[fp]; + if (!target) return; + selectNode(target); + } function clearSelection() { nodeEls.forEach(el => el.classList.remove("selected")); if (detailEl) detailEl.innerHTML = '<p class="topo-detail-empty">Click any node in the graph above to inspect it.</p>'; } - function countEdges(fp) { - let vouchOut = 0, vouchIn = 0, signalsIn = 0, knows = 0; - for (const e of edges) { - if (e.kind === "vouch" && e.source === fp) vouchOut++; - else if (e.kind === "vouch" && e.target === fp) vouchIn++; - else if (e.kind === "signal" && e.target === fp) signalsIn += e.weight; - else if (e.kind === "signal" && e.source === fp) signalsIn += e.weight; - else if (e.kind === "knows" && (e.source === fp || e.target === fp)) knows++; + // Aggregate self stats from all peer.stats blocks so the self node has + // a meaningful detail card too. + function selfStats() { + let signals_24h = 0, vouches_in = 0, vouches_out = 0, quorum = 0; + let cases_24h = 0, iocs_24h = 0; + const sev = { critical: 0, high: 0, medium: 0, low: 0 }; + const iocType = { url: 0, domain: 0, ip: 0, hash: 0, cve: 0 }; + for (const nd of nodes) { + if (nd.is_self || !nd.stats) continue; + signals_24h += nd.stats.signals_24h || 0; + cases_24h += nd.stats.cases_24h || 0; + iocs_24h += nd.stats.iocs_24h || 0; + vouches_in += nd.stats.vouches_in_count || 0; + vouches_out += nd.stats.vouches_out_count || 0; + quorum += nd.stats.quorum_contribution || 0; + const sb = nd.stats.severity_breakdown || {}; + for (const k of Object.keys(sev)) sev[k] += sb[k] || 0; + const ib = nd.stats.ioc_type_breakdown || {}; + for (const k of Object.keys(iocType)) iocType[k] += ib[k] || 0; } - return { vouchOut, vouchIn, signalsIn, knows }; + return { + signals_24h, cases_24h, iocs_24h, + severity_breakdown: sev, ioc_type_breakdown: iocType, + vouches_in_count: vouches_in, vouches_out_count: vouches_out, + quorum_contribution: quorum, + }; + } + + function sevRow(sev) { + const order = ["critical", "high", "medium", "low"]; + const cells = order.map(k => { + const n = (sev && sev[k]) || 0; + return `<span class="fn-sev-chip fn-sev-${k}">${k}<span class="n">${n}</span></span>`; + }).join(""); + return `<div class="fn-sev-row">${cells}</div>`; + } + function iocRow(it) { + const order = ["url", "domain", "ip", "hash", "cve"]; + const cells = order.map(k => { + const n = (it && it[k]) || 0; + return `<span class="fn-ioc-chip"><span class="k">${k}</span><span class="n">${n}</span></span>`; + }).join(""); + return `<div class="fn-sev-row">${cells}</div>`; } function renderDetail(n) { if (!detailEl) return; - const stats = countEdges(n.fp); const kindLabel = n.is_self ? "SELF" : (n.distance >= 2 ? "TRANSITIVE" : "DIRECT PEER"); const kindCls = n.is_self ? "td-kind-host" : (n.distance >= 2 ? "td-kind-cont" : "td-kind-net"); const statusBadge = `<span class="state-badge fn-status-badge-${esc(n.status)}">${esc(n.status)}</span>`; - const jumpBack = (!n.is_self && n.domain) - ? `<p style="margin-top:10px;"><a href="/admin/federation" class="td-jump">→ open peer in /admin/federation</a></p>` - : ""; + + const s = n.is_self ? selfStats() : (n.stats || {}); + const signals24h = s.signals_24h || 0; + const sigQuorum = s.quorum_contribution || 0; + const quorumPct = signals24h > 0 ? Math.min(100, Math.round((sigQuorum / signals24h) * 100)) : 0; + + // Identity section. Self has no remote "last_seen"; show "—". + const fullFp = `<code class="full-fp">${esc(n.fp)}</code>` + + `<button type="button" class="fn-copy-btn" data-copy="${esc(n.fp)}">copy</button>`; + const identity = ` + <div class="fn-detail-sec"> + <h4>identity</h4> + <div class="row"><span class="k">fingerprint</span><span class="v">${fullFp}</span></div> + <div class="row"><span class="k">domain</span><span class="v">${n.domain ? esc(n.domain) : "—"}</span></div> + <div class="row"><span class="k">status</span><span class="v">${statusBadge}</span></div> + <div class="row"><span class="k">distance</span><span class="v">${n.is_self ? "self" : (n.distance >= 2 ? "transitive (2 hops)" : "direct (1 hop)")}</span></div> + <div class="row"><span class="k">last seen</span><span class="v">${esc((n.stats && n.stats.last_seen_relative) || "—")}</span></div> + </div>`; + + // Signals section. + const signals = ` + <div class="fn-detail-sec"> + <h4>signals · 24h</h4> + <div class="row"><span class="k">total</span><span class="v">${signals24h}</span></div> + <div class="row"><span class="k">cases</span><span class="v">${s.cases_24h || 0}</span></div> + <div class="row"><span class="k">iocs</span><span class="v">${s.iocs_24h || 0}</span></div> + <div class="row"><span class="k">all-time</span><span class="v">${(n.stats && n.stats.signals_total) || (n.is_self ? "—" : 0)}</span></div> + ${sevRow(s.severity_breakdown)} + ${iocRow(s.ioc_type_breakdown)} + </div>`; + + // Vouches section. + const vouchesPeerList = (() => { + if (n.is_self) return ""; + const count = (n.stats && n.stats.vouches_in_count) || 0; + if (!count) return ""; + // Find the actual voucher fingerprints by scanning rendered nodes + // — server doesn't ship the list per-peer, but the edge list does. + const vouchers = edges + .filter(e => e.kind === "vouch" && e.target === n.fp) + .map(e => e.source); + // Plus the case where peer-to-peer vouches exist as data but no edge + // (transitive nodes don't always get vouch edges); show known set. + const uniq = Array.from(new Set(vouchers)); + if (!uniq.length) return ""; + const chips = uniq.map(fp => + `<button type="button" class="fn-fp-jump" data-jump="${esc(fp)}">${esc(shortFp(fp))}</button>` + ).join(""); + return `<div style="margin-top:6px;">${chips}</div>`; + })(); + const vouches = ` + <div class="fn-detail-sec"> + <h4>vouches</h4> + <div class="row"><span class="k">in</span><span class="v">${s.vouches_in_count || 0}</span></div> + <div class="row"><span class="k">out</span><span class="v">${s.vouches_out_count || 0}</span></div> + ${vouchesPeerList} + </div>`; + + // Quorum section — small progress bar of "signals that are quorum-met". + const quorum = ` + <div class="fn-detail-sec"> + <h4>quorum</h4> + <div class="row"><span class="k">contribution</span><span class="v">${sigQuorum}</span></div> + <div class="row"><span class="k">of 24h total</span><span class="v">${quorumPct}%</span></div> + <div class="fn-quorum-bar"><div class="fn-quorum-fill" style="width:${quorumPct}%;"></div></div> + </div>`; + + // Transparency log section. + const translog = (() => { + const entries = (n.stats && n.stats.recent_translog) || []; + if (!entries.length) { + return `<div class="fn-detail-sec"><h4>transparency log</h4><div class="row"><span class="k">recent</span><span class="v">—</span></div></div>`; + } + const items = entries.map(e => { + const ts = (e.timestamp || "").slice(0, 19).replace("T", " "); + const hash = (e.hash || "").slice(0, 12); + return `<li><span class="id">#${esc(e.id)}</span><span class="type">${esc(e.entry_type)}</span><span class="ts">${esc(ts)}</span><span class="hash">${esc(hash)}…</span></li>`; + }).join(""); + return `<div class="fn-detail-sec"><h4>transparency log</h4><ul class="fn-trans-list">${items}</ul></div>`; + })(); + + // Actions. + const rawStatsUrl = "/admin/federation/network/data"; + const actions = ` + <div class="fn-detail-sec"> + <h4>actions</h4> + <div class="fn-actions"> + <a class="fn-action-btn" href="/admin/federation">peer registry</a> + <a class="fn-action-btn" href="/admin/federation/vouches">vouches</a> + <a class="fn-action-btn" href="/admin/federation/quorum">quorum</a> + <a class="fn-action-btn" href="${rawStatsUrl}" target="_blank" rel="noopener">raw JSON</a> + </div> + </div>`; + const html = ` <div class="td-head"> <span class="td-kind ${kindCls}">${esc(kindLabel)}</span> @@ -354,20 +588,43 @@ ${statusBadge} <button type="button" class="td-close" aria-label="close">×</button> </div> - <dl class="td-kv"> - <dt>Fingerprint</dt><dd><code>${esc(n.fp)}</code></dd> - <dt>Domain</dt><dd>${n.domain ? esc(n.domain) : "—"}</dd> - <dt>Distance</dt><dd>${esc(n.distance)} hop${n.distance === 1 ? "" : "s"}</dd> - <dt>Vouches</dt><dd>out: ${stats.vouchOut} · in: ${stats.vouchIn}</dd> - <dt>Signals (24h)</dt><dd>${stats.signalsIn ? stats.signalsIn.toFixed(0) : "0"}</dd> - <dt>Knows-edges</dt><dd>${stats.knows}</dd> - </dl> - ${jumpBack} + <div class="fn-detail-card"> + ${identity} + ${signals} + ${vouches} + ${quorum} + ${translog} + ${actions} + </div> `; detailEl.innerHTML = html; detailEl.classList.add("has-selection"); + const close = detailEl.querySelector(".td-close"); if (close) close.addEventListener("click", clearSelection); + + // Wire copy buttons. + detailEl.querySelectorAll(".fn-copy-btn").forEach(btn => { + btn.addEventListener("click", () => { + const v = btn.getAttribute("data-copy") || ""; + if (!v) return; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(v).catch(() => {}); + } + const t = btn.textContent; + btn.textContent = "copied"; + setTimeout(() => { btn.textContent = t; }, 1100); + }); + }); + + // Wire jump-to-fp chips. + detailEl.querySelectorAll(".fn-fp-jump").forEach(btn => { + btn.addEventListener("click", () => { + const fp = btn.getAttribute("data-jump") || ""; + if (fp) jumpToFp(fp); + }); + }); + detailEl.scrollIntoView({ behavior: "smooth", block: "nearest" }); } @@ -387,8 +644,6 @@ loop(); // ---------- edge liveness + flow toggle ----------------------------- - // Signal edges always flow (we just saw N signals in 24h). Vouch edges - // are static. Knows edges fade. edges.forEach((e, i) => { const ln = edgeEls[i].line; if (e.kind === "signal") ln.classList.add("alive"); @@ -460,8 +715,6 @@ const LAYOUTS = { force: applyForce, hier: applyHierarchical, radial: applyRadial }; let currentLayout = "force"; - // Force-mode bootstraps with self pinned at center — so the very first - // settle radiates outward naturally. const selfNode = nodes.find(n => n.is_self); if (selfNode) { selfNode.x = W / 2; selfNode.y = H / 2; selfNode.fixed = true; } @@ -491,6 +744,91 @@ }); } + // ---------- search / filter ----------------------------------------- + function applySearch(qRaw) { + const q = (qRaw || "").trim().toLowerCase(); + if (!q) { + nodeEls.forEach(el => el.classList.remove("dimmed", "match")); + edgeEls.forEach(els => els.grp.classList.remove("dimmed")); + if (searchCountEl) searchCountEl.textContent = ""; + return; + } + const matchFps = new Set(); + nodes.forEach((n, i) => { + const hay = (n.label || "") + " " + (n.domain || "") + " " + n.fp; + if (hay.toLowerCase().indexOf(q) !== -1) { + matchFps.add(n.fp); + nodeEls[i].classList.add("match"); + nodeEls[i].classList.remove("dimmed"); + } else { + nodeEls[i].classList.remove("match"); + nodeEls[i].classList.add("dimmed"); + } + }); + edges.forEach((e, i) => { + const visible = matchFps.has(e.source) || matchFps.has(e.target); + edgeEls[i].grp.classList.toggle("dimmed", !visible); + }); + if (searchCountEl) { + searchCountEl.textContent = matchFps.size + " match" + (matchFps.size === 1 ? "" : "es"); + } + } + if (searchEl) { + searchEl.addEventListener("input", ev => applySearch(ev.target.value)); + } + + // ---------- 24h timeline strip -------------------------------------- + function renderTimeline() { + if (!timelineEl) return; + const buckets = topStats.signal_timeline_24h || []; + if (!buckets.length) { + timelineEl.innerHTML = `<div class="fn-timeline-empty">no signals in the last 24h</div>`; + if (timelineAxisEl) timelineAxisEl.innerHTML = ""; + if (timelineMetaEl) timelineMetaEl.textContent = "0 signals"; + return; + } + // Find max bucket total for height-scaling. + let maxTotal = 0; + let allTotal = 0; + for (const b of buckets) { + if ((b.total || 0) > maxTotal) maxTotal = b.total || 0; + allTotal += b.total || 0; + } + if (timelineMetaEl) { + timelineMetaEl.textContent = + `${allTotal} signal${allTotal === 1 ? "" : "s"} · peak ${maxTotal}/hr`; + } + const bars = buckets.map((b, idx) => { + const total = b.total || 0; + const perPeer = b.per_peer || {}; + const hPct = maxTotal > 0 ? Math.round((total / maxTotal) * 100) : 0; + const segHtml = Object.keys(perPeer).map(fp => { + const seg = perPeer[fp]; + const pct = total > 0 ? (seg / total) * hPct : 0; + const color = peerColor[fp] || "var(--accent)"; + return `<div class="fn-timeline-bar-seg" data-fp="${esc(fp)}" data-n="${seg}" style="height:${pct.toFixed(2)}%;background:${color};"></div>`; + }).join(""); + const hoursAgo = 23 - idx; + const tooltipLines = [`${hoursAgo}h ago · ${total} signals`]; + for (const fp of Object.keys(perPeer)) { + tooltipLines.push(` ${shortFp(fp)}: ${perPeer[fp]}`); + } + return `<div class="fn-timeline-bar" data-hour="${hoursAgo}" title="${esc(tooltipLines.join("\n"))}">${segHtml}</div>`; + }).join(""); + timelineEl.innerHTML = bars; + + if (timelineAxisEl) { + // Axis labels every 6 hours. + const axis = buckets.map((b, idx) => { + const hoursAgo = 23 - idx; + const show = (hoursAgo % 6 === 0); + return `<span>${show ? "-" + hoursAgo + "h" : ""}</span>`; + }).join(""); + timelineAxisEl.innerHTML = axis; + } + } + renderTimeline(); + // Wheel zoom. let zoom = 1, panX = 0, panY = 0; svg.addEventListener("wheel", ev => {