stage-net-d network view: cockpit page + JS force-directed graph

This commit is contained in:
m17hr1l
2026-06-07 00:40:13 +02:00
parent 5ff6d80333
commit 5950d34deb
5 changed files with 641 additions and 2 deletions

View File

@@ -1166,3 +1166,81 @@ body.wide .net-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr
.stat-country { border-color: rgba(251,191,36,0.4); color: var(--amber); } .stat-country { border-color: rgba(251,191,36,0.4); color: var(--amber); }
.stat-confidence { border-color: rgba(167,139,250,0.4); color: #c4b5fd; } .stat-confidence { border-color: rgba(167,139,250,0.4); color: #c4b5fd; }
.stat-family { border-color: rgba(248,113,113,0.4); color: var(--red); } .stat-family { border-color: rgba(248,113,113,0.4); color: var(--red); }
/* ── federation network graph ──────────────────────────────── */
.fn-stats { display: flex; flex-wrap: wrap; gap: 10px; margin: 8px 0 18px; }
.fn-stat {
flex: 1; min-width: 120px;
background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px;
padding: 10px 14px;
}
.fn-stat-label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; }
.fn-stat-value { font-family: var(--font-display); font-size: 22px; font-weight: 700; color: var(--accent); margin-top: 4px; text-shadow: 0 0 12px var(--accent-glow); }
#federation-network-graph { display: block; width: 100%; height: 620px; cursor: grab; }
#federation-network-graph:active { cursor: grabbing; }
body.wide #federation-network-graph { height: 720px; }
.fn-edge { stroke: rgba(125,133,151,0.45); stroke-width: 1.2; fill: none; }
.fn-edge-label { fill: var(--muted); font-size: 9px; text-anchor: middle; font-family: ui-monospace, Menlo, monospace; opacity: 0.7; pointer-events: none; }
.fn-kind-vouch .fn-edge { stroke: rgba(74,222,128,0.7); stroke-width: 1.8; }
.fn-kind-vouch .fn-edge-label { fill: rgba(160,240,190,0.85); font-weight: 600; }
.fn-kind-signal .fn-edge { stroke: rgba(30,200,255,0.65); stroke-dasharray: 5 4; }
.fn-kind-signal .fn-edge-label { fill: rgba(170, 220, 255, 0.85); }
.fn-kind-knows .fn-edge { stroke: rgba(125,133,151,0.32); stroke-dasharray: 2 4; }
.fn-kind-knows .fn-edge-label { display: none; }
.fn-edge.alive { animation: fn-flow 1.6s linear infinite; }
.fn-edge.dim { opacity: 0.55; }
@keyframes fn-flow { to { stroke-dashoffset: -54; } }
#federation-network-graph.flow-off .fn-edge.alive { animation: none; }
@media (prefers-reduced-motion: reduce) { .fn-edge.alive { animation: none; } }
.fn-node { cursor: grab; }
.fn-node.dragging { cursor: grabbing; }
.fn-node circle, .fn-node rect { transition: filter 0.15s; }
.fn-node:hover circle, .fn-node:hover rect { filter: drop-shadow(0 0 10px var(--accent)); }
/* Self — accent-glowing rounded square. */
.fn-self rect {
fill: rgba(30,200,255,0.18); stroke: var(--accent); stroke-width: 2;
filter: drop-shadow(0 0 14px var(--accent-glow));
}
.fn-self .fn-label { fill: var(--accent); font-weight: 700; letter-spacing: 0.10em; font-size: 13px; }
/* Direct peers (distance=1). Status drives color. */
.fn-status-trusted circle { fill: rgba(74,222,128,0.12); stroke: var(--green); stroke-width: 2; }
.fn-status-vouched circle { fill: rgba(167,139,250,0.12); stroke: #a78bfa; stroke-width: 1.8; stroke-dasharray: 4 3; }
.fn-status-unknown circle { fill: rgba(125,133,151,0.10); stroke: var(--muted); stroke-width: 1.6; }
.fn-status-blocked circle { fill: rgba(248,113,113,0.10); stroke: var(--red); stroke-width: 1.6; }
/* Transitive (distance=2) — fade and shrink the stroke. */
.fn-distance-2 circle { opacity: 0.78; stroke-width: 1.2; }
.fn-distance-2 .fn-label { fill: var(--muted); font-size: 9.5px; }
.fn-distance-2 .fn-sublabel { display: none; }
.fn-label, .fn-sublabel { text-anchor: middle; pointer-events: none; font-family: var(--font-display); }
.fn-label { fill: var(--text); font-size: 11px; }
.fn-sublabel { fill: var(--muted); font-size: 9px; font-family: ui-monospace, Menlo, monospace; letter-spacing: 0.04em; }
.fn-node.selected circle, .fn-node.selected rect {
filter: drop-shadow(0 0 14px var(--accent));
}
.fn-node.selected .fn-label { fill: #eaf6ff; font-weight: 700; }
/* Legend swatches. */
.lg-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 50%; border: 1.5px solid; vertical-align: -1px; }
.fn-lg-self { border-color: var(--accent); background: rgba(30,200,255,0.18); }
.fn-lg-trusted { border-color: var(--green); background: rgba(74,222,128,0.18); }
.fn-lg-vouched { border-color: #a78bfa; background: rgba(167,139,250,0.18); }
.fn-lg-unknown { border-color: var(--muted); background: rgba(125,133,151,0.18); }
.fn-lg-blocked { border-color: var(--red); background: rgba(248,113,113,0.18); }
/* Detail status badge tinting. */
.fn-status-badge-self { color: var(--accent); border-color: var(--accent); background: rgba(30,200,255,0.10); }
.fn-status-badge-trusted { color: var(--green); border-color: var(--green); background: rgba(74,222,128,0.10); }
.fn-status-badge-vouched { color: #c4b5fd; border-color: #a78bfa; background: rgba(167,139,250,0.10); }
.fn-status-badge-unknown { color: var(--muted); border-color: var(--muted); }
.fn-status-badge-blocked { color: var(--red); border-color: var(--red); background: rgba(248,113,113,0.10); }

View File

@@ -0,0 +1,510 @@
/* psyc — federation network force-directed graph.
*
* 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).
*
* 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.
*/
(function () {
"use strict";
const svg = document.getElementById("federation-network-graph");
const detailEl = document.getElementById("fn-detail");
const loadingEl = document.getElementById("fn-loading");
const errorEl = document.getElementById("fn-error");
const transitiveCountEl = document.getElementById("fn-transitive-count");
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);
return r.json();
})
.then(data => {
if (loadingEl) loadingEl.style.display = "none";
render(data);
})
.catch(err => {
if (loadingEl) loadingEl.style.display = "none";
if (errorEl) {
errorEl.style.display = "block";
errorEl.textContent = "✗ failed to load network data: " + err.message;
}
});
function render(data) {
const selfFp = data.self_fingerprint || "";
const nodesData = data.nodes || [];
const edgesData = data.edges || [];
if (transitiveCountEl) {
const n = nodesData.filter(n => (n.distance || 0) >= 2).length;
transitiveCountEl.textContent = String(n);
}
// ---------- build node + edge sim objects -----------------------------
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 n = {
id: nd.fingerprint,
fp: nd.fingerprint,
domain: nd.domain || "",
label: nd.label || nd.fingerprint.slice(0, 8),
status: nd.status || "unknown",
is_self: isSelf,
distance: dist,
r,
x: 0, y: 0, vx: 0, vy: 0, fixed: false,
tooltip: buildTooltip(nd),
};
nodes.push(n);
nodeByFp[n.id] = n;
}
const edges = [];
for (const ed of edgesData) {
const a = nodeByFp[ed.source_fingerprint];
const b = nodeByFp[ed.target_fingerprint];
if (!a || !b) continue;
edges.push({
source: a.id,
target: b.id,
kind: ed.kind || "knows",
weight: Number(ed.weight || 1),
label: ed.label || "",
bidirectional: !!ed.bidirectional,
});
}
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;
const H = parseInt(getComputedStyle(svg).height, 10) || 560;
return { W, H };
}
let { W, H } = viewport();
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
(function seed() {
const cx = W / 2, cy = H / 2;
nodes.forEach((n, i) => {
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;
n.y = cy + ring * Math.sin(ang) + (Math.random() - 0.5) * 20;
});
})();
// 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;
const DAMP = 0.82;
const CENTER_PULL = 0.005;
function tick() {
for (let i = 0; i < nodes.length; i++) {
const a = nodes[i];
for (let j = i + 1; j < nodes.length; j++) {
const b = nodes[j];
const dx = b.x - a.x, dy = b.y - a.y;
const d2 = dx * dx + dy * dy + 0.1;
const d = Math.sqrt(d2);
const f = REPULSION / d2;
const fx = (dx / d) * f, fy = (dy / d) * f;
if (!a.fixed) { a.vx -= fx; a.vy -= fy; }
if (!b.fixed) { b.vx += fx; b.vy += fy; }
}
}
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.
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;
const f = (d - rest) * SPRING_K;
const fx = (dx / d) * f, fy = (dy / d) * f;
if (!a.fixed) { a.vx += fx; a.vy += fy; }
if (!b.fixed) { b.vx -= fx; b.vy -= fy; }
}
for (const n of nodes) {
if (n.fixed) { n.vx = 0; n.vy = 0; continue; }
n.vx += (W / 2 - n.x) * CENTER_PULL;
n.vy += (H / 2 - n.y) * CENTER_PULL;
n.vx *= DAMP; n.vy *= DAMP;
n.x += n.vx; n.y += n.vy;
n.x = Math.max(n.r, Math.min(W - n.r, n.x));
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 ----------------------------------------------------
const ns = "http://www.w3.org/2000/svg";
const edgesG = document.createElementNS(ns, "g");
const nodesG = document.createElementNS(ns, "g");
edgesG.setAttribute("class", "fn-edges");
nodesG.setAttribute("class", "fn-nodes");
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);
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));
}
grp.appendChild(ln);
if (e.label) {
const lbl = document.createElementNS(ns, "text");
lbl.setAttribute("class", "fn-edge-label");
lbl.textContent = e.label;
grp.appendChild(lbl);
}
edgesG.appendChild(grp);
return { line: ln, label: grp.querySelector("text") };
});
function _classFor(n) {
if (n.is_self) return "fn-node fn-self";
const dist = n.distance >= 2 ? " fn-distance-2" : " fn-distance-1";
return "fn-node fn-status-" + n.status + dist;
}
const nodeEls = nodes.map(n => {
const g = document.createElementNS(ns, "g");
g.setAttribute("class", _classFor(n));
g.dataset.fp = n.fp;
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);
} else {
const c = document.createElementNS(ns, "circle");
c.setAttribute("r", n.r);
g.appendChild(c);
}
const text = document.createElementNS(ns, "text");
text.setAttribute("class", "fn-label");
text.setAttribute("dy", n.r + 13);
text.textContent = n.label;
g.appendChild(text);
if (!n.is_self) {
const sub = document.createElementNS(ns, "text");
sub.setAttribute("class", "fn-sublabel");
sub.setAttribute("dy", n.r + 24);
sub.textContent = n.fp.slice(0, 8) + "…";
g.appendChild(sub);
}
const title = document.createElementNS(ns, "title");
title.textContent = n.tooltip;
g.appendChild(title);
nodesG.appendChild(g);
return g;
});
function paint() {
for (let i = 0; i < edges.length; i++) {
const e = edges[i];
const a = nodeByFp[e.source], b = nodeByFp[e.target];
if (!a || !b) continue;
const els = edgeEls[i];
els.line.setAttribute("x1", a.x); els.line.setAttribute("y1", a.y);
els.line.setAttribute("x2", b.x); els.line.setAttribute("y2", b.y);
if (els.label) {
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
els.label.setAttribute("x", mx); els.label.setAttribute("y", my - 3);
}
}
for (let i = 0; i < nodes.length; i++) {
nodeEls[i].setAttribute("transform", `translate(${nodes[i].x},${nodes[i].y})`);
}
}
paint();
// ---------- drag + click --------------------------------------------
let dragging = null, dragOffset = { x: 0, y: 0 };
let pressedNode = null, pressedAt = null, moved = false;
function svgPoint(clientX, clientY) {
const pt = svg.createSVGPoint(); pt.x = clientX; pt.y = clientY;
return pt.matrixTransform(svg.getScreenCTM().inverse());
}
nodeEls.forEach((g, i) => {
g.addEventListener("mousedown", ev => {
ev.preventDefault();
pressedNode = nodes[i];
pressedAt = { x: ev.clientX, y: ev.clientY };
moved = false;
dragging = nodes[i];
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");
});
});
document.addEventListener("mousemove", ev => {
if (pressedAt) {
const dx = ev.clientX - pressedAt.x, dy = ev.clientY - pressedAt.y;
if (dx * dx + dy * dy > 16) moved = true;
}
if (!dragging) return;
const p = svgPoint(ev.clientX, ev.clientY);
dragging.x = p.x - dragOffset.x; dragging.y = p.y - dragOffset.y;
dragging.vx = 0; dragging.vy = 0;
energyBudget = 80;
});
document.addEventListener("mouseup", () => {
if (dragging) {
const g = nodesG.querySelector(`[data-fp="${CSS.escape(dragging.fp)}"]`);
if (g) g.classList.remove("dragging");
if (currentLayout === "force") dragging.fixed = false;
dragging = null;
}
if (pressedNode && !moved) selectNode(pressedNode);
pressedNode = null; pressedAt = null;
});
svg.addEventListener("click", ev => {
if (!ev.target.closest(".fn-node")) clearSelection();
});
// ---------- 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 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++;
}
return { vouchOut, vouchIn, signalsIn, knows };
}
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 html = `
<div class="td-head">
<span class="td-kind ${kindCls}">${esc(kindLabel)}</span>
<h3 class="td-title">${esc(n.domain || n.label || n.fp.slice(0, 12))}</h3>
${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}
`;
detailEl.innerHTML = html;
detailEl.classList.add("has-selection");
const close = detailEl.querySelector(".td-close");
if (close) close.addEventListener("click", clearSelection);
detailEl.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
// ---------- idle animation ------------------------------------------
let energyBudget = 40;
function loop() {
let moving = false;
for (const n of nodes) {
if (Math.abs(n.vx) > 0.04 || Math.abs(n.vy) > 0.04) { moving = true; break; }
}
if (moving || energyBudget > 0 || dragging) {
tick(); paint();
if (energyBudget > 0) energyBudget--;
}
requestAnimationFrame(loop);
}
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");
if (e.kind === "knows") ln.classList.add("dim");
});
const flowToggle = document.getElementById("fn-flow");
function applyFlowToggle() {
svg.classList.toggle("flow-off", !(flowToggle && flowToggle.checked));
}
applyFlowToggle();
if (flowToggle) flowToggle.addEventListener("change", applyFlowToggle);
// ---------- layout modes --------------------------------------------
function unfix() { for (const n of nodes) n.fixed = false; }
function clearVel() { for (const n of nodes) { n.vx = 0; n.vy = 0; } }
function applyForce() {
unfix();
for (const n of nodes) {
if (n.is_self) { n.x = W / 2; n.y = H / 2; n.fixed = true; continue; }
n.vx = (Math.random() - 0.5) * 5;
n.vy = (Math.random() - 0.5) * 5;
}
energyBudget = 300;
}
function applyHierarchical() {
const self = nodes.find(n => n.is_self);
const direct = nodes.filter(n => !n.is_self && n.distance === 1);
const transitive = nodes.filter(n => n.distance >= 2);
if (self) { self.x = W / 2; self.y = 70; self.fixed = true; }
direct.forEach((n, i) => {
n.x = W * (i + 1) / (direct.length + 1);
n.y = H * 0.42;
n.fixed = true;
});
const tCount = transitive.length || 1;
transitive.forEach((n, i) => {
n.x = W * (i + 1) / (tCount + 1);
n.y = H * 0.78;
n.fixed = true;
});
clearVel(); paint();
}
function applyRadial() {
const self = nodes.find(n => n.is_self);
const direct = nodes.filter(n => !n.is_self && n.distance === 1);
const transitive = nodes.filter(n => n.distance >= 2);
const R1 = Math.min(W, H) * 0.22;
const R2 = Math.min(W, H) * 0.40;
if (self) { self.x = W / 2; self.y = H / 2; self.fixed = true; }
const dCount = direct.length || 1;
direct.forEach((n, i) => {
const a = (i / dCount) * Math.PI * 2 - Math.PI / 2;
n.x = W / 2 + R1 * Math.cos(a);
n.y = H / 2 + R1 * Math.sin(a);
n.fixed = true;
});
const tCount = transitive.length || 1;
transitive.forEach((n, i) => {
const a = (i / tCount) * Math.PI * 2 - Math.PI / 2;
n.x = W / 2 + R2 * Math.cos(a);
n.y = H / 2 + R2 * Math.sin(a);
n.fixed = true;
});
clearVel(); paint();
}
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; }
document.querySelectorAll(".topo-layout").forEach(btn => {
btn.addEventListener("click", () => {
const mode = btn.dataset.layout;
if (!LAYOUTS[mode] || mode === currentLayout) return;
document.querySelectorAll(".topo-layout").forEach(b => b.classList.toggle("is-active", b === btn));
currentLayout = mode;
LAYOUTS[mode]();
});
});
const resetBtn = document.getElementById("fn-reset");
if (resetBtn) {
resetBtn.addEventListener("click", () => {
if (currentLayout === "force") {
for (const n of nodes) {
if (n.is_self) continue;
n.vx = (Math.random() - 0.5) * 6;
n.vy = (Math.random() - 0.5) * 6;
}
energyBudget = 200;
} else {
LAYOUTS[currentLayout]();
}
});
}
// Wheel zoom.
let zoom = 1, panX = 0, panY = 0;
svg.addEventListener("wheel", ev => {
ev.preventDefault();
const delta = ev.deltaY > 0 ? 1.1 : 0.9;
zoom = Math.max(0.3, Math.min(2.5, zoom * delta));
const vw = W / zoom, vh = H / zoom;
svg.setAttribute("viewBox", `${(W - vw) / 2 + panX} ${(H - vh) / 2 + panY} ${vw} ${vh}`);
}, { passive: false });
window.addEventListener("resize", () => {
const v = viewport();
W = v.W; H = v.H;
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
energyBudget = 60;
});
}
})();

