stage-net-d network view: cockpit page + JS force-directed graph
This commit is contained in:
@@ -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); }
|
||||||
|
|||||||
510
src/psyc/cockpit/static/federation_network.js
Normal file
510
src/psyc/cockpit/static/federation_network.js
Normal 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 =>
|
||||||
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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> · <a href="/admin/federation/discovery">discovery</a> · <a href="/admin/federation/vouches">vouches</a> · <a href="/admin/federation/quorum">quorum config</a> · <a href="/admin/federation/log">transparency log</a></p>
|
<p class="back"><a href="/admin">← back to admin</a> · <a href="/admin/federation/discovery">discovery</a> · <a href="/admin/federation/vouches">vouches</a> · <a href="/admin/federation/quorum">quorum config</a> · <a href="/admin/federation/log">transparency log</a> · <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>
|
||||||
|
|||||||
51
src/psyc/cockpit/templates/admin_federation_network.html
Normal file
51
src/psyc/cockpit/templates/admin_federation_network.html
Normal 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> · <a href="/admin/federation/discovery">discovery</a> · <a href="/admin/federation/vouches">vouches</a> · <a href="/admin/federation/quorum">quorum</a> · <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 %}
|
||||||
Reference in New Issue
Block a user