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
Click any node in the graph above to inspect it.
'; } - 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 `${k}${n}`; + }).join(""); + return `→ open peer in /admin/federation
` - : ""; + + 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 = `${esc(n.fp)}` +
+ ``;
+ const identity = `
+ ${esc(n.fp)}