stage-topo-c topology-export: federation network panel renders peer containers

This commit is contained in:
m17hr1l
2026-06-07 01:57:29 +02:00
parent 367f17a013
commit d998be276b
2 changed files with 181 additions and 0 deletions

View File

@@ -1824,3 +1824,68 @@ body.wide #federation-network-graph { height: 720px; }
.fn-remote-list code { font-size: 11px; color: var(--accent); } .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-list .muted { color: var(--muted); margin-left: 6px; font-size: 11px; }
.fn-remote-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px; } .fn-remote-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px; }
/* peer container topology — sanitized snapshot fetched from peer's
/federation/topology. Full-width row inside the detail card; networks
+ containers in two columns, each row tagged with a small state dot. */
.fn-topology-sec { grid-column: 1 / -1; }
.fn-topology-meta {
display: flex; flex-wrap: wrap; gap: 6px 16px;
font-size: 11px; color: var(--muted); margin-bottom: 10px;
letter-spacing: 0.02em;
}
.fn-topology-meta code { font-size: 11px; color: var(--accent); }
.fn-topology-cols {
display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px; margin: 4px 0 6px;
}
.fn-topology-h {
font-size: 11px; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px;
}
.fn-topology-list { list-style: none; padding: 0; margin: 0; font-size: 12px; line-height: 1.55; }
.fn-topology-list li {
padding: 4px 0; border-bottom: 1px dashed rgba(125,133,151,0.18);
display: block;
}
.fn-topology-list li:last-child { border-bottom: 0; }
.fn-topology-list .muted { color: var(--muted); font-size: 11px; }
/* small colored dot indicating container state */
.fn-topo-state-dot {
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
margin-right: 8px; vertical-align: middle;
background: rgba(125,133,151,0.7);
}
.fn-topo-state-running { background: rgba(74,222,128,1); box-shadow: 0 0 4px rgba(74,222,128,0.55); }
.fn-topo-state-exited { background: rgba(125,133,151,0.7); }
.fn-topo-state-paused { background: rgba(251,191,36,1); }
.fn-topo-state-restarting,
.fn-topo-state-dead,
.fn-topo-state-unhealthy { background: rgba(248,113,113,1); }
.fn-topo-cname { color: var(--text); font-family: ui-monospace, Menlo, Consolas, monospace; }
.fn-topo-netname { color: var(--text); font-family: ui-monospace, Menlo, Consolas, monospace; }
.fn-topo-image {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 11px; color: var(--muted);
margin-left: 14px; word-break: break-all;
}
.fn-topo-int {
display: inline-block; font-size: 10px;
padding: 1px 5px; margin-left: 6px;
border-radius: 3px; color: var(--amber);
background: rgba(251,191,36,0.12);
border: 1px solid rgba(251,191,36,0.25);
text-transform: uppercase; letter-spacing: 0.05em;
}
.fn-topo-health {
display: inline-block; font-size: 10px;
padding: 1px 5px; margin-left: 6px;
border-radius: 3px; text-transform: uppercase; letter-spacing: 0.05em;
background: rgba(125,133,151,0.15); color: var(--muted);
border: 1px solid rgba(125,133,151,0.25);
}
.fn-topo-h-healthy { color: var(--green); border-color: rgba(74,222,128,0.35); background: rgba(74,222,128,0.10); }
.fn-topo-h-unhealthy { color: var(--red); border-color: rgba(248,113,113,0.35); background: rgba(248,113,113,0.10); }
.fn-topo-h-starting { color: var(--amber); border-color: rgba(251,191,36,0.35); background: rgba(251,191,36,0.10); }

View File

@@ -589,6 +589,20 @@
</div>` </div>`
: ""; : "";
// Placeholder for the peer's container topology. We render this for
// self too — the local node's /federation/topology fetches via the same
// path, just same-origin. Distance=2 transitive peers usually lack a
// direct domain in the network feed; skip them since there's nothing
// to fetch.
const topoHost = n.is_self ? "" : (n.domain || "");
const topoTarget = n.is_self ? "" : (n.domain || "");
const topoSec = (n.is_self || n.domain)
? `<div class="fn-detail-sec fn-topology-sec" data-topo-fp="${esc(n.fp)}">
<h4>containers <span class="fn-remote-status fn-topo-status">fetching…</span></h4>
<div class="fn-topology-body">loading${topoHost ? ` from <code>${esc(topoHost)}</code>` : " local topology"}…</div>
</div>`
: "";
const html = ` const html = `
<div class="td-head"> <div class="td-head">
<span class="td-kind ${kindCls}">${esc(kindLabel)}</span> <span class="td-kind ${kindCls}">${esc(kindLabel)}</span>
@@ -603,6 +617,7 @@
${quorum} ${quorum}
${translog} ${translog}
${remote} ${remote}
${topoSec}
${actions} ${actions}
</div> </div>
`; `;
@@ -616,6 +631,14 @@
fetchPeerSelfView(n.domain, n.fp); fetchPeerSelfView(n.domain, n.fp);
} }
// Async-fetch the topology — sanitized container/network listing. For
// SELF we hit our own origin; for peers we hit https://<domain>/.
if (n.is_self) {
fetchPeerTopology("", n.fp);
} else if (n.domain) {
fetchPeerTopology(n.domain, n.fp);
}
const close = detailEl.querySelector(".td-close"); const close = detailEl.querySelector(".td-close");
if (close) close.addEventListener("click", clearSelection); if (close) close.addEventListener("click", clearSelection);
@@ -737,6 +760,99 @@
`; `;
} }
// ---------- peer container topology (cross-origin fetch) ------------
// Each psyc node exposes /federation/topology — a whitelist-only docker
// snapshot (container names + images + state + network names, nothing
// else). CORS=* so we can render every connected node's containers
// inside the federation network panel — operator gets a single pane of
// glass instead of having to SSH into each node.
function _topoStateClass(state) {
const s = String(state || "").toLowerCase();
if (s === "running") return "fn-topo-state-running";
if (s === "paused") return "fn-topo-state-paused";
if (s === "restarting" || s === "dead") return "fn-topo-state-restarting";
// exited / created / removing / unknown → muted
return "fn-topo-state-exited";
}
function _truncImage(img, max) {
const s = String(img || "");
if (s.length <= max) return s;
// Keep the tail — repo/image:tag is more meaningful than the registry
// prefix when truncating.
return "…" + s.slice(-(max - 1));
}
async function fetchPeerTopology(domain, expectedFp) {
const sec = detailEl.querySelector(`.fn-topology-sec[data-topo-fp="${expectedFp}"]`);
if (!sec) return;
const statusEl = sec.querySelector(".fn-topo-status");
const bodyEl = sec.querySelector(".fn-topology-body");
// Empty domain → same-origin fetch for SELF. Otherwise cross-origin.
const base = domain ? `https://${domain}` : "";
let data = null;
try {
const r = await fetch(`${base}/federation/topology`, { mode: "cors", cache: "no-store" });
if (r.ok) data = await r.json();
} catch (e) { /* fall through */ }
// Detail panel may have moved on; bail if our section is gone.
if (!detailEl.contains(sec)) return;
if (!data) {
if (statusEl) statusEl.textContent = "unreachable";
bodyEl.innerHTML = `<span class="muted">couldn't reach ${esc(domain || "self")} — endpoint may be offline, blocking cross-origin, or this peer hasn't been upgraded yet.</span>`;
return;
}
const containers = Array.isArray(data.containers) ? data.containers : [];
const networks = Array.isArray(data.networks) ? data.networks : [];
const host = data.host_name || "—";
const gen = String(data.generated_at || "").slice(0, 19).replace("T", " ");
const cCount = Number(data.container_count || containers.length || 0);
const nCount = Number(data.network_count || networks.length || 0);
// Empty-state — node has docker_view disabled or no containers visible.
if (!cCount && !nCount) {
if (statusEl) statusEl.textContent = "no data";
bodyEl.innerHTML = `<div class="fn-topology-meta">host <code>${esc(host)}</code> · ${cCount} containers · ${nCount} networks${gen ? ` · generated <code>${esc(gen)}</code>` : ""}</div><span class="muted">no containers reported — docker-socket-proxy may be down on this node.</span>`;
return;
}
const CONT_VISIBLE = 30, NET_VISIBLE = 10;
const contShown = containers.slice(0, CONT_VISIBLE);
const contHidden = Math.max(0, containers.length - CONT_VISIBLE);
const netShown = networks.slice(0, NET_VISIBLE);
const netHidden = Math.max(0, networks.length - NET_VISIBLE);
const netsList = netShown.length
? `<ul class="fn-topology-list">${netShown.map(net => {
const drv = esc(net.driver || "—");
const ct = Number(net.container_count || 0);
const internalChip = net.internal ? ` <span class="fn-topo-int">internal</span>` : "";
return `<li><span class="fn-topo-netname">${esc(net.name || "?")}</span> <span class="muted">· ${drv} · ${ct} container${ct === 1 ? "" : "s"}</span>${internalChip}</li>`;
}).join("")}${netHidden ? `<li class="muted">+ ${netHidden} more</li>` : ""}</ul>`
: `<span class="muted">no networks reported</span>`;
const contList = contShown.length
? `<ul class="fn-topology-list">${contShown.map(c => {
const dotCls = _topoStateClass(c.state);
const stateTxt = esc(c.state || "?");
const img = _truncImage(c.image, 30);
const healthPill = (c.health && c.health !== "—")
? ` <span class="fn-topo-health fn-topo-h-${esc(c.health)}">${esc(c.health)}</span>` : "";
const svc = c.service ? ` <span class="muted">[${esc(c.service)}]</span>` : "";
return `<li><span class="fn-topo-state-dot ${dotCls}" title="${stateTxt}"></span><span class="fn-topo-cname">${esc(c.name || "?")}</span>${svc}${healthPill}<div class="fn-topo-image">${esc(img)}</div></li>`;
}).join("")}${contHidden ? `<li class="muted">+ ${contHidden} more</li>` : ""}</ul>`
: `<span class="muted">no containers reported</span>`;
if (statusEl) statusEl.textContent = domain ? "peer topology" : "local topology";
bodyEl.innerHTML = `
<div class="fn-topology-meta">host <code>${esc(host)}</code> · ${cCount} container${cCount === 1 ? "" : "s"} · ${nCount} network${nCount === 1 ? "" : "s"}${gen ? ` · generated <code>${esc(gen)}</code>` : ""}</div>
<div class="fn-topology-cols">
<div><div class="fn-topology-h">networks <span class="muted">(${networks.length})</span></div>${netsList}</div>
<div><div class="fn-topology-h">containers <span class="muted">(${containers.length})</span></div>${contList}</div>
</div>`;
}
// ---------- idle animation ------------------------------------------ // ---------- idle animation ------------------------------------------
let energyBudget = 40; let energyBudget = 40;
function loop() { function loop() {