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 `| Name | IPv4 | MAC |
|---|---|---|
| ${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 = `${esc(c.image)}`)}
+ ${_kvRow("Status", esc(c.status))}
+ ${_kvRow("Published", pub.length ? pub.map(p => `${esc(p)}`).join(" ") : "—")}
+ | Switch | IPv4 | MAC | Gateway |
|---|---|---|---|
| ${esc(nn.name)} | ${esc(nn.ip || "—")} | ${esc(nn.mac || "—")} | ${esc(nn.gateway || "—")} |
No networks attached.
`} + ${(c.ports || []).length ? `Click any node in the graph above to inspect it.
+