stage-26d: click any topology node → structured spec panel below
Click a container, switch, or the host in the graph: the node gets a pulsing accent highlight and a detail panel below renders its full logical overview — for a container, image/status/per-network IPv4+MAC+ gateway + published ports + all ports; for a switch, driver/scope/ subnet/gateway/internal + attached-container table; for the host, OS/ CPUs/container counts. Cross-jumps in the spec tables click through to the related node (the graph re-selects too). Click the × button or any empty graph area to deselect. Drag is distinguished from click by a 4px move threshold so dragging nodes doesn't accidentally open the panel. HTML escaped end-to-end. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -663,3 +663,59 @@ body.wide #topology-graph {
|
||||
}
|
||||
/* Network cards get more room on wide; bump min column width. */
|
||||
body.wide .net-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
|
||||
|
||||
/* ── topology selection + detail panel ──────────────────────── */
|
||||
.topo-node.selected circle, .topo-node.selected rect {
|
||||
stroke-width: 3;
|
||||
filter: drop-shadow(0 0 16px var(--accent)) drop-shadow(0 0 24px rgba(30,200,255,0.55));
|
||||
}
|
||||
.topo-node.selected .topo-label { fill: #eaf6ff; font-weight: 700; }
|
||||
@keyframes topo-pulse {
|
||||
0%, 100% { stroke-opacity: 1; }
|
||||
50% { stroke-opacity: 0.55; }
|
||||
}
|
||||
.topo-node.selected circle:first-of-type, .topo-node.selected rect {
|
||||
animation: topo-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.topo-detail {
|
||||
margin: 18px 0;
|
||||
background: linear-gradient(180deg, var(--panel), rgba(18,22,30,0.85));
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 18px 20px; position: relative;
|
||||
}
|
||||
.topo-detail.has-selection { border-color: color-mix(in oklab, var(--accent) 35%, var(--border)); box-shadow: 0 0 18px var(--accent-glow); }
|
||||
.topo-detail-empty { color: var(--muted); margin: 0; font-style: italic; }
|
||||
|
||||
.td-head { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
|
||||
.td-kind {
|
||||
font-family: var(--font-display); font-size: 10px; letter-spacing: 0.16em;
|
||||
padding: 3px 10px; border-radius: 4px; text-transform: uppercase;
|
||||
background: var(--panel-2); border: 1px solid var(--border); color: var(--muted);
|
||||
}
|
||||
.td-kind-host { color: #d4c5ff; border-color: #a78bfa; background: rgba(167,139,250,0.10); }
|
||||
.td-kind-net { color: var(--accent); border-color: var(--accent); background: rgba(30,200,255,0.10); }
|
||||
.td-kind-cont { color: var(--green); border-color: var(--green); background: rgba(74,222,128,0.10); }
|
||||
.td-title { margin: 0; font-size: 18px; flex: 1; text-shadow: 0 0 14px var(--accent-glow); }
|
||||
.td-close {
|
||||
background: transparent; border: 1px solid var(--border); color: var(--muted);
|
||||
width: 30px; height: 30px; border-radius: 50%; font-size: 18px; line-height: 1; cursor: pointer;
|
||||
}
|
||||
.td-close:hover { color: var(--red); border-color: var(--red); }
|
||||
|
||||
.td-kv { display: grid; grid-template-columns: 110px 1fr; gap: 6px 14px; margin: 0 0 14px; font-size: 13px; }
|
||||
.td-kv dt { color: var(--muted); }
|
||||
.td-kv dd { margin: 0; color: var(--text); }
|
||||
.td-kv code { font-size: 12px; }
|
||||
|
||||
.td-sec { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.12em; margin: 14px 0 6px; }
|
||||
.td-tbl { width: 100%; border-collapse: collapse; }
|
||||
.td-tbl th, .td-tbl td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); font-size: 12px; }
|
||||
.td-tbl th { color: var(--muted); font-weight: 500; text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em; }
|
||||
.td-tbl tr:hover { background: var(--panel-2); }
|
||||
.td-jump { color: var(--accent); }
|
||||
.td-jump:hover { text-decoration: underline; text-shadow: 0 0 8px var(--accent-glow); }
|
||||
|
||||
.port-pill { display: inline-block; padding: 1px 7px; margin-right: 4px; background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.4); border-radius: 10px; color: var(--amber); font-family: ui-monospace, Menlo, monospace; font-size: 11px; }
|
||||
.td-portlist { margin: 0; padding-left: 18px; font-family: ui-monospace, Menlo, monospace; font-size: 12px; color: var(--text); }
|
||||
.td-portlist li { margin: 2px 0; }
|
||||
|
||||
@@ -241,8 +241,9 @@
|
||||
}
|
||||
paint();
|
||||
|
||||
// ---------- drag --------------------------------------------------------
|
||||
// ---------- drag + click ------------------------------------------------
|
||||
let dragging = null, dragOffset = { x: 0, y: 0 };
|
||||
let pressedNode = null, pressedAt = null, moved = false;
|
||||
function svgPoint(clientX, clientY) {
|
||||
const pt = svg.createSVGPoint(); pt.x = clientX; pt.y = clientY;
|
||||
return pt.matrixTransform(svg.getScreenCTM().inverse());
|
||||
@@ -250,27 +251,140 @@
|
||||
nodeEls.forEach((g, i) => {
|
||||
g.addEventListener("mousedown", ev => {
|
||||
ev.preventDefault();
|
||||
pressedNode = nodes[i];
|
||||
pressedAt = { x: ev.clientX, y: ev.clientY };
|
||||
moved = false;
|
||||
dragging = nodes[i];
|
||||
const p = svgPoint(ev.clientX, ev.clientY);
|
||||
dragOffset.x = p.x - dragging.x; dragOffset.y = p.y - dragging.y;
|
||||
dragging.fixed = true;
|
||||
if (currentLayout === "force") dragging.fixed = true;
|
||||
g.classList.add("dragging");
|
||||
});
|
||||
});
|
||||
document.addEventListener("mousemove", ev => {
|
||||
if (pressedAt) {
|
||||
const dx = ev.clientX - pressedAt.x, dy = ev.clientY - pressedAt.y;
|
||||
if (dx * dx + dy * dy > 16) moved = true; // > 4px = drag, not click
|
||||
}
|
||||
if (!dragging) return;
|
||||
const p = svgPoint(ev.clientX, ev.clientY);
|
||||
dragging.x = p.x - dragOffset.x; dragging.y = p.y - dragOffset.y;
|
||||
dragging.vx = 0; dragging.vy = 0;
|
||||
energyBudget = 80; // re-energize so other nodes adapt
|
||||
energyBudget = 80;
|
||||
});
|
||||
document.addEventListener("mouseup", () => {
|
||||
if (!dragging) return;
|
||||
const g = nodesG.querySelector(`[data-id="${CSS.escape(dragging.id)}"]`);
|
||||
if (g) g.classList.remove("dragging");
|
||||
dragging.fixed = false;
|
||||
dragging = null;
|
||||
if (dragging) {
|
||||
const g = nodesG.querySelector(`[data-id="${CSS.escape(dragging.id)}"]`);
|
||||
if (g) g.classList.remove("dragging");
|
||||
if (currentLayout === "force") dragging.fixed = false;
|
||||
dragging = null;
|
||||
}
|
||||
if (pressedNode && !moved) selectNode(pressedNode);
|
||||
pressedNode = null; pressedAt = null;
|
||||
});
|
||||
// Click on empty graph area clears selection.
|
||||
svg.addEventListener("click", ev => {
|
||||
if (!ev.target.closest(".topo-node")) clearSelection();
|
||||
});
|
||||
|
||||
// ---------- spec panel (click any node) --------------------------------
|
||||
const detailEl = document.getElementById("topo-detail");
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s).replace(/[&<>"']/g, c =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
|
||||
function selectNode(n) {
|
||||
nodeEls.forEach(el => el.classList.remove("selected"));
|
||||
const me = nodesG.querySelector(`[data-id="${CSS.escape(n.id)}"]`);
|
||||
if (me) me.classList.add("selected");
|
||||
renderDetail(n);
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
nodeEls.forEach(el => el.classList.remove("selected"));
|
||||
if (detailEl) detailEl.innerHTML = '<p class="topo-detail-empty">Click any node in the graph above to inspect it.</p>';
|
||||
}
|
||||
|
||||
function _kvRow(k, v) { return `<dt>${esc(k)}</dt><dd>${v}</dd>`; }
|
||||
function _stateChip(s) {
|
||||
const cls = s === "running" ? "state-running" : (s === "exited" || s === "dead") ? "state-exited" : (s === "paused" || s === "restarting") ? "state-paused" : "";
|
||||
return `<span class="state-badge ${cls}">${esc(s || "?")}</span>`;
|
||||
}
|
||||
|
||||
function renderDetail(n) {
|
||||
if (!detailEl) return;
|
||||
const kind = n.id.slice(0, 1);
|
||||
const name = n.id.slice(2);
|
||||
let html = "";
|
||||
|
||||
if (kind === "h") {
|
||||
const h = data.host || {};
|
||||
html = `<div class="td-head"><span class="td-kind td-kind-host">HOST</span>
|
||||
<h3 class="td-title">${esc(h.name || "docker host")}</h3>
|
||||
<button type="button" class="td-close" aria-label="close">×</button></div>
|
||||
<dl class="td-kv">
|
||||
${_kvRow("OS", esc(h.os || "—"))}
|
||||
${_kvRow("CPUs", h.ncpu != null ? esc(h.ncpu) : "—")}
|
||||
${_kvRow("Containers", h.containers != null ? `${esc(h.containers)} (running: ${esc(h.containers_running)})` : "—")}
|
||||
</dl>`;
|
||||
} else if (kind === "n") {
|
||||
const net = (data.networks || []).find(x => x.name === name) || {};
|
||||
const conts = net.containers || [];
|
||||
html = `<div class="td-head"><span class="td-kind td-kind-net">SWITCH</span>
|
||||
<h3 class="td-title">${esc(net.name)}</h3>
|
||||
<button type="button" class="td-close" aria-label="close">×</button></div>
|
||||
<dl class="td-kv">
|
||||
${_kvRow("ID", esc(net.id))}
|
||||
${_kvRow("Driver", esc(net.driver) + " · " + esc(net.scope))}
|
||||
${_kvRow("Subnet", esc(net.subnet || "—"))}
|
||||
${_kvRow("Gateway", esc(net.gateway || "—"))}
|
||||
${_kvRow("Internal", net.internal ? "yes" : "no")}
|
||||
</dl>
|
||||
<h4 class="td-sec">Attached containers (${conts.length})</h4>
|
||||
${conts.length ? `<table class="td-tbl">
|
||||
<thead><tr><th>Name</th><th>IPv4</th><th>MAC</th></tr></thead>
|
||||
<tbody>${conts.map(c => `<tr><td><a href="#" class="td-jump" data-id="c:${esc(c.name)}">${esc(c.name)}</a></td><td>${esc(c.ip || "—")}</td><td>${esc(c.mac || "—")}</td></tr>`).join("")}</tbody>
|
||||
</table>` : `<p class="empty">No containers attached.</p>`}`;
|
||||
} else {
|
||||
const c = (data.containers || []).find(x => x.name === name) || {};
|
||||
const nets = c.networks || [];
|
||||
const pub = c.published_ports || [];
|
||||
html = `<div class="td-head"><span class="td-kind td-kind-cont">CONTAINER</span>
|
||||
<h3 class="td-title">${esc(c.name)}</h3>
|
||||
${_stateChip(c.state)}
|
||||
<button type="button" class="td-close" aria-label="close">×</button></div>
|
||||
<dl class="td-kv">
|
||||
${_kvRow("ID", esc(c.id))}
|
||||
${_kvRow("Image", `<code>${esc(c.image)}</code>`)}
|
||||
${_kvRow("Status", esc(c.status))}
|
||||
${_kvRow("Published", pub.length ? pub.map(p => `<span class="port-pill">${esc(p)}</span>`).join(" ") : "—")}
|
||||
</dl>
|
||||
<h4 class="td-sec">Networks (${nets.length})</h4>
|
||||
${nets.length ? `<table class="td-tbl">
|
||||
<thead><tr><th>Switch</th><th>IPv4</th><th>MAC</th><th>Gateway</th></tr></thead>
|
||||
<tbody>${nets.map(nn => `<tr><td><a href="#" class="td-jump" data-id="n:${esc(nn.name)}">${esc(nn.name)}</a></td><td>${esc(nn.ip || "—")}</td><td>${esc(nn.mac || "—")}</td><td>${esc(nn.gateway || "—")}</td></tr>`).join("")}</tbody>
|
||||
</table>` : `<p class="empty">No networks attached.</p>`}
|
||||
${(c.ports || []).length ? `<h4 class="td-sec">All ports</h4>
|
||||
<ul class="td-portlist">${c.ports.map(p => `<li>${esc(p)}</li>`).join("")}</ul>` : ""}`;
|
||||
}
|
||||
detailEl.innerHTML = html;
|
||||
detailEl.classList.add("has-selection");
|
||||
// Wire close + cross-jumps.
|
||||
const close = detailEl.querySelector(".td-close");
|
||||
if (close) close.addEventListener("click", clearSelection);
|
||||
detailEl.querySelectorAll(".td-jump").forEach(a => {
|
||||
a.addEventListener("click", ev => {
|
||||
ev.preventDefault();
|
||||
const id = a.dataset.id;
|
||||
const target = nodeById[id];
|
||||
if (target) selectNode(target);
|
||||
});
|
||||
});
|
||||
// Smooth scroll the panel into view.
|
||||
detailEl.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
|
||||
// ---------- idle animation ---------------------------------------------
|
||||
let energyBudget = 40;
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="topo-detail" class="topo-detail">
|
||||
<p class="topo-detail-empty">Click any node in the graph above to inspect it.</p>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-top:18px;">Networks</h2>
|
||||
<div class="net-grid">
|
||||
{% for n in topo.networks %}
|
||||
|
||||
Reference in New Issue
Block a user