View File

@@ -5,7 +5,7 @@
// This makes the cockpit installable as a PWA and survives flaky connections, // This makes the cockpit installable as a PWA and survives flaky connections,
// without serving stale operational data behind the operator's back. // without serving stale operational data behind the operator's back.
const CACHE_VERSION = "psyc-v5"; const CACHE_VERSION = "psyc-v6";
const STATIC_ASSETS = [ const STATIC_ASSETS = [
"/static/cockpit.css", "/static/cockpit.css",
"/static/psyc-tokens.css", "/static/psyc-tokens.css",

View File

@@ -8,7 +8,7 @@
<span class="count">{{ peers|length }} peer{{ '' if peers|length == 1 else 's' }}</span> <span class="count">{{ peers|length }} peer{{ '' if peers|length == 1 else 's' }}</span>
</div> </div>
<p class="page-intro">This node's Ed25519 identity. The fingerprint goes into a DNS TXT record so other psyc nodes can discover this one. The public key lets them verify any feed we publish — the private key never leaves this box.</p> <p class="page-intro">This node's Ed25519 identity. The fingerprint goes into a DNS TXT record so other psyc nodes can discover this one. The public key lets them verify any feed we publish — the private key never leaves this box.</p>
<p class="back"><a href="/admin">← back to admin</a> &nbsp;·&nbsp; <a href="/admin/federation/discovery">discovery</a> &nbsp;·&nbsp; <a href="/admin/federation/vouches">vouches</a> &nbsp;·&nbsp; <a href="/admin/federation/quorum">quorum config</a> &nbsp;·&nbsp; <a href="/admin/federation/log">transparency log</a></p> <p class="back"><a href="/admin">← back to admin</a> &nbsp;·&nbsp; <a href="/admin/federation/discovery">discovery</a> &nbsp;·&nbsp; <a href="/admin/federation/vouches">vouches</a> &nbsp;·&nbsp; <a href="/admin/federation/quorum">quorum config</a> &nbsp;·&nbsp; <a href="/admin/federation/log">transparency log</a> &nbsp;·&nbsp; <a href="/admin/federation/network">network</a></p>
<div class="card" style="margin-bottom:14px;"> <div class="card" style="margin-bottom:14px;">
<div class="lg-sub">node fingerprint</div> <div class="lg-sub">node fingerprint</div>

View File

@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}Federation network — psyc admin{% endblock %}
{% block body_class %}wide{% endblock %}
{% block content %}
<section class="panel">
<div class="panel-head">
<h1>Federation Network</h1>
<span class="count">{{ stats.total_peers }} direct · <span id="fn-transitive-count"></span> transitive</span>
</div>
<p class="page-intro">Force-directed map of the federation this node sits inside. Self at the center, directly-registered peers at distance 1, peers-of-peers (fetched from each trusted peer's <code>/federation/network</code>) at distance 2. Edges: <em>vouches</em> (solid), <em>signals</em> (dashed, thickness ∝ 24h volume), <em>knows</em> (dotted grey).</p>
<p class="back"><a href="/admin/federation">← federation hub</a> &nbsp;·&nbsp; <a href="/admin/federation/discovery">discovery</a> &nbsp;·&nbsp; <a href="/admin/federation/vouches">vouches</a> &nbsp;·&nbsp; <a href="/admin/federation/quorum">quorum</a> &nbsp;·&nbsp; <a href="/admin/federation/log">log</a></p>
<div class="fn-stats">
<div class="fn-stat"><div class="fn-stat-label">direct peers</div><div class="fn-stat-value">{{ stats.total_peers }}</div></div>
<div class="fn-stat"><div class="fn-stat-label">vouched / trusted</div><div class="fn-stat-value">{{ stats.vouched_peers }}</div></div>
<div class="fn-stat"><div class="fn-stat-label">vouches issued</div><div class="fn-stat-value">{{ stats.vouches_issued }}</div></div>
<div class="fn-stat"><div class="fn-stat-label">signals (24h)</div><div class="fn-stat-value">{{ stats.signals_buffered_24h }}</div></div>
<div class="fn-stat"><div class="fn-stat-label">distinct hashes</div><div class="fn-stat-value">{{ stats.distinct_signal_hashes_24h }}</div></div>
<div class="fn-stat"><div class="fn-stat-label">quorum-met</div><div class="fn-stat-value">{{ stats.quorum_met_count }}</div></div>
</div>
<div class="topo-stage">
<div class="topo-toolbar">
<div class="topo-layouts" role="tablist">
<button type="button" class="topo-layout is-active" data-layout="force" title="organic force-directed">Force</button>
<button type="button" class="topo-layout" data-layout="hier" title="self on top, direct peers in a row, transitives below">Hierarchical</button>
<button type="button" class="topo-layout" data-layout="radial" title="self at center, peers on a ring, transitives outside">Radial</button>
</div>
<label class="topo-toggle"><input type="checkbox" id="fn-flow" checked> signal flow</label>
<span class="topo-legend"><span class="lg-swatch fn-lg-self"></span>self</span>
<span class="topo-legend"><span class="lg-swatch fn-lg-trusted"></span>trusted</span>
<span class="topo-legend"><span class="lg-swatch fn-lg-vouched"></span>vouched</span>
<span class="topo-legend"><span class="lg-swatch fn-lg-unknown"></span>unknown</span>
<span class="topo-legend"><span class="lg-swatch fn-lg-blocked"></span>blocked</span>
<button type="button" id="fn-reset" class="btn">re-settle</button>
<span class="topo-hint">drag · scroll to zoom</span>
</div>
<svg id="federation-network-graph" preserveAspectRatio="xMidYMid meet"></svg>
<div id="fn-loading" class="page-intro" style="text-align:center; margin-top:10px;">loading federation network…</div>
<div id="fn-error" class="gate-error" style="display:none;"></div>
</div>
<div id="fn-detail" class="topo-detail">
<p class="topo-detail-empty">Click any node in the graph above to inspect it.</p>
</div>
<p class="page-intro" style="margin-top:18px;">Self fingerprint: <code style="color:var(--accent);">{{ fingerprint }}</code></p>
</section>
<script src="/static/federation_network.js" defer></script>
{% endblock %}