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
+ ? `${peers.slice(0, 12).map(p => {
+ const fp = p.fingerprint || "";
+ const dom = p.domain || "—";
+ const sig24 = (typeof p.signal_count_24h === "number") ? ` · ${p.signal_count_24h} sig/24h` : "";
+ return `${esc(shortFp(fp))} ${esc(dom)}${sig24} `;
+ }).join("")}
`
+ : `no trusted peers exposed`;
+
+ const vouchesOutList = vouchesOut.length
+ ? `${vouchesOut.slice(0, 12).map(v => {
+ const tfp = v.target_fingerprint || v.target_fp || "";
+ return `${esc(shortFp(tfp))} `;
+ }).join("")}
`
+ : `no outbound vouches`;
+
+ const vouchesInList = vouchesIn.length
+ ? `${vouchesIn.slice(0, 12).map(v => {
+ const vfp = v.voucher_fingerprint || v.voucher_fp || "";
+ return `${esc(shortFp(vfp))} `;
+ }).join("")}
`
+ : `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("")}
` : ""}
+
+
+ `;
+ }
+
// ---------- 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",