stage-netd-c network detail: rich detail panel + hover tooltips + search/intensity + timeline JS

This commit is contained in:
m17hr1l
2026-06-07 00:57:49 +02:00
parent 15749e050e
commit 70b6af6a35

View File

@@ -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 =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 <title> 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 =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 => {