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:
@@ -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; }
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user