From 8587e079bb2c2cc5f77074384a6f293590512166 Mon Sep 17 00:00:00 2001 From: m17hr1l Date: Sun, 7 Jun 2026 01:21:50 +0200 Subject: [PATCH] federation/network: fetch peer's own /federation/explore/data on click + render their self-view inline (their peers, vouches in/out, transitive, translog head) --- src/psyc/cockpit/static/cockpit.css | 26 +++++ src/psyc/cockpit/static/federation_network.js | 109 ++++++++++++++++++ src/psyc/cockpit/static/sw.js | 2 +- 3 files changed, 136 insertions(+), 1 deletion(-) diff --git a/src/psyc/cockpit/static/cockpit.css b/src/psyc/cockpit/static/cockpit.css index 0dab773..13730e9 100644 --- a/src/psyc/cockpit/static/cockpit.css +++ b/src/psyc/cockpit/static/cockpit.css @@ -1798,3 +1798,29 @@ body.wide #federation-network-graph { height: 720px; } } .fe-walk-cta { width: 100%; justify-content: center; } } + +/* peer self-view section inside the detail panel — fetched cross-origin */ +.fn-remote-sec { grid-column: 1 / -1; } +.fn-remote-status { + font-size: 11px; color: var(--muted); margin-left: 8px; font-weight: 400; + text-transform: lowercase; letter-spacing: 0.02em; +} +.fn-remote-meta { + display: flex; flex-wrap: wrap; gap: 12px 18px; + font-size: 12px; color: var(--muted); margin-bottom: 12px; +} +.fn-remote-meta b { color: var(--text); font-weight: 600; } +.fn-remote-meta code { font-size: 11px; color: var(--accent); } +.fn-remote-ok { color: rgba(74,222,128,0.95); } +.fn-remote-warn { color: rgba(251,191,36,0.95); } +.fn-remote-cols { + display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 14px; margin: 8px 0 12px; +} +.fn-remote-h { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; } +.fn-remote-list { list-style: none; padding: 0; margin: 0; font-size: 12px; line-height: 1.55; } +.fn-remote-list li { padding: 2px 0; border-bottom: 1px dashed rgba(125,133,151,0.18); } +.fn-remote-list li:last-child { border-bottom: 0; } +.fn-remote-list code { font-size: 11px; color: var(--accent); } +.fn-remote-list .muted { color: var(--muted); margin-left: 6px; font-size: 11px; } +.fn-remote-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px; } diff --git a/src/psyc/cockpit/static/federation_network.js b/src/psyc/cockpit/static/federation_network.js index 807ec3b..2a7705a 100644 --- a/src/psyc/cockpit/static/federation_network.js +++ b/src/psyc/cockpit/static/federation_network.js @@ -581,6 +581,14 @@ `; + // Placeholder for the peer's-own-view, populated by an async fetch below. + const remote = (!n.is_self && n.domain) + ? `
+

peer's self-view fetching…

+
loading from ${esc(n.domain)}
+
` + : ""; + const html = `
${esc(kindLabel)} @@ -594,12 +602,20 @@ ${vouches} ${quorum} ${translog} + ${remote} ${actions}
`; detailEl.innerHTML = html; detailEl.classList.add("has-selection"); + // Async-fetch the peer's own /federation/explore/data and render their + // self-view inline. CORS is set on that endpoint, so the browser can hit + // any psyc node directly. Falls back to /federation/network on older nodes. + if (!n.is_self && n.domain) { + fetchPeerSelfView(n.domain, n.fp); + } + const close = detailEl.querySelector(".td-close"); if (close) close.addEventListener("click", clearSelection); @@ -628,6 +644,99 @@ detailEl.scrollIntoView({ behavior: "smooth", block: "nearest" }); } + // ---------- peer self-view (cross-origin fetch) ---------------------- + // Each psyc node exposes /federation/explore/data (rich) and falls back to + // /federation/network (slim). Both carry CORS=*, so the browser can hit + // them from any cockpit page. The fetch is best-effort — if the peer is + // unreachable, blocked by CSP, or older, we render whatever we got. + + async function fetchPeerSelfView(domain, expectedFp) { + const sec = detailEl.querySelector(`.fn-remote-sec[data-remote-fp="${expectedFp}"]`); + if (!sec) return; + const statusEl = sec.querySelector(".fn-remote-status"); + const bodyEl = sec.querySelector(".fn-remote-body"); + const base = `https://${domain}`; + let data = null; + let kind = ""; + try { + const r = await fetch(`${base}/federation/explore/data`, { mode: "cors", cache: "no-store" }); + if (r.ok) { data = await r.json(); kind = "explore"; } + } catch (e) { /* fall through */ } + if (!data) { + try { + const r = await fetch(`${base}/federation/network`, { mode: "cors", cache: "no-store" }); + if (r.ok) { data = await r.json(); kind = "network"; } + } catch (e) { /* fall through */ } + } + if (!data) { + statusEl.textContent = "unreachable"; + bodyEl.innerHTML = `couldn't reach ${esc(domain)} — peer may be offline, blocking cross-origin, or on an older psyc.`; + return; + } + // The detail panel may have moved on to another node by now — re-check + // the section is still in the DOM before mutating. + if (!detailEl.contains(sec)) return; + + const gen = (data.generated_at || (data.node && data.node.generated_at) || "").slice(0, 19).replace("T", " "); + const declaredFp = data.fingerprint || (data.node && data.node.fingerprint) || ""; + const peers = data.peers || []; + const vouchesOut = data.vouches_out || data.vouches || []; + const vouchesIn = data.vouches_in || []; + const transitive = data.transitive_peers || []; + const corr = (typeof data.corroboration_count_24h === "number") ? data.corroboration_count_24h : null; + const logHead = data.node && data.node.transparency_log_head_hash; + const logCount = data.node && data.node.translog_entry_count; + + const fpMatch = declaredFp === expectedFp; + const fpBadge = fpMatch + ? `fingerprint matches` + : `fingerprint mismatch (${esc((declaredFp || "—").slice(0, 12))}…)`; + + const peersList = peers.length + ? `` + : `no trusted peers exposed`; + + const vouchesOutList = vouchesOut.length + ? `` + : `no outbound vouches`; + + const vouchesInList = vouchesIn.length + ? `` + : `no inbound vouches`; + + statusEl.textContent = kind === "explore" ? "explore feed" : "network feed"; + bodyEl.innerHTML = ` +
+ generated ${esc(gen || "—")} + ${fpBadge} + ${corr !== null ? `corroborations 24h: ${corr}` : ""} + ${logCount !== undefined ? `translog: ${logCount} entries` : ""} + ${logHead ? `head ${esc(String(logHead).slice(0, 12))}…` : ""} +
+
+
their peers (${peers.length})
${peersList}
+
vouches out (${vouchesOut.length})
${vouchesOutList}
+
vouches in (${vouchesIn.length})
${vouchesInList}
+ ${transitive.length ? `
they know (${transitive.length})
    ${transitive.slice(0, 12).map(t => `
  • ${esc(shortFp(t.fingerprint || ""))}
  • `).join("")}
` : ""} +
+
+ open their explorer → + raw JSON +
+ `; + } + // ---------- idle animation ------------------------------------------ let energyBudget = 40; function loop() { diff --git a/src/psyc/cockpit/static/sw.js b/src/psyc/cockpit/static/sw.js index d35ba60..f203085 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-v8"; +const CACHE_VERSION = "psyc-v9"; const STATIC_ASSETS = [ "/static/cockpit.css", "/static/psyc-tokens.css",