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(n.fp)}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
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
+ +Click any node in the graph above to inspect it.
+Self fingerprint: {{ fingerprint }}