Render the topology graph with PixiJS v8

Replaces the hand-rolled inline-SVG topology with a PixiJS 8 scene graph.

Why: the old renderer rebuilt all 37 nodes / 36 edges as one innerHTML string
every 10s, which tore down and recreated every DOM node. That restarted CSS
animations mid-flight and made dragging fight the browser's own hit-testing.
The scene graph gives per-node transforms, so pan/zoom is a single container
transform instead of getScreenCTM() matrix math.

Changes:
- static/topo_pixi.js: new self-contained renderer. Owns its Application and
  tears it down on tab exit so a second WebGL context cannot leak.
- static/index.html: the <svg id=topoSvg> host becomes a <div id=topoHost>;
  the 188-line SVG renderer is replaced by a bridge to the module.
- static/vendor/pixi.mjs: PixiJS 8.21.0 self-hosted (MIT). The .mjs build is
  required; the .js build exports no global. See vendor/README.md.
- main.py: mount /static. Pages were served as inline HTMLResponse, so the
  directory was never mounted and the module had no URL to load from.

Two real bugs found by measuring pixels rather than trusting init():
- preserveDrawingBuffer: without it WebGL clears the back buffer after
  compositing, so any readback or screenshot of the canvas is a coin flip
  depending on which frame it lands on. The graph rendered intermittently
  blank. Now enabled: cheap for a 2D scene, and it makes the view capturable.
- Layout was centred on the SCROLLABLE width (nodes.length * 130), not the
  viewport, so with 37 nodes every team and challenge node landed at
  x=2230-2650 on a 1310px canvas: entirely off-screen. Layout now centres on
  the visible width and reset() frames the whole graph to fit.

test_topo_pixels.js documents three wrong test designs it replaces, all of
which reported false failures against a working graph: counting scene-graph
children (passes on a blank canvas), diffing against the background colour
(the theme is dark by design, so a perfect render measures ~0%), and diffing
two Playwright screenshots (both can be captured after the scene was mutated).
The check now reads the GL back buffer via readPixels in one evaluate.

Verified: 37 nodes / 36 edges drawn (7.94% of frame, max channel delta 225),
graph bbox [437,46,881,476] inside the 1310x520 canvas, glGetError=0, no page
errors, zoom and frame-to-fit reset working. Platform unregressed: SLA 32/32.
This commit is contained in:
root
2026-09-27 00:31:47 +08:00
parent 50cb782ded
commit 1461c874c3
8 changed files with 3260 additions and 181 deletions
+43 -181
View File
@@ -100,7 +100,10 @@
.spin { display:inline-block; width:12px; height:12px; border:2px solid #2a4a6f; border-top-color:#5ad1ff; border-radius:50%; animation:sp .7s linear infinite; }
@keyframes sp { to { transform:rotate(360deg); } }
.topo-wrap { background:#0a0f1c; border:1px solid #1e3a5f; border-radius:12px; padding:20px; overflow-x:auto; }
.topo-svg { width:100%; min-width:800px; }
/* Now a DIV host for the PixiJS canvas (was an inline <svg>). It needs an
explicit height: the Pixi Application is created with resizeTo:this element
and a zero-height box would size the renderer to nothing. */
.topo-svg { width:100%; min-width:800px; height:520px; }
/* attack visualizer: recent attacks pulse */
@keyframes attackPulse { 0%,100% { stroke-opacity:0.4; } 50% { stroke-opacity:1; } }
@keyframes attackBlink { 0%,100% { opacity:1; } 50% { opacity:0.35; } }
@@ -151,8 +154,8 @@
<button onclick="topoReset()">⟳ Reset</button>
<span id="topoZoomLabel" style="font-size:12px;color:var(--dim);min-width:40px">100%</span>
</div>
<div class="topo-wrap"><svg id="topoSvg" class="topo-svg" height="520"></svg></div>
<div class="topo-hint">💡 <b>Geser node</b> untuk atur layout • <b>scroll / ctrl+scroll</b> untuk zoom in-out • <b>drag area kosong</b> untuk geser canvas • ⚔️ garis merah = serangan (panah attacker → target) • garis <b>putus-putus</b> = serangan baru (60 detik terakhir)</div>
<div class="topo-wrap"><div id="topoHost" class="topo-svg"></div></div>
<div class="topo-hint">💡 <b>Geser node</b> untuk atur layout • <b>scroll / ctrl+scroll</b> untuk zoom in-out • <b>drag area kosong</b> untuk geser canvas • ⚔️ garis merah = serangan (attacker → target) • garis tebal = serangan 60 detik terakhir &nbsp;<span id="topoEngine" style="opacity:.6"></span></div>
</div>
<!-- Teams view -->
@@ -548,194 +551,53 @@ async function viewCred(ch) {
// ---------- Topology ----------
let topoLoaded = false;
// ---------- Topology (PixiJS v8) ----------
// The renderer lives in topo_pixi.js as an ES module so the ~810 KB Pixi bundle is
// only parsed when this tab is actually opened. These functions are the thin
// bridge the tab buttons and the 10s refresh timer already call.
let topoAttacks = [];
let topoGraph = null;
let topoModule = null;
async function topoEngine() {
if (!topoModule) topoModule = await import('/static/topo_pixi.js');
return topoModule;
}
async function ensureTopoGraph() {
const host = document.getElementById('topoHost');
if (!host) return null;
if (topoGraph && topoGraph.app) return topoGraph;
const mod = await topoEngine();
topoGraph = new mod.TopoGraph(host);
await topoGraph.init();
const lbl = document.getElementById('topoEngine');
if (lbl) lbl.textContent = '· rendered with PixiJS v8';
return topoGraph;
}
async function loadTopo() {
try {
const g = await ensureTopoGraph();
if (!g) return;
const [d, a] = await Promise.all([api('/api/topology'), api('/api/attacks').catch(() => ({events: []}))]);
topoAttacks = a.events || [];
renderTopo(d.nodes, d.edges);
} catch (e) { toast('Topologi gagal: ' + e.message, true); }
}
function renderTopo(nodes, edges) {
const svg = document.getElementById('topoSvg');
const W = Math.max(900, nodes.length * 130);
const H = 500;
svg.setAttribute('width', W); svg.setAttribute('height', H);
const cx = W / 2;
const ns = {};
nodes.forEach(n => { ns[n.id] = n; });
// layout: panel/infra top center, teams in circle, challenges below each team
const pos = {};
pos['dns'] = {x: cx, y: 30};
pos['traefik'] = {x: cx, y: 100};
pos['panel'] = {x: cx - 140, y: 100};
const teams = nodes.filter(n => n.type === 'team');
const teamPos = {};
teams.forEach((t, i) => {
const ang = (i / Math.max(1, teams.length)) * Math.PI * 2 - Math.PI / 2;
const rx = W * 0.36, ry = 100;
const tx = cx + rx * Math.cos(ang);
const ty = 250 + ry * Math.sin(ang) * 0.6;
teamPos[t.index] = {x: tx, y: ty};
pos[t.id] = {x: tx, y: ty};
});
nodes.filter(n => n.type === 'challenge').forEach(n => {
const ti = n.id.split('-')[0].replace('team','');
const base = teamPos[ti] || {x: cx, y: 300};
const chIdx = ['blogpost','carbeat','cdn','phew','sheesh','warmup'].indexOf(n.label.split('-').pop() || n.label);
pos[n.id] = {x: base.x + (chIdx - 2.5) * 70, y: base.y + 130};
});
// ---- attack arcs (attacker team -> target team), recent only (last 10 min) ----
const now = Date.now() / 1000;
const recentAttacks = topoAttacks.filter(e => now - (e.ts || 0) < 600);
const attackCounts = {}; // "attacker:target" -> {ok, fail, latest}
recentAttacks.forEach(e => {
const k = `${e.attacker}:${e.target}`;
attackCounts[k] = attackCounts[k] || {ok: 0, fail: 0, latest: e.ts};
attackCounts[k][e.success ? 'ok' : 'fail']++;
attackCounts[k].latest = Math.max(attackCounts[k].latest, e.ts || 0);
});
// edges
let html = '';
edges.forEach(e => {
const a = pos[e.from], b = pos[e.to];
if (!a || !b) return;
html += `<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="#2a4a6f" stroke-width="1.5" stroke-dasharray="4 3"/>`;
if (e.label) {
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2 - 6;
html += `<text x="${mx}" y="${my}" fill="#5a7a9f" font-size="9" text-anchor="middle">${esc(e.label)}</text>`;
}
});
// ---- attack visualizer ----
for (const [k, c] of Object.entries(attackCounts)) {
const [atk, tgt] = k.split(':');
const a = pos['team' + atk], b = pos['team' + tgt];
if (!a || !b || atk === tgt) continue;
const isHot = now - c.latest < 60;
const color = c.ok > 0 ? '#f87171' : '#fb923c';
const dash = isHot ? '6 3' : '4 4';
html += `<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="${color}" stroke-width="${isHot ? 3 : 2}" stroke-dasharray="${dash}" opacity="0.9" class="attack-line"/>`;
const mx = (a.x + b.x) / 2 + 8, my = (a.y + b.y) / 2 - 10;
const icon = isHot ? '⚔️' : '⚔';
html += `<text x="${mx}" y="${my}" fill="${color}" font-size="12" text-anchor="middle" class="attack-icon">${icon} ${c.ok}✓ ${c.fail}✗</text>`;
g.setData(d.nodes, d.edges, topoAttacks);
} catch (e) {
toast('Topologi gagal: ' + e.message, true);
}
// nodes
nodes.forEach(n => {
const p = pos[n.id];
if (!p) return;
let fill = '#0e1526', stroke = '#2a4a6f', color = '#a8c3e0', r = 34;
if (n.type === 'panel') { fill = '#0ea5e933'; stroke = '#0ea5e9'; color = '#7dd3fc'; r = 42; }
if (n.type === 'infra') { r = 30; color = '#8aa0b8'; }
if (n.type === 'team') { fill = '#2563eb22'; stroke = '#3b82f6'; color = '#93c5fd'; r = 48; }
if (n.type === 'challenge') { r = 26; color = '#c9d4e3'; }
const status = n.status === 'running' ? ' fill="#34d399"' : '';
const sub = n.type === 'team' ? `:${n.receiver_port}` : (n.port ? `:${n.port}` : '');
html += `<g data-node="${esc(n.id)}" style="cursor:grab">
<circle cx="${p.x}" cy="${p.y}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>
<circle cx="${p.x}" cy="${p.y}" r="${r-4}" fill="none" stroke="${stroke}" stroke-opacity="0.3"/>
<text x="${p.x}" y="${p.y - (sub ? 0 : 4)}" fill="${color}" font-size="${n.type==='team'?12:10}" font-weight="bold" text-anchor="middle">${esc(n.label)}</text>
${sub ? `<text x="${p.x}" y="${p.y + 12}" fill="#5a7a9f" font-size="9" text-anchor="middle">${sub}</text>` : ''}
${status ? `<circle cx="${p.x + r - 8}" cy="${p.y - r + 8}" r="5" fill="#34d399"/>` : ''}
</g>`;
});
svg.innerHTML = `<defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#2a4a6f"/></marker></defs><g data-root>${html}</g>`;
enableTopoDrag(svg, pos);
applyTopoView();
}
// ---- draggable topology + zoom/pan ----
let topoScale = 1;
let topoPanX = 0, topoPanY = 0;
function enableTopoDrag(svg, pos) {
const gMain = svg.querySelector('g[data-root]') || svg;
let dragEl = null, dx = 0, dy = 0;
let panning = false, px = 0, py = 0;
const toLocal = (ev) => {
const ctm = svg.getScreenCTM();
if (!ctm) return {x: 0, y: 0};
const pt = svg.createSVGPoint();
pt.x = ev.clientX; pt.y = ev.clientY;
return pt.matrixTransform(ctm.inverse());
};
const onMove = (ev) => {
if (dragEl) {
const p = toLocal(ev);
dragEl.setAttribute('transform', `translate(${p.x - dx}, ${p.y - dy})`);
} else if (panning) {
topoPanX += ev.clientX - px;
topoPanY += ev.clientY - py;
px = ev.clientX; py = ev.clientY;
applyTopoView();
}
};
const onUp = () => { dragEl = null; panning = false; svg.style.cursor = ''; };
svg.addEventListener('mousedown', (ev) => {
const g = ev.target.closest('g[data-node]');
if (g) {
ev.preventDefault();
const p = toLocal(ev);
const c = g.querySelector('circle');
dx = p.x - parseFloat(c.getAttribute('cx'));
dy = p.y - parseFloat(c.getAttribute('cy'));
dragEl = g;
svg.style.cursor = 'grabbing';
g.setPointerCapture && g.setPointerCapture(ev.pointerId);
} else {
// empty area -> pan the canvas
panning = true;
px = ev.clientX; py = ev.clientY;
svg.style.cursor = 'move';
}
});
svg.addEventListener('pointermove', onMove);
svg.addEventListener('pointerup', onUp);
svg.addEventListener('pointercancel', onUp);
// wheel zoom (ctrl+wheel or plain wheel on empty area)
svg.addEventListener('wheel', (ev) => {
ev.preventDefault();
const factor = ev.deltaY < 0 ? 1.12 : 1 / 1.12;
const ns = Math.min(3, Math.max(0.3, topoScale * factor));
// zoom around cursor
const rect = svg.getBoundingClientRect();
const mx = ev.clientX - rect.left, my = ev.clientY - rect.top;
const W = svg.clientWidth, H = svg.clientHeight;
topoPanX = mx - (mx - topoPanX) * (ns / topoScale);
topoPanY = my - (my - topoPanY) * (ns / topoScale);
topoScale = ns;
applyTopoView();
}, {passive: false});
}
function applyTopoView() {
const svg = document.getElementById('topoSvg');
const g = svg.querySelector('g[data-root]');
if (!g) return;
g.setAttribute('transform', `translate(${topoPanX}, ${topoPanY}) scale(${topoScale})`);
// update hint + zoom % label
const zl = document.getElementById('topoZoomLabel');
if (zl) zl.textContent = Math.round(topoScale * 100) + '%';
}
function topoZoom(factor) {
const svg = document.getElementById('topoSvg');
const rect = svg.getBoundingClientRect();
const ns = Math.min(3, Math.max(0.3, topoScale * factor));
topoPanX = rect.width / 2 - (rect.width / 2 - topoPanX) * (ns / topoScale);
topoPanY = rect.height / 2 - (rect.height / 2 - topoPanY) * (ns / topoScale);
topoScale = ns;
applyTopoView();
if (topoGraph && topoGraph.app) topoGraph.zoomBy(factor);
}
function topoReset() { topoScale = 1; topoPanX = 0; topoPanY = 0; applyTopoView(); }
function topoReset() {
if (topoGraph && topoGraph.app) topoGraph.reset();
}
// Debug/automation hook: the inline script's top-level `let topoGraph` is not a
// window property, so headless tests and devtools have no way to inspect the
// live scene graph. Expose the instance deliberately.
window.__topoProbe = () => topoGraph;
// ---------- Teams ----------
let TEAM_LABELS = {}; // idx -> label, filled by loadTeams (for confirm dialogs)