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 + ? `` + : `no networks reported`; + + const contList = contShown.length + ? `` + : `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() {