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-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; }
|
.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();
|
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 -------------------------------------------
|
// ---------- controls + resize -------------------------------------------
|
||||||
const resetBtn = document.getElementById("topo-reset");
|
const resetBtn = document.getElementById("topo-reset");
|
||||||
if (resetBtn) {
|
if (resetBtn) {
|
||||||
resetBtn.addEventListener("click", () => {
|
resetBtn.addEventListener("click", () => {
|
||||||
for (const n of nodes) { n.vx = (Math.random() - 0.5) * 6; n.vy = (Math.random() - 0.5) * 6; }
|
if (currentLayout === "force") {
|
||||||
energyBudget = 200;
|
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).
|
// Wheel zoom on the SVG (changes viewBox).
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Docker topology — psyc admin{% endblock %}
|
{% block title %}Docker topology — psyc admin{% endblock %}
|
||||||
|
{% block body_class %}wide{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
@@ -16,11 +17,17 @@
|
|||||||
{% if topo.containers %}
|
{% if topo.containers %}
|
||||||
<div class="topo-stage">
|
<div class="topo-stage">
|
||||||
<div class="topo-toolbar">
|
<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-run"></span>running</span>
|
||||||
<span class="topo-legend"><span class="lg-swatch lg-stop"></span>exited</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>
|
<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>
|
</div>
|
||||||
<svg id="topology-graph" preserveAspectRatio="xMidYMid meet"></svg>
|
<svg id="topology-graph" preserveAspectRatio="xMidYMid meet"></svg>
|
||||||
<script id="topo-data" type="application/json">{{ topo|tojson }}</script>
|
<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/cockpit.css">
|
||||||
<link rel="stylesheet" href="/static/psyc-tokens.css">
|
<link rel="stylesheet" href="/static/psyc-tokens.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="{% block body_class %}{% endblock %}">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a class="brand" href="/cases">
|
<a class="brand" href="/cases">
|
||||||
<img class="brand-icon" src="/static/psyc-logo.png" alt="psyc">
|
<img class="brand-icon" src="/static/psyc-logo.png" alt="psyc">
|
||||||
|
|||||||
Reference in New Issue
Block a user