stage-netd-c network detail: rich detail panel + hover tooltips + search/intensity + timeline JS
This commit is contained in:
@@ -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
|
* Self at center, direct peers around it, transitive peers (distance=2) on
|
||||||
* the outer ring. Edges: vouch (solid), signal (dashed, animated, thickness
|
* 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
|
* Compared to the previous version this file additionally renders:
|
||||||
* the two pages feel familiar; once both are stable the shared engine can
|
* • per-peer compact stat badge below the sublabel
|
||||||
* factor out into a force_graph.js module — for now, a copy keeps the diff
|
* • opacity-scaled fill based on log(signals_24h) for non-self nodes
|
||||||
* narrow.
|
* • 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 () {
|
(function () {
|
||||||
@@ -18,10 +23,14 @@
|
|||||||
const loadingEl = document.getElementById("fn-loading");
|
const loadingEl = document.getElementById("fn-loading");
|
||||||
const errorEl = document.getElementById("fn-error");
|
const errorEl = document.getElementById("fn-error");
|
||||||
const transitiveCountEl = document.getElementById("fn-transitive-count");
|
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;
|
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" })
|
fetch("/admin/federation/network/data", { credentials: "same-origin" })
|
||||||
.then(r => {
|
.then(r => {
|
||||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
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) {
|
function render(data) {
|
||||||
const selfFp = data.self_fingerprint || "";
|
const selfFp = data.self_fingerprint || "";
|
||||||
const nodesData = data.nodes || [];
|
const nodesData = data.nodes || [];
|
||||||
const edgesData = data.edges || [];
|
const edgesData = data.edges || [];
|
||||||
|
const topStats = data.stats || {};
|
||||||
|
|
||||||
if (transitiveCountEl) {
|
if (transitiveCountEl) {
|
||||||
const n = nodesData.filter(n => (n.distance || 0) >= 2).length;
|
const n = nodesData.filter(n => (n.distance || 0) >= 2).length;
|
||||||
transitiveCountEl.textContent = String(n);
|
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 -----------------------------
|
// ---------- 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 nodes = [];
|
||||||
const nodeByFp = Object.create(null);
|
const nodeByFp = Object.create(null);
|
||||||
for (const nd of nodesData) {
|
for (const nd of nodesData) {
|
||||||
const isSelf = !!nd.is_self;
|
const isSelf = !!nd.is_self;
|
||||||
const dist = Number(nd.distance || 0);
|
const dist = Number(nd.distance || 0);
|
||||||
const r = isSelf ? 38 : (dist >= 2 ? 9 : 16);
|
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 = {
|
const n = {
|
||||||
id: nd.fingerprint,
|
id: nd.fingerprint,
|
||||||
fp: nd.fingerprint,
|
fp: nd.fingerprint,
|
||||||
@@ -64,9 +118,10 @@
|
|||||||
status: nd.status || "unknown",
|
status: nd.status || "unknown",
|
||||||
is_self: isSelf,
|
is_self: isSelf,
|
||||||
distance: dist,
|
distance: dist,
|
||||||
|
stats,
|
||||||
|
intensity,
|
||||||
r,
|
r,
|
||||||
x: 0, y: 0, vx: 0, vy: 0, fixed: false,
|
x: 0, y: 0, vx: 0, vy: 0, fixed: false,
|
||||||
tooltip: buildTooltip(nd),
|
|
||||||
};
|
};
|
||||||
nodes.push(n);
|
nodes.push(n);
|
||||||
nodeByFp[n.id] = 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 ---------------------------------------
|
// ---------- viewport + seeding ---------------------------------------
|
||||||
function viewport() {
|
function viewport() {
|
||||||
const W = svg.clientWidth || 900;
|
const W = svg.clientWidth || 900;
|
||||||
@@ -108,10 +154,7 @@
|
|||||||
(function seed() {
|
(function seed() {
|
||||||
const cx = W / 2, cy = H / 2;
|
const cx = W / 2, cy = H / 2;
|
||||||
nodes.forEach((n, i) => {
|
nodes.forEach((n, i) => {
|
||||||
if (n.is_self) {
|
if (n.is_self) { n.x = cx; n.y = cy; return; }
|
||||||
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 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;
|
const ang = (i / Math.max(1, nodes.length)) * Math.PI * 2;
|
||||||
n.x = cx + ring * Math.cos(ang) + (Math.random() - 0.5) * 20;
|
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 REPULSION = 1500;
|
||||||
const SPRING_K = 0.035;
|
const SPRING_K = 0.035;
|
||||||
const SPRING_REST_BASE = 110;
|
const SPRING_REST_BASE = 110;
|
||||||
@@ -144,7 +185,8 @@
|
|||||||
for (const e of edges) {
|
for (const e of edges) {
|
||||||
const a = nodeByFp[e.source], b = nodeByFp[e.target];
|
const a = nodeByFp[e.source], b = nodeByFp[e.target];
|
||||||
if (!a || !b) continue;
|
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 rest = e.kind === "knows" ? SPRING_REST_BASE + 60 : SPRING_REST_BASE;
|
||||||
const dx = b.x - a.x, dy = b.y - a.y;
|
const dx = b.x - a.x, dy = b.y - a.y;
|
||||||
const d = Math.sqrt(dx * dx + dy * dy) + 0.1;
|
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));
|
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();
|
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 ns = "http://www.w3.org/2000/svg";
|
||||||
|
const corrG = document.createElementNS(ns, "g");
|
||||||
const edgesG = document.createElementNS(ns, "g");
|
const edgesG = document.createElementNS(ns, "g");
|
||||||
const nodesG = document.createElementNS(ns, "g");
|
const nodesG = document.createElementNS(ns, "g");
|
||||||
|
corrG.setAttribute("class", "fn-edges fn-edges-corr");
|
||||||
edgesG.setAttribute("class", "fn-edges");
|
edgesG.setAttribute("class", "fn-edges");
|
||||||
nodesG.setAttribute("class", "fn-nodes");
|
nodesG.setAttribute("class", "fn-nodes");
|
||||||
|
svg.appendChild(corrG);
|
||||||
svg.appendChild(edgesG);
|
svg.appendChild(edgesG);
|
||||||
svg.appendChild(nodesG);
|
svg.appendChild(nodesG);
|
||||||
|
|
||||||
const edgeEls = edges.map(e => {
|
const edgeEls = edges.map(e => {
|
||||||
const grp = document.createElementNS(ns, "g");
|
const grp = document.createElementNS(ns, "g");
|
||||||
grp.setAttribute("class", "fn-edge-grp fn-kind-" + e.kind);
|
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");
|
const ln = document.createElementNS(ns, "line");
|
||||||
ln.setAttribute("class", "fn-edge");
|
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") {
|
if (e.kind === "signal") {
|
||||||
const w = Math.min(5, 1 + Math.log2(e.weight + 1) * 0.8);
|
const w = Math.min(5, 1 + Math.log2(e.weight + 1) * 0.8);
|
||||||
ln.setAttribute("stroke-width", w.toFixed(2));
|
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);
|
grp.appendChild(ln);
|
||||||
if (e.label) {
|
if (e.label) {
|
||||||
const lbl = document.createElementNS(ns, "text");
|
const lbl = document.createElementNS(ns, "text");
|
||||||
@@ -194,8 +244,9 @@
|
|||||||
lbl.textContent = e.label;
|
lbl.textContent = e.label;
|
||||||
grp.appendChild(lbl);
|
grp.appendChild(lbl);
|
||||||
}
|
}
|
||||||
edgesG.appendChild(grp);
|
const host = e.kind === "corroborate" ? corrG : edgesG;
|
||||||
return { line: ln, label: grp.querySelector("text") };
|
host.appendChild(grp);
|
||||||
|
return { line: ln, label: grp.querySelector("text"), grp };
|
||||||
});
|
});
|
||||||
|
|
||||||
function _classFor(n) {
|
function _classFor(n) {
|
||||||
@@ -209,17 +260,20 @@
|
|||||||
g.setAttribute("class", _classFor(n));
|
g.setAttribute("class", _classFor(n));
|
||||||
g.dataset.fp = n.fp;
|
g.dataset.fp = n.fp;
|
||||||
|
|
||||||
|
let shape;
|
||||||
if (n.is_self) {
|
if (n.is_self) {
|
||||||
const sz = n.r;
|
const sz = n.r;
|
||||||
const rect = document.createElementNS(ns, "rect");
|
shape = document.createElementNS(ns, "rect");
|
||||||
rect.setAttribute("x", -sz); rect.setAttribute("y", -sz);
|
shape.setAttribute("x", -sz); shape.setAttribute("y", -sz);
|
||||||
rect.setAttribute("width", sz * 2); rect.setAttribute("height", sz * 2);
|
shape.setAttribute("width", sz * 2); shape.setAttribute("height", sz * 2);
|
||||||
rect.setAttribute("rx", 10); rect.setAttribute("ry", 10);
|
shape.setAttribute("rx", 10); shape.setAttribute("ry", 10);
|
||||||
g.appendChild(rect);
|
g.appendChild(shape);
|
||||||
} else {
|
} else {
|
||||||
const c = document.createElementNS(ns, "circle");
|
shape = document.createElementNS(ns, "circle");
|
||||||
c.setAttribute("r", n.r);
|
shape.setAttribute("r", n.r);
|
||||||
g.appendChild(c);
|
// 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");
|
const text = document.createElementNS(ns, "text");
|
||||||
@@ -234,10 +288,25 @@
|
|||||||
sub.setAttribute("dy", n.r + 24);
|
sub.setAttribute("dy", n.r + 24);
|
||||||
sub.textContent = n.fp.slice(0, 8) + "…";
|
sub.textContent = n.fp.slice(0, 8) + "…";
|
||||||
g.appendChild(sub);
|
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");
|
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);
|
g.appendChild(title);
|
||||||
|
|
||||||
nodesG.appendChild(g);
|
nodesG.appendChild(g);
|
||||||
@@ -263,7 +332,46 @@
|
|||||||
}
|
}
|
||||||
paint();
|
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 dragging = null, dragOffset = { x: 0, y: 0 };
|
||||||
let pressedNode = null, pressedAt = null, moved = false;
|
let pressedNode = null, pressedAt = null, moved = false;
|
||||||
function svgPoint(clientX, clientY) {
|
function svgPoint(clientX, clientY) {
|
||||||
@@ -271,17 +379,21 @@
|
|||||||
return pt.matrixTransform(svg.getScreenCTM().inverse());
|
return pt.matrixTransform(svg.getScreenCTM().inverse());
|
||||||
}
|
}
|
||||||
nodeEls.forEach((g, i) => {
|
nodeEls.forEach((g, i) => {
|
||||||
|
const n = nodes[i];
|
||||||
g.addEventListener("mousedown", ev => {
|
g.addEventListener("mousedown", ev => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
pressedNode = nodes[i];
|
pressedNode = n;
|
||||||
pressedAt = { x: ev.clientX, y: ev.clientY };
|
pressedAt = { x: ev.clientX, y: ev.clientY };
|
||||||
moved = false;
|
moved = false;
|
||||||
dragging = nodes[i];
|
dragging = n;
|
||||||
const p = svgPoint(ev.clientX, ev.clientY);
|
const p = svgPoint(ev.clientX, ev.clientY);
|
||||||
dragOffset.x = p.x - dragging.x; dragOffset.y = p.y - dragging.y;
|
dragOffset.x = p.x - dragging.x; dragOffset.y = p.y - dragging.y;
|
||||||
if (currentLayout === "force") dragging.fixed = true;
|
if (currentLayout === "force") dragging.fixed = true;
|
||||||
g.classList.add("dragging");
|
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 => {
|
document.addEventListener("mousemove", ev => {
|
||||||
if (pressedAt) {
|
if (pressedAt) {
|
||||||
@@ -309,44 +421,166 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------- detail panel --------------------------------------------
|
// ---------- detail panel --------------------------------------------
|
||||||
function esc(s) {
|
|
||||||
return String(s == null ? "" : s).replace(/[&<>"']/g, c =>
|
|
||||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectNode(n) {
|
function selectNode(n) {
|
||||||
nodeEls.forEach(el => el.classList.remove("selected"));
|
nodeEls.forEach(el => el.classList.remove("selected"));
|
||||||
const me = nodesG.querySelector(`[data-fp="${CSS.escape(n.fp)}"]`);
|
const me = nodesG.querySelector(`[data-fp="${CSS.escape(n.fp)}"]`);
|
||||||
if (me) me.classList.add("selected");
|
if (me) me.classList.add("selected");
|
||||||
renderDetail(n);
|
renderDetail(n);
|
||||||
}
|
}
|
||||||
|
function jumpToFp(fp) {
|
||||||
|
const target = nodeByFp[fp];
|
||||||
|
if (!target) return;
|
||||||
|
selectNode(target);
|
||||||
|
}
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
nodeEls.forEach(el => el.classList.remove("selected"));
|
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>';
|
if (detailEl) detailEl.innerHTML = '<p class="topo-detail-empty">Click any node in the graph above to inspect it.</p>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function countEdges(fp) {
|
// Aggregate self stats from all peer.stats blocks so the self node has
|
||||||
let vouchOut = 0, vouchIn = 0, signalsIn = 0, knows = 0;
|
// a meaningful detail card too.
|
||||||
for (const e of edges) {
|
function selfStats() {
|
||||||
if (e.kind === "vouch" && e.source === fp) vouchOut++;
|
let signals_24h = 0, vouches_in = 0, vouches_out = 0, quorum = 0;
|
||||||
else if (e.kind === "vouch" && e.target === fp) vouchIn++;
|
let cases_24h = 0, iocs_24h = 0;
|
||||||
else if (e.kind === "signal" && e.target === fp) signalsIn += e.weight;
|
const sev = { critical: 0, high: 0, medium: 0, low: 0 };
|
||||||
else if (e.kind === "signal" && e.source === fp) signalsIn += e.weight;
|
const iocType = { url: 0, domain: 0, ip: 0, hash: 0, cve: 0 };
|
||||||
else if (e.kind === "knows" && (e.source === fp || e.target === fp)) knows++;
|
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) {
|
function renderDetail(n) {
|
||||||
if (!detailEl) return;
|
if (!detailEl) return;
|
||||||
const stats = countEdges(n.fp);
|
|
||||||
const kindLabel = n.is_self ? "SELF" : (n.distance >= 2 ? "TRANSITIVE" : "DIRECT PEER");
|
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 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 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 = `
|
const html = `
|
||||||
<div class="td-head">
|
<div class="td-head">
|
||||||
<span class="td-kind ${kindCls}">${esc(kindLabel)}</span>
|
<span class="td-kind ${kindCls}">${esc(kindLabel)}</span>
|
||||||
@@ -354,20 +588,43 @@
|
|||||||
${statusBadge}
|
${statusBadge}
|
||||||
<button type="button" class="td-close" aria-label="close">×</button>
|
<button type="button" class="td-close" aria-label="close">×</button>
|
||||||
</div>
|
</div>
|
||||||
<dl class="td-kv">
|
<div class="fn-detail-card">
|
||||||
<dt>Fingerprint</dt><dd><code>${esc(n.fp)}</code></dd>
|
${identity}
|
||||||
<dt>Domain</dt><dd>${n.domain ? esc(n.domain) : "—"}</dd>
|
${signals}
|
||||||
<dt>Distance</dt><dd>${esc(n.distance)} hop${n.distance === 1 ? "" : "s"}</dd>
|
${vouches}
|
||||||
<dt>Vouches</dt><dd>out: ${stats.vouchOut} · in: ${stats.vouchIn}</dd>
|
${quorum}
|
||||||
<dt>Signals (24h)</dt><dd>${stats.signalsIn ? stats.signalsIn.toFixed(0) : "0"}</dd>
|
${translog}
|
||||||
<dt>Knows-edges</dt><dd>${stats.knows}</dd>
|
${actions}
|
||||||
</dl>
|
</div>
|
||||||
${jumpBack}
|
|
||||||
`;
|
`;
|
||||||
detailEl.innerHTML = html;
|
detailEl.innerHTML = html;
|
||||||
detailEl.classList.add("has-selection");
|
detailEl.classList.add("has-selection");
|
||||||
|
|
||||||
const close = detailEl.querySelector(".td-close");
|
const close = detailEl.querySelector(".td-close");
|
||||||
if (close) close.addEventListener("click", clearSelection);
|
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" });
|
detailEl.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,8 +644,6 @@
|
|||||||
loop();
|
loop();
|
||||||
|
|
||||||
// ---------- edge liveness + flow toggle -----------------------------
|
// ---------- 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) => {
|
edges.forEach((e, i) => {
|
||||||
const ln = edgeEls[i].line;
|
const ln = edgeEls[i].line;
|
||||||
if (e.kind === "signal") ln.classList.add("alive");
|
if (e.kind === "signal") ln.classList.add("alive");
|
||||||
@@ -460,8 +715,6 @@
|
|||||||
|
|
||||||
const LAYOUTS = { force: applyForce, hier: applyHierarchical, radial: applyRadial };
|
const LAYOUTS = { force: applyForce, hier: applyHierarchical, radial: applyRadial };
|
||||||
let currentLayout = "force";
|
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);
|
const selfNode = nodes.find(n => n.is_self);
|
||||||
if (selfNode) { selfNode.x = W / 2; selfNode.y = H / 2; selfNode.fixed = true; }
|
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.
|
// Wheel zoom.
|
||||||
let zoom = 1, panX = 0, panY = 0;
|
let zoom = 1, panX = 0, panY = 0;
|
||||||
svg.addEventListener("wheel", ev => {
|
svg.addEventListener("wheel", ev => {
|
||||||
|
|||||||
Reference in New Issue
Block a user