diff --git a/src/psyc/cockpit/static/cockpit.css b/src/psyc/cockpit/static/cockpit.css
index 13730e9..4d4dfa7 100644
--- a/src/psyc/cockpit/static/cockpit.css
+++ b/src/psyc/cockpit/static/cockpit.css
@@ -1824,3 +1824,68 @@ body.wide #federation-network-graph { height: 720px; }
.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; }
+
+/* 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); }
diff --git a/src/psyc/cockpit/static/federation_network.js b/src/psyc/cockpit/static/federation_network.js
index 2a7705a..8aded76 100644
--- a/src/psyc/cockpit/static/federation_network.js
+++ b/src/psyc/cockpit/static/federation_network.js
@@ -589,6 +589,20 @@
`
: "";
+ // 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)
+ ? `
+
containers fetching…
+
loading${topoHost ? ` from ${esc(topoHost)}` : " local topology"}…
+
`
+ : "";
+
const html = `
${esc(kindLabel)}
@@ -603,6 +617,7 @@
${quorum}
${translog}
${remote}
+ ${topoSec}
${actions}
`;
@@ -616,6 +631,14 @@
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:///.
+ if (n.is_self) {
+ fetchPeerTopology("", n.fp);
+ } else if (n.domain) {
+ fetchPeerTopology(n.domain, n.fp);
+ }
+
const close = detailEl.querySelector(".td-close");
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 = `couldn't reach ${esc(domain || "self")} — endpoint may be offline, blocking cross-origin, or this peer hasn't been upgraded yet.`;
+ 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 = `host ${esc(host)} · ${cCount} containers · ${nCount} networks${gen ? ` · generated ${esc(gen)}` : ""}
no containers reported — docker-socket-proxy may be down on this node.`;
+ 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
+ ? `${netShown.map(net => {
+ const drv = esc(net.driver || "—");
+ const ct = Number(net.container_count || 0);
+ const internalChip = net.internal ? ` internal` : "";
+ return `- ${esc(net.name || "?")} · ${drv} · ${ct} container${ct === 1 ? "" : "s"}${internalChip}
`;
+ }).join("")}${netHidden ? `- + ${netHidden} more
` : ""}
`
+ : `no networks reported`;
+
+ const contList = contShown.length
+ ? `${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 !== "—")
+ ? ` ${esc(c.health)}` : "";
+ const svc = c.service ? ` [${esc(c.service)}]` : "";
+ return `- ${esc(c.name || "?")}${svc}${healthPill}
${esc(img)}
`;
+ }).join("")}${contHidden ? `- + ${contHidden} more
` : ""}
`
+ : `no containers reported`;
+
+ if (statusEl) statusEl.textContent = domain ? "peer topology" : "local topology";
+ bodyEl.innerHTML = `
+ host ${esc(host)} · ${cCount} container${cCount === 1 ? "" : "s"} · ${nCount} network${nCount === 1 ? "" : "s"}${gen ? ` · generated ${esc(gen)}` : ""}
+
+
networks (${networks.length})
${netsList}
+
containers (${containers.length})
${contList}
+
`;
+ }
+
// ---------- idle animation ------------------------------------------
let energyBudget = 40;
function loop() {