diff --git a/src/psyc/cockpit/static/federation_explore.js b/src/psyc/cockpit/static/federation_explore.js new file mode 100644 index 0000000..20be78a --- /dev/null +++ b/src/psyc/cockpit/static/federation_explore.js @@ -0,0 +1,780 @@ +/* psyc — federation explorer (public, transparency view). + * + * Forked from federation_network.js, adapted for the public surface: + * • data source is /federation/explore/data (signed, CORS-enabled) + * • clicking a peer opens a walk-to-peer card with a primary CTA + * that full-page-navigates to that peer's own /federation/explore + * • the transparency log can be re-verified live from the page + * • inbound vouches (who vouches for THIS node) get their own section + * • severity/IOC-type breakdowns are intentionally NOT surfaced — + * those stay admin-only to avoid sector-leaking via the public page + * + * Pure vanilla JS, no deps. Mirrors topology.js's proven force-sim loop. + */ + +(function () { + "use strict"; + + const svg = document.getElementById("federation-network-graph"); + const loadingEl = document.getElementById("fn-loading"); + const errorEl = document.getElementById("fn-error"); + const tooltipEl = document.getElementById("fn-tooltip"); + const walkEl = document.getElementById("fe-walk"); + const directCountEl = document.getElementById("fe-direct-count"); + const transitiveCountEl = document.getElementById("fe-transitive-count"); + const kpiPeers = document.getElementById("fe-kpi-peers"); + const kpiVouchesOut = document.getElementById("fe-kpi-vouches-out"); + const kpiVouchesIn = document.getElementById("fe-kpi-vouches-in"); + const kpiSignals = document.getElementById("fe-kpi-signals"); + const kpiCorroboration = document.getElementById("fe-kpi-corroboration"); + const kpiTranslog = document.getElementById("fe-kpi-translog"); + const kpiVerify = document.getElementById("fe-kpi-verify"); + const verifyBtn = document.getElementById("fe-verify-btn"); + const verifyResult = document.getElementById("fe-verify-result"); + const vouchesInList = document.getElementById("fe-vouches-in-list"); + const vouchesInCountEl = document.getElementById("fe-vouches-in-count"); + const settings = window.PSYC_EXPLORE || {}; + + if (!svg) return; + + // ---------- shared escape ----------------------------------------------- + function esc(s) { + return String(s == null ? "" : s).replace(/[&<>"']/g, c => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); + } + function shortFp(fp) { + if (!fp) return "—"; + if (fp.length >= 16) return fp.slice(0, 8) + "…" + fp.slice(-8); + return fp; + } + function fmtAge(iso) { + if (!iso) return "—"; + const ts = new Date(iso); + if (isNaN(ts.getTime())) return "—"; + const secs = Math.floor((Date.now() - ts.getTime()) / 1000); + if (secs < 0) return "just now"; + if (secs < 60) return secs + "s ago"; + if (secs < 3600) return Math.floor(secs / 60) + "m ago"; + if (secs < 86400) return Math.floor(secs / 3600) + "h ago"; + return Math.floor(secs / 86400) + "d ago"; + } + + fetch("/federation/explore/data", { credentials: "omit" }) + .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 explore payload: " + err.message; + } + }); + + // ---------- verify button — fetch /federation/log/verify --------------- + if (verifyBtn) { + verifyBtn.addEventListener("click", () => { + verifyBtn.disabled = true; + verifyResult.textContent = "verifying…"; + verifyResult.classList.remove("fe-verify-ok", "fe-verify-bad"); + fetch("/federation/log/verify", { credentials: "omit" }) + .then(r => r.json().then(b => ({ status: r.status, body: b }))) + .then(({ status, body }) => { + if (status === 200 && body.verified != null) { + verifyResult.textContent = "✓ verified " + body.verified + " entries · head " + (body.head_hash || "").slice(0, 12) + "…"; + verifyResult.classList.add("fe-verify-ok"); + if (kpiVerify) { + kpiVerify.textContent = "✓ ok"; + kpiVerify.classList.add("fe-verify-ok"); + kpiVerify.classList.remove("fe-verify-bad"); + } + } else { + verifyResult.textContent = "✗ " + (body.error || "chain invalid"); + verifyResult.classList.add("fe-verify-bad"); + if (kpiVerify) { + kpiVerify.textContent = "✗ broken"; + kpiVerify.classList.add("fe-verify-bad"); + kpiVerify.classList.remove("fe-verify-ok"); + } + } + }) + .catch(err => { + verifyResult.textContent = "✗ fetch failed: " + err.message; + verifyResult.classList.add("fe-verify-bad"); + }) + .finally(() => { verifyBtn.disabled = false; }); + }); + } + + function render(data) { + const node = data.node || {}; + const selfFp = data.fingerprint || node.fingerprint || ""; + const peersData = data.peers || []; + const transitiveData = data.transitive_peers || []; + const vouchesIn = data.vouches_in || []; + const vouchesOut = data.vouches_out || data.vouches || []; + + // ---------- KPI strip ------------------------------------------------ + if (kpiPeers) kpiPeers.textContent = String(node.peer_count != null ? node.peer_count : peersData.length); + if (kpiVouchesOut) kpiVouchesOut.textContent = String(node.vouches_out_count != null ? node.vouches_out_count : vouchesOut.length); + if (kpiVouchesIn) kpiVouchesIn.textContent = String(node.vouches_in_count != null ? node.vouches_in_count : vouchesIn.length); + if (kpiSignals) kpiSignals.textContent = String(node.signals_count_24h || 0); + if (kpiCorroboration) kpiCorroboration.textContent = String(node.corroboration_count_24h || 0); + if (kpiTranslog) kpiTranslog.textContent = String(node.translog_entry_count || 0); + if (kpiVerify) kpiVerify.textContent = "unverified"; + + if (directCountEl) directCountEl.textContent = String(peersData.length); + if (transitiveCountEl) transitiveCountEl.textContent = String(transitiveData.length); + + // ---------- node + edge model ---------------------------------------- + // The explore payload doesn't ship edges directly; we derive them from + // the vouches + per-peer signal counts so the graph reads the same way + // the admin view does. + const peerByFp = Object.create(null); + const nodes = []; + + // Self at the center. + const selfNode = { + id: selfFp, fp: selfFp, + domain: settings.selfDomain || node.domain || "", + label: settings.selfDomain || (node.domain || "self"), + status: "self", + is_self: true, + distance: 0, + stats: null, + r: 38, + intensity: 1, + x: 0, y: 0, vx: 0, vy: 0, fixed: false, + }; + nodes.push(selfNode); + peerByFp[selfFp] = selfNode; + + // Max signal count for log-intensity normalization. + let maxSig = 0; + for (const p of peersData) { + if ((p.signal_count_24h || 0) > maxSig) maxSig = p.signal_count_24h || 0; + } + + for (const p of peersData) { + const fp = p.fingerprint; + if (!fp || fp === selfFp) continue; + const sig = p.signal_count_24h || 0; + let intensity = 1; + if (maxSig > 0) { + const num = Math.log2(sig + 1); + const den = Math.log2(maxSig + 1) || 1; + intensity = 0.20 + 0.80 * (num / den); + } + const n = { + id: fp, fp, + domain: p.domain || "", + label: p.domain || shortFp(fp), + status: "trusted", + is_self: false, + distance: 1, + stats: p, + r: 16, + intensity, + x: 0, y: 0, vx: 0, vy: 0, fixed: false, + }; + nodes.push(n); + peerByFp[fp] = n; + } + + for (const t of transitiveData) { + const fp = t.fingerprint; + if (!fp || peerByFp[fp]) continue; + const n = { + id: fp, fp, + domain: t.domain || "", + label: t.domain || shortFp(fp), + status: "unknown", + is_self: false, + distance: 2, + stats: null, + via: t.via_peer_fingerprint || "", + r: 9, + intensity: 0.7, + x: 0, y: 0, vx: 0, vy: 0, fixed: false, + }; + nodes.push(n); + peerByFp[fp] = n; + } + + // Edges. Per-peer signal counts → signal edges; outbound vouches → + // vouch edges; vouches_in → bidirectional vouch edges; transitive + // "via" → knows edges. + const edges = []; + for (const p of peersData) { + const fp = p.fingerprint; + if (!fp || fp === selfFp) continue; + if ((p.signal_count_24h || 0) > 0) { + edges.push({ + source: fp, target: selfFp, kind: "signal", + weight: p.signal_count_24h, label: p.signal_count_24h + " signals/24h", + bidirectional: false, + }); + } + } + // Outbound vouches. + const outbound = new Set(); + for (const v of vouchesOut) { + const tgt = v.target_fingerprint; + if (!tgt || !peerByFp[tgt]) continue; + outbound.add(tgt); + edges.push({ + source: selfFp, target: tgt, kind: "vouch", + weight: 1, label: "vouched", bidirectional: false, + }); + } + // Inbound vouches — collapse onto existing outbound where possible. + for (const v of vouchesIn) { + const src = v.voucher_fingerprint; + if (!src || !peerByFp[src]) continue; + if (outbound.has(src)) { + const existing = edges.find(e => e.kind === "vouch" + && e.source === selfFp && e.target === src); + if (existing) { + existing.bidirectional = true; + existing.label = "vouched ↔"; + continue; + } + } + edges.push({ + source: src, target: selfFp, kind: "vouch", + weight: 1, label: "vouches us", bidirectional: false, + }); + } + // Transitive "knows" edges. + for (const t of transitiveData) { + const parent = t.via_peer_fingerprint; + if (!parent || !peerByFp[parent] || !peerByFp[t.fingerprint]) continue; + edges.push({ + source: parent, target: t.fingerprint, kind: "knows", + weight: 0.5, label: "knows", bidirectional: false, + }); + } + + // ---------- 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; + }); + })(); + + 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 = peerByFp[e.source], b = peerByFp[e.target]; + if (!a || !b) continue; + 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)); + } + } + for (let i = 0; i < 280; i++) tick(); + + // ---------- render SVG groups ---------------------------------------- + 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); + grp.dataset.source = e.source; + grp.dataset.target = e.target; + const ln = document.createElementNS(ns, "line"); + ln.setAttribute("class", "fn-edge"); + 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"), grp }; + }); + + 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; + + let shape; + if (n.is_self) { + const sz = n.r; + shape = document.createElementNS(ns, "rect"); + shape.setAttribute("x", -sz); shape.setAttribute("y", -sz); + shape.setAttribute("width", sz * 2); shape.setAttribute("height", sz * 2); + shape.setAttribute("rx", 10); shape.setAttribute("ry", 10); + g.appendChild(shape); + } else { + shape = document.createElementNS(ns, "circle"); + shape.setAttribute("r", n.r); + shape.setAttribute("fill-opacity", n.intensity.toFixed(2)); + g.appendChild(shape); + } + + 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); + + if (n.stats) { + const badge = document.createElementNS(ns, "text"); + badge.setAttribute("class", "fn-stat-badge"); + badge.setAttribute("dy", n.r + 36); + badge.textContent = + "↓ " + (n.stats.signal_count_24h || 0) + + " · ⚡ " + (n.stats.quorum_contribution_24h || 0); + g.appendChild(badge); + } + } + + const title = document.createElementNS(ns, "title"); + title.textContent = (n.is_self ? "self · " : "") + (n.domain || n.label || n.fp); + g.appendChild(title); + + nodesG.appendChild(g); + return g; + }); + + function paint() { + for (let i = 0; i < edges.length; i++) { + const e = edges[i]; + const a = peerByFp[e.source], b = peerByFp[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(); + + // ---------- tooltip -------------------------------------------------- + function showTooltip(n, clientX, clientY) { + if (!tooltipEl) return; + const rows = []; + rows.push(`
${esc(fp)}
+
+ ${esc(v.issued_at || "")}
+