stage-26c: topology layout views, traffic flow, full-width page

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 <noreply@anthropic.com>
This commit is contained in:
m17hr1l
2026-05-25 12:20:18 +02:00
parent b51a88d502
commit ef88cd9d5d
4 changed files with 171 additions and 5 deletions

View File

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

View File

@@ -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).

View File

@@ -1,5 +1,6 @@
{% extends "base.html" %}
{% block title %}Docker topology — psyc admin{% endblock %}
{% block body_class %}wide{% endblock %}
{% block content %}
<section class="panel">
<div class="panel-head">
@@ -16,11 +17,17 @@
{% if topo.containers %}
<div class="topo-stage">
<div class="topo-toolbar">
<span class="topo-legend"><span class="lg-swatch lg-net"></span>network</span>
<div class="topo-layouts" role="tablist">
<button type="button" class="topo-layout is-active" data-layout="force" title="organic force-directed">Force</button>
<button type="button" class="topo-layout" data-layout="hier" title="host on top, switches in a row, containers grouped below">Hierarchical</button>
<button type="button" class="topo-layout" data-layout="radial" title="host in center, switches on a ring, containers on the outer ring">Radial</button>
</div>
<label class="topo-toggle"><input type="checkbox" id="topo-flow" checked> traffic flow</label>
<span class="topo-legend"><span class="lg-swatch lg-net"></span>switch</span>
<span class="topo-legend"><span class="lg-swatch lg-run"></span>running</span>
<span class="topo-legend"><span class="lg-swatch lg-stop"></span>exited</span>
<button type="button" id="topo-reset" class="btn">re-settle</button>
<span class="topo-hint">drag any node · scroll to zoom</span>
<span class="topo-hint">drag · scroll to zoom</span>
</div>
<svg id="topology-graph" preserveAspectRatio="xMidYMid meet"></svg>
<script id="topo-data" type="application/json">{{ topo|tojson }}</script>

View File

@@ -10,7 +10,7 @@
<link rel="stylesheet" href="/static/cockpit.css">
<link rel="stylesheet" href="/static/psyc-tokens.css">
</head>
<body>
<body class="{% block body_class %}{% endblock %}">
<header class="topbar">
<a class="brand" href="/cases">
<img class="brand-icon" src="/static/psyc-logo.png" alt="psyc">