From ef88cd9d5db3da1007d29db3f62c12ef2ba25faf Mon Sep 17 00:00:00 2001 From: m17hr1l Date: Mon, 25 May 2026 12:20:18 +0200 Subject: [PATCH] stage-26c: topology layout views, traffic flow, full-width page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three layout modes (Force/Hierarchical/Radial) switch the topology graph between organic force-directed, host→switches→containers tiers, and a radial host-in-center wheel. Animated traffic flow on edges via stroke-dashoffset marching: wires to running containers flow cyan, host↔switch uplinks pulse slower in violet, container→host publish edges flash fastest in amber-dashed; dead edges (exited containers) fade. "traffic flow" checkbox kills the animation globally; layout choice auto-fixes nodes (drag still overrides). Respects prefers-reduced-motion. /admin/docker now opts into a wide layout via a new body_class block on base.html — content area uncaps, SVG sized min(74vh, 880px), network cards get wider min columns. Other pages keep the 1280px reading cap. Co-Authored-By: Claude Opus 4.7 --- src/psyc/cockpit/static/cockpit.css | 43 +++++++ src/psyc/cockpit/static/topology.js | 120 ++++++++++++++++++- src/psyc/cockpit/templates/admin_docker.html | 11 +- src/psyc/cockpit/templates/base.html | 2 +- 4 files changed, 171 insertions(+), 5 deletions(-) diff --git a/src/psyc/cockpit/static/cockpit.css b/src/psyc/cockpit/static/cockpit.css index bf57fcc..20b1efb 100644 --- a/src/psyc/cockpit/static/cockpit.css +++ b/src/psyc/cockpit/static/cockpit.css @@ -620,3 +620,46 @@ tr.sev-low .sev-badge { color: var(--muted); } .topo-label, .topo-sublabel { text-anchor: middle; pointer-events: none; font-family: var(--font-display); } .topo-sublabel { fill: var(--muted); font-size: 9px; font-family: ui-monospace, Menlo, monospace; letter-spacing: 0.04em; } + +/* ── topology: layout buttons + traffic flow ─────────────────── */ +.topo-layouts { display: inline-flex; gap: 0; border: 1px solid var(--border); border-radius: 5px; overflow: hidden; } +.topo-layout { + background: transparent; color: var(--muted); border: none; cursor: pointer; + padding: 4px 11px; font: inherit; font-size: 11px; + font-family: var(--font-display); letter-spacing: 0.06em; + border-right: 1px solid var(--border); +} +.topo-layout:last-child { border-right: none; } +.topo-layout:hover { color: var(--text); background: rgba(30,200,255,0.06); } +.topo-layout.is-active { + color: var(--accent); background: rgba(30,200,255,0.14); + box-shadow: inset 0 -2px 0 var(--accent); +} +.topo-toggle { + display: inline-flex; align-items: center; gap: 6px; cursor: pointer; + font-size: 11px; color: var(--muted); letter-spacing: 0.06em; +} +.topo-toggle input { accent-color: var(--accent); } + +/* Traffic-flow animation: dashed pattern marches along live edges. */ +.topo-edge.alive { stroke-dasharray: 5 4; animation: topo-flow 1.6s linear infinite; } +.topo-kind-uplink .topo-edge.alive { animation-duration: 2.6s; stroke-dasharray: 6 4; } +.topo-kind-publish .topo-edge.alive { animation-duration: 0.95s; stroke-dasharray: 4 3; } +.topo-edge.dead { opacity: 0.28; } +@keyframes topo-flow { to { stroke-dashoffset: -54; } } + +/* Master kill-switch for flow (the "traffic flow" checkbox) */ +#topology-graph.flow-off .topo-edge.alive { animation: none; } + +@media (prefers-reduced-motion: reduce) { + .topo-edge.alive { animation: none; } +} + +/* ── wide-page mode (topology lives here) ───────────────────── */ +body.wide .content { max-width: none; padding: 24px 32px; } +body.wide #topology-graph { + height: min(74vh, 880px); + min-height: 520px; +} +/* Network cards get more room on wide; bump min column width. */ +body.wide .net-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); } diff --git a/src/psyc/cockpit/static/topology.js b/src/psyc/cockpit/static/topology.js index 993df48..1be3f06 100644 --- a/src/psyc/cockpit/static/topology.js +++ b/src/psyc/cockpit/static/topology.js @@ -287,12 +287,128 @@ } loop(); + // ---------- traffic flow on edges -------------------------------------- + // An edge is "alive" if its container endpoint is running, or if it's an + // uplink (host↔switch) — those are considered backbone. Dead edges fade. + function markEdgeLiveness() { + edges.forEach((e, i) => { + const a = nodeById[e.source], b = nodeById[e.target]; + const contAlive = + (a.type === "cont" && a.state === "running") || + (b.type === "cont" && b.state === "running"); + const alwaysOn = e.kind === "uplink"; + const alive = contAlive || alwaysOn; + const ln = edgeEls[i].line; + ln.classList.toggle("alive", alive); + ln.classList.toggle("dead", !alive); + }); + } + markEdgeLiveness(); + + const flowToggle = document.getElementById("topo-flow"); + function applyFlowToggle() { + svg.classList.toggle("flow-off", !(flowToggle && flowToggle.checked)); + } + applyFlowToggle(); + if (flowToggle) flowToggle.addEventListener("change", applyFlowToggle); + + // ---------- layout modes (force | hierarchical | radial) ---------------- + function unfix() { for (const n of nodes) n.fixed = false; } + function clearVel() { for (const n of nodes) { n.vx = 0; n.vy = 0; } } + + function applyForce() { + unfix(); + for (const n of nodes) { n.vx = (Math.random() - 0.5) * 5; n.vy = (Math.random() - 0.5) * 5; } + energyBudget = 300; + } + + function applyHierarchical() { + const switches = nodes.filter(n => n.type === "net"); + const conts = nodes.filter(n => n.type === "cont"); + // Group containers by their primary (first) connected switch. + const groups = {}; + for (const c of conts) { + const edge = edges.find(e => e.source === c.id && nodeById[e.target] && nodeById[e.target].type === "net"); + const swId = edge ? edge.target : "_unattached"; + (groups[swId] = groups[swId] || []).push(c); + } + host.x = W / 2; host.y = 60; host.fixed = true; + switches.forEach((sw, i) => { + sw.x = W * (i + 1) / (switches.length + 1); + sw.y = H * 0.36; + sw.fixed = true; + }); + for (const [swId, group] of Object.entries(groups)) { + const sw = nodeById[swId]; + const cx = sw ? sw.x : 40; + const cy = sw ? sw.y + 90 : H - 50; + const cols = Math.max(1, Math.ceil(Math.sqrt(group.length))); + group.forEach((c, idx) => { + const col = idx % cols, row = Math.floor(idx / cols); + c.x = cx + (col - (cols - 1) / 2) * 38; + c.y = cy + row * 38; + c.fixed = true; + }); + } + clearVel(); paint(); + } + + function applyRadial() { + const switches = nodes.filter(n => n.type === "net"); + const conts = nodes.filter(n => n.type === "cont"); + const R1 = Math.min(W, H) * 0.22; + const R2 = Math.min(W, H) * 0.42; + host.x = W / 2; host.y = H / 2; host.fixed = true; + switches.forEach((sw, i) => { + const a = (i / switches.length) * Math.PI * 2 - Math.PI / 2; + sw.x = W / 2 + R1 * Math.cos(a); + sw.y = H / 2 + R1 * Math.sin(a); + sw._angle = a; sw.fixed = true; + }); + // Bucket containers by their primary switch. + const groups = {}; + for (const c of conts) { + const edge = edges.find(e => e.source === c.id && nodeById[e.target] && nodeById[e.target].type === "net"); + const swId = edge ? edge.target : "_unattached"; + (groups[swId] = groups[swId] || []).push(c); + } + const slice = switches.length ? (Math.PI * 2) / switches.length : Math.PI; + for (const [swId, group] of Object.entries(groups)) { + const sw = nodeById[swId]; + const baseAng = sw ? sw._angle : Math.PI; + group.forEach((c, idx) => { + const t = group.length === 1 ? 0 : (idx / (group.length - 1) - 0.5); + const a = baseAng + t * slice * 0.75; + c.x = W / 2 + R2 * Math.cos(a); + c.y = H / 2 + R2 * Math.sin(a); + c.fixed = true; + }); + } + clearVel(); paint(); + } + + const LAYOUTS = { force: applyForce, hier: applyHierarchical, radial: applyRadial }; + let currentLayout = "force"; + document.querySelectorAll(".topo-layout").forEach(btn => { + btn.addEventListener("click", () => { + const mode = btn.dataset.layout; + if (!LAYOUTS[mode] || mode === currentLayout) return; + document.querySelectorAll(".topo-layout").forEach(b => b.classList.toggle("is-active", b === btn)); + currentLayout = mode; + LAYOUTS[mode](); + }); + }); + // ---------- controls + resize ------------------------------------------- const resetBtn = document.getElementById("topo-reset"); if (resetBtn) { resetBtn.addEventListener("click", () => { - for (const n of nodes) { n.vx = (Math.random() - 0.5) * 6; n.vy = (Math.random() - 0.5) * 6; } - energyBudget = 200; + if (currentLayout === "force") { + for (const n of nodes) { n.vx = (Math.random() - 0.5) * 6; n.vy = (Math.random() - 0.5) * 6; } + energyBudget = 200; + } else { + LAYOUTS[currentLayout](); + } }); } // Wheel zoom on the SVG (changes viewBox). diff --git a/src/psyc/cockpit/templates/admin_docker.html b/src/psyc/cockpit/templates/admin_docker.html index 05224dd..36dedd7 100644 --- a/src/psyc/cockpit/templates/admin_docker.html +++ b/src/psyc/cockpit/templates/admin_docker.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% block title %}Docker topology — psyc admin{% endblock %} +{% block body_class %}wide{% endblock %} {% block content %}
@@ -16,11 +17,17 @@ {% if topo.containers %}
- network +
+ + + +
+ + switch running exited - drag any node · scroll to zoom + drag · scroll to zoom
diff --git a/src/psyc/cockpit/templates/base.html b/src/psyc/cockpit/templates/base.html index 77d50e9..f5459b5 100644 --- a/src/psyc/cockpit/templates/base.html +++ b/src/psyc/cockpit/templates/base.html @@ -10,7 +10,7 @@ - +
psyc