From 494755ec4f8c092eed13e55d2394b86e510d484b Mon Sep 17 00:00:00 2001 From: m17hr1l Date: Mon, 25 May 2026 12:25:15 +0200 Subject: [PATCH] =?UTF-8?q?stage-26d:=20click=20any=20topology=20node=20?= =?UTF-8?q?=E2=86=92=20structured=20spec=20panel=20below?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/psyc/cockpit/static/cockpit.css | 56 ++++++++ src/psyc/cockpit/static/topology.js | 130 +++++++++++++++++-- src/psyc/cockpit/templates/admin_docker.html | 4 + 3 files changed, 182 insertions(+), 8 deletions(-) diff --git a/src/psyc/cockpit/static/cockpit.css b/src/psyc/cockpit/static/cockpit.css index 20b1efb..de9eaff 100644 --- a/src/psyc/cockpit/static/cockpit.css +++ b/src/psyc/cockpit/static/cockpit.css @@ -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; } diff --git a/src/psyc/cockpit/static/topology.js b/src/psyc/cockpit/static/topology.js index 1be3f06..01c3931 100644 --- a/src/psyc/cockpit/static/topology.js +++ b/src/psyc/cockpit/static/topology.js @@ -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 = '

Click any node in the graph above to inspect it.

'; + } + + function _kvRow(k, v) { return `
${esc(k)}
${v}
`; } + function _stateChip(s) { + const cls = s === "running" ? "state-running" : (s === "exited" || s === "dead") ? "state-exited" : (s === "paused" || s === "restarting") ? "state-paused" : ""; + return `${esc(s || "?")}`; + } + + 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 = `
HOST +

${esc(h.name || "docker host")}

+
+
+ ${_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)})` : "—")} +
`; + } else if (kind === "n") { + const net = (data.networks || []).find(x => x.name === name) || {}; + const conts = net.containers || []; + html = `
SWITCH +

${esc(net.name)}

+
+
+ ${_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")} +
+

Attached containers (${conts.length})

+ ${conts.length ? ` + + ${conts.map(c => ``).join("")} +
NameIPv4MAC
${esc(c.name)}${esc(c.ip || "—")}${esc(c.mac || "—")}
` : `

No containers attached.

`}`; + } else { + const c = (data.containers || []).find(x => x.name === name) || {}; + const nets = c.networks || []; + const pub = c.published_ports || []; + html = `
CONTAINER +

${esc(c.name)}

+ ${_stateChip(c.state)} +
+
+ ${_kvRow("ID", esc(c.id))} + ${_kvRow("Image", `${esc(c.image)}`)} + ${_kvRow("Status", esc(c.status))} + ${_kvRow("Published", pub.length ? pub.map(p => `${esc(p)}`).join(" ") : "—")} +
+

Networks (${nets.length})

+ ${nets.length ? ` + + ${nets.map(nn => ``).join("")} +
SwitchIPv4MACGateway
${esc(nn.name)}${esc(nn.ip || "—")}${esc(nn.mac || "—")}${esc(nn.gateway || "—")}
` : `

No networks attached.

`} + ${(c.ports || []).length ? `

All ports

+ ` : ""}`; + } + 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; diff --git a/src/psyc/cockpit/templates/admin_docker.html b/src/psyc/cockpit/templates/admin_docker.html index 36dedd7..b0a29e6 100644 --- a/src/psyc/cockpit/templates/admin_docker.html +++ b/src/psyc/cockpit/templates/admin_docker.html @@ -35,6 +35,10 @@ {% endif %} +
+

Click any node in the graph above to inspect it.

+
+

Networks

{% for n in topo.networks %}