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:
m17hr1l
2026-05-25 12:25:15 +02:00
parent ef88cd9d5d
commit 494755ec4f
3 changed files with 182 additions and 8 deletions

View File

@@ -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; }

View File

@@ -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 =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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;

View File

@@ -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 %}