diff --git a/src/psyc/cockpit/static/cockpit.css b/src/psyc/cockpit/static/cockpit.css index 920ceb9..35125f7 100644 --- a/src/psyc/cockpit/static/cockpit.css +++ b/src/psyc/cockpit/static/cockpit.css @@ -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-confidence { border-color: rgba(167,139,250,0.4); color: #c4b5fd; } .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); } diff --git a/src/psyc/cockpit/static/federation_network.js b/src/psyc/cockpit/static/federation_network.js new file mode 100644 index 0000000..2c1dbda --- /dev/null +++ b/src/psyc/cockpit/static/federation_network.js @@ -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 = '

Click any node in the graph above to inspect it.

'; + } + + function countEdges(fp) { + let vouchOut = 0, vouchIn = 0, signalsIn = 0, knows = 0; + for (const e of edges) { + if (e.kind === "vouch" && e.source === fp) vouchOut++; + else if (e.kind === "vouch" && e.target === fp) vouchIn++; + else if (e.kind === "signal" && e.target === fp) signalsIn += e.weight; + else if (e.kind === "signal" && e.source === fp) signalsIn += e.weight; + else if (e.kind === "knows" && (e.source === fp || e.target === fp)) knows++; + } + 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 = `${esc(n.status)}`; + const jumpBack = (!n.is_self && n.domain) + ? `

→ open peer in /admin/federation

` + : ""; + const html = ` +
+ ${esc(kindLabel)} +

${esc(n.domain || n.label || n.fp.slice(0, 12))}

+ ${statusBadge} + +
+
+
Fingerprint
${esc(n.fp)}
+
Domain
${n.domain ? esc(n.domain) : "—"}
+
Distance
${esc(n.distance)} hop${n.distance === 1 ? "" : "s"}
+
Vouches
out: ${stats.vouchOut} · in: ${stats.vouchIn}
+
Signals (24h)
${stats.signalsIn ? stats.signalsIn.toFixed(0) : "0"}
+
Knows-edges
${stats.knows}
+
+ ${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; + }); + } +})(); diff --git a/src/psyc/cockpit/static/sw.js b/src/psyc/cockpit/static/sw.js index 32c8dc1..61c9ee9 100644 --- a/src/psyc/cockpit/static/sw.js +++ b/src/psyc/cockpit/static/sw.js @@ -5,7 +5,7 @@ // This makes the cockpit installable as a PWA and survives flaky connections, // without serving stale operational data behind the operator's back. -const CACHE_VERSION = "psyc-v5"; +const CACHE_VERSION = "psyc-v6"; const STATIC_ASSETS = [ "/static/cockpit.css", "/static/psyc-tokens.css", diff --git a/src/psyc/cockpit/templates/admin_federation.html b/src/psyc/cockpit/templates/admin_federation.html index d201be0..58e8fbe 100644 --- a/src/psyc/cockpit/templates/admin_federation.html +++ b/src/psyc/cockpit/templates/admin_federation.html @@ -8,7 +8,7 @@ {{ peers|length }} peer{{ '' if peers|length == 1 else 's' }}

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.

-

← back to admin  ·  discovery  ·  vouches  ·  quorum config  ·  transparency log

+

← back to admin  ·  discovery  ·  vouches  ·  quorum config  ·  transparency log  ·  network

node fingerprint
diff --git a/src/psyc/cockpit/templates/admin_federation_network.html b/src/psyc/cockpit/templates/admin_federation_network.html new file mode 100644 index 0000000..98487cf --- /dev/null +++ b/src/psyc/cockpit/templates/admin_federation_network.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}Federation network — psyc admin{% endblock %} +{% block body_class %}wide{% endblock %} +{% block content %} +
+
+

Federation Network

+ {{ stats.total_peers }} direct · transitive +
+

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 /federation/network) at distance 2. Edges: vouches (solid), signals (dashed, thickness ∝ 24h volume), knows (dotted grey).

+

← federation hub  ·  discovery  ·  vouches  ·  quorum  ·  log

+ +
+
direct peers
{{ stats.total_peers }}
+
vouched / trusted
{{ stats.vouched_peers }}
+
vouches issued
{{ stats.vouches_issued }}
+
signals (24h)
{{ stats.signals_buffered_24h }}
+
distinct hashes
{{ stats.distinct_signal_hashes_24h }}
+
quorum-met
{{ stats.quorum_met_count }}
+
+ +
+
+
+ + + +
+ + self + trusted + vouched + unknown + blocked + + drag · scroll to zoom +
+ +
loading federation network…
+ +
+ +
+

Click any node in the graph above to inspect it.

+
+ +

Self fingerprint: {{ fingerprint }}

+
+ + +{% endblock %}