stage-topo-c topology-export: federation network panel renders peer containers
This commit is contained in:
@@ -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); }
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user