federation/network: fetch peer's own /federation/explore/data on click + render their self-view inline (their peers, vouches in/out, transitive, translog head)

This commit is contained in:
m17hr1l
2026-06-07 01:21:50 +02:00
parent cef3bcb1ed
commit 8587e079bb
3 changed files with 136 additions and 1 deletions

View File

@@ -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; }

View File

@@ -581,6 +581,14 @@
</div>
</div>`;
// Placeholder for the peer's-own-view, populated by an async fetch below.
const remote = (!n.is_self && n.domain)
? `<div class="fn-detail-sec fn-remote-sec" data-remote-fp="${esc(n.fp)}">
<h4>peer's self-view <span class="fn-remote-status">fetching…</span></h4>
<div class="fn-remote-body">loading from <code>${esc(n.domain)}</code>…</div>
</div>`
: "";
const html = `
<div class="td-head">
<span class="td-kind ${kindCls}">${esc(kindLabel)}</span>
@@ -594,12 +602,20 @@
${vouches}
${quorum}
${translog}
${remote}
${actions}
</div>
`;
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 = `<span class="muted">couldn't reach ${esc(domain)} — peer may be offline, blocking cross-origin, or on an older psyc.</span>`;
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
? `<span class="fn-remote-ok">fingerprint matches</span>`
: `<span class="fn-remote-warn">fingerprint mismatch (${esc((declaredFp || "—").slice(0, 12))}…)</span>`;
const peersList = peers.length
? `<ul class="fn-remote-list">${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 `<li><code>${esc(shortFp(fp))}</code> <span class="muted">${esc(dom)}</span>${sig24}</li>`;
}).join("")}</ul>`
: `<span class="muted">no trusted peers exposed</span>`;
const vouchesOutList = vouchesOut.length
? `<ul class="fn-remote-list">${vouchesOut.slice(0, 12).map(v => {
const tfp = v.target_fingerprint || v.target_fp || "";
return `<li><code>${esc(shortFp(tfp))}</code></li>`;
}).join("")}</ul>`
: `<span class="muted">no outbound vouches</span>`;
const vouchesInList = vouchesIn.length
? `<ul class="fn-remote-list">${vouchesIn.slice(0, 12).map(v => {
const vfp = v.voucher_fingerprint || v.voucher_fp || "";
return `<li><code>${esc(shortFp(vfp))}</code></li>`;
}).join("")}</ul>`
: `<span class="muted">no inbound vouches</span>`;
statusEl.textContent = kind === "explore" ? "explore feed" : "network feed";
bodyEl.innerHTML = `
<div class="fn-remote-meta">
<span>generated <code>${esc(gen || "—")}</code></span>
${fpBadge}
${corr !== null ? `<span>corroborations 24h: <b>${corr}</b></span>` : ""}
${logCount !== undefined ? `<span>translog: <b>${logCount}</b> entries</span>` : ""}
${logHead ? `<span>head <code>${esc(String(logHead).slice(0, 12))}…</code></span>` : ""}
</div>
<div class="fn-remote-cols">
<div><div class="fn-remote-h">their peers <span class="muted">(${peers.length})</span></div>${peersList}</div>
<div><div class="fn-remote-h">vouches out <span class="muted">(${vouchesOut.length})</span></div>${vouchesOutList}</div>
<div><div class="fn-remote-h">vouches in <span class="muted">(${vouchesIn.length})</span></div>${vouchesInList}</div>
${transitive.length ? `<div><div class="fn-remote-h">they know <span class="muted">(${transitive.length})</span></div><ul class="fn-remote-list">${transitive.slice(0, 12).map(t => `<li><code>${esc(shortFp(t.fingerprint || ""))}</code></li>`).join("")}</ul></div>` : ""}
</div>
<div class="fn-remote-actions">
<a class="fn-action-btn" href="${esc(base)}/federation/explore" target="_blank" rel="noopener">open their explorer →</a>
<a class="fn-action-btn" href="${esc(base)}/federation/explore/data" target="_blank" rel="noopener">raw JSON</a>
</div>
`;
}
// ---------- idle animation ------------------------------------------
let energyBudget = 40;
function loop() {

View File

@@ -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",