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:
@@ -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)); }
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user