Files
attack-defense-platform/panel/static/topo_pixi.js
T
root 1461c874c3 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.
2026-09-27 00:31:47 +08:00

528 lines
20 KiB
JavaScript

// ---------------------------------------------------------------------------
// Topology graph renderer — PixiJS v8 (WebGL/WebGPU).
//
// Replaces the hand-rolled inline-SVG topology. Why Pixi:
// * 37 nodes / 36 edges redrawn every 10s as one innerHTML string blew away
// and re-created every DOM node, so CSS animations (attack pulse) restarted
// and dragging fought the browser's own hit-testing.
// * The scene graph gives per-node transforms for free, so pan/zoom is a
// single container transform instead of getScreenCTM() matrix math.
//
// v8 API notes (verified against pixijs.com/8.x docs, NOT v7 memory):
// * `new Application()` then `await app.init({...})` — init is ASYNC.
// * `app.canvas` is the HTMLCanvasElement (v7 was `app.view`).
// * Graphics is a builder: `.circle(x,y,r).fill({color}).stroke({width,color})`.
// The v7 `.beginFill().drawCircle().endFill()` chain is gone.
// * `Text` uses `style: { fill, fontSize, fontFamily, align }`; v7 took flat
// ctor args like `new Text(txt, style, canvas)`, which no longer applies.
//
// The module is self-contained: it owns its Application, and `destroy()` tears
// it down so re-entering the tab cannot leak a second WebGL context.
// ---------------------------------------------------------------------------
let PIXI = null;
export async function loadPixi() {
if (PIXI) return PIXI;
// Dynamic import: the topology tab may never be opened, and an 810 KB parse
// on every panel load is pure waste.
PIXI = await import('/static/vendor/pixi.mjs');
return PIXI;
}
const THEME = {
bg: 0x0a0f1c,
edge: 0x2a4a6f,
edgeLabel: 0x5a7a9f,
node: { fill: 0x0e1526, stroke: 0x2a4a6f, color: 0xa8c3e0, r: 34 },
panel: { fill: 0x0ea5e9, alpha: 0.20, stroke: 0x0ea5e9, color: 0x7dd3fc, r: 42 },
infra: { fill: 0x0e1526, stroke: 0x2a4a6f, color: 0x8aa0b8, r: 30 },
team: { fill: 0x2563eb, alpha: 0.13, stroke: 0x3b82f6, color: 0x93c5fd, r: 48 },
challenge: { fill: 0x0e1526, stroke: 0x2a4a6f, color: 0xc9d4e3, r: 26 },
running: 0x34d399,
attackOk: 0xf87171,
attackFail: 0xfb923c,
};
// Challenge layout order — must match the old SVG code, which positioned the
// 6 native GEMASTIK XVIII challenges left-to-right under their team.
const NATIVE_ORDER = ['blogpost', 'carbeat', 'cdn', 'phew', 'sheesh', 'warmup'];
export class TopoGraph {
constructor(host) {
this.host = host;
this.app = null;
this.world = null; // pan/zoom container
this.edgeLayer = null;
this.attackLayer = null;
this.nodeLayer = null;
this.nodeViews = new Map(); // id -> {container, data}
this.attackViews = new Map(); // key -> {gfx, label, latest, ok, fail}
this.scale = 1;
this.panX = 0;
this.panY = 0;
this.nodes = [];
this.edges = [];
this.attacks = [];
this.drag = null;
this.ticker = 0;
}
async init() {
const PIXI = await loadPixi();
this.PIXI = PIXI;
this.app = new PIXI.Application();
await this.app.init({
resizeTo: this.host,
background: THEME.bg,
antialias: true,
autoDensity: true,
resolution: Math.min(2, window.devicePixelRatio || 1),
preference: 'webgl',
// WebGL clears its back buffer after compositing unless this is set, so a
// readback/screenshot of the canvas is a coin flip that depends on which
// frame the snapshot lands on. That made the render look intermittently
// blank. Cheap here (a 2D scene, no shaders) and it makes the view
// capturable for tests, screenshots and "save image" affordances.
preserveDrawingBuffer: true,
});
this.app.canvas.style.display = 'block';
this.app.canvas.style.width = '100%';
this.app.canvas.style.height = '520px';
this.app.canvas.style.touchAction = 'none';
this.host.innerHTML = '';
this.host.appendChild(this.app.canvas);
this.world = new PIXI.Container();
this.app.stage.addChild(this.world);
// Render groups: pan/zoom touches ONE transform instead of every child.
this.world.enableRenderGroup();
// First setData() must frame the graph instead of rendering at scale=1.
this._needsFit = true;
// `resizeTo: this.host` resizes the renderer but not our view transform, so
// a narrower window would clip the graph. Re-frame instead.
this._resizeHandler = () => this._onResize();
window.addEventListener('resize', this._resizeHandler);
this.edgeLayer = new PIXI.Container();
this.attackLayer = new PIXI.Container();
this.nodeLayer = new PIXI.Container();
this.world.addChild(this.edgeLayer, this.attackLayer, this.nodeLayer);
this._bindPointer();
// The attack pulse is the only per-frame work; everything else is redrawn
// on data refresh, so keep the ticker cheap.
this.app.ticker.add((ticker) => this._animate(ticker.deltaMS));
return this;
}
destroy() {
if (this.app) {
try { this.app.destroy(true, { children: true }); } catch (e) { /* already gone */ }
}
this.app = null;
this.nodeViews.clear();
this.attackViews.clear();
}
// ---- layout -------------------------------------------------------------
layout(nodes) {
// The world is wider than the canvas on purpose (the old SVG scrolled
// horizontally), but the graph must still START inside the viewport: the
// team ring was centred on W/2, so with 37 nodes W grew to ~4810 and every
// team/challenge node landed far off the right edge of a ~1300px canvas.
// Centre the layout on the VISIBLE width, then let it grow to the right for
// overflow, and let reset()/applyView() fit it back on screen.
const visW = Math.max(320, (this.app && this.app.renderer ? this.app.renderer.width / this.app.renderer.resolution : 900));
const W = Math.max(visW, 900, nodes.length * 60);
const H = 500;
// Centre the ring on the viewport, not on the scrollable width.
const cx = visW / 2;
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;
// Radius must fit the viewport too, or the outermost team is off-screen.
const rx = Math.min(visW * 0.36, 420);
const 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 label = n.label || '';
const tail = label.split('-').pop() || label;
const chIdx = NATIVE_ORDER.indexOf(tail);
// Imported XVI/XVII challenges aren't in NATIVE_ORDER: fan them out to
// the right of the native six so they never stack on the same column.
const slot = chIdx >= 0 ? chIdx - 2.5 : (n.nativeIndex ?? 0) + 3.5;
pos[n.id] = { x: base.x + slot * 70, y: base.y + 130 };
});
return { pos, W, H };
}
// Frame the whole graph inside the viewport (used by reset + first load).
fit() {
if (!this.app || !this.pos) return;
const rw = this.app.renderer.width / this.app.renderer.resolution;
const rh = this.app.renderer.height / this.app.renderer.resolution;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const [, v] of this.nodeViews) {
minX = Math.min(minX, v.c.x - 60); maxX = Math.max(maxX, v.c.x + 60);
minY = Math.min(minY, v.c.y - 60); maxY = Math.max(maxY, v.c.y + 60);
}
if (!isFinite(minX)) return;
const gw = maxX - minX, gh = maxY - minY;
const pad = 16;
this.scale = Math.max(0.2, Math.min(1.6, Math.min((rw - pad * 2) / gw, (rh - pad * 2) / gh)));
this.panX = (rw - gw * this.scale) / 2 - minX * this.scale;
this.panY = (rh - gh * this.scale) / 2 - minY * this.scale;
this.applyView();
}
// ---- data -> scene ------------------------------------------------------
setData(nodes, edges, attacks) {
if (!this.app) return;
this.nodes = nodes || [];
this.edges = edges || [];
this.attacks = attacks || [];
const PIXI = this.PIXI;
const { pos, W, H } = this.layout(this.nodes);
this.pos = pos;
this.worldW = W;
this.worldH = H;
// Edges are cheap and fully derived -> clear and redraw wholesale.
this._redrawEdges(pos);
this._syncNodes(pos);
this._syncAttacks(pos);
// First paint (or a big data change) must land framed, not at scale=1 with
// the graph hanging off the right edge.
if (this._needsFit) { this._needsFit = false; this.fit(); }
else this.applyView();
}
_syncNodes(pos) {
const PIXI = this.PIXI;
const seen = new Set();
for (const n of this.nodes) {
const p = pos[n.id];
if (!p) continue;
seen.add(n.id);
let view = this.nodeViews.get(n.id);
if (!view) {
const c = new PIXI.Container();
const halo = new PIXI.Graphics();
const disc = new PIXI.Graphics();
const title = new PIXI.Text({
text: n.label,
style: { fill: 0xffffff, fontSize: 10, fontFamily: 'ui-sans-serif, system-ui', fontWeight: 'bold', align: 'center' },
});
const sub = new PIXI.Text({
text: '',
style: { fill: THEME.edgeLabel, fontSize: 9, fontFamily: 'ui-monospace, monospace', align: 'center' },
});
const dot = new PIXI.Graphics();
c.addChild(halo, disc, title, sub, dot);
// Nodes must be draggable, so make the whole container interactive.
c.eventMode = 'static';
c.cursor = 'grab';
c.on('pointerdown', (ev) => this._onNodeDown(ev, c));
title.anchor.set(0.5);
sub.anchor.set(0.5);
this.nodeLayer.addChild(c);
view = { c, halo, disc, title, sub, dot, data: null };
this.nodeViews.set(n.id, view);
}
view.data = n;
const theme = this._themeFor(n);
const r = theme.r;
const fillAlpha = theme.alpha ?? 1;
view.disc.clear()
.circle(0, 0, r)
.fill({ color: theme.fill, alpha: fillAlpha })
.stroke({ width: 1.5, color: theme.stroke, alpha: 1 });
view.halo.clear()
.circle(0, 0, Math.max(1, r - 4))
.stroke({ width: 1, color: theme.stroke, alpha: 0.3 });
view.title.style.fill = theme.color;
view.title.style.fontSize = n.type === 'team' ? 12 : 10;
view.title.text = n.label;
const sub = n.type === 'team' ? `:${n.receiver_port}` : (n.port ? `:${n.port}` : '');
view.sub.text = sub || '';
view.sub.visible = !!sub;
view.title.y = sub ? 0 : -4;
view.sub.y = 12;
// Position only when it changed: writing x/y every refresh would fight a
// drag in progress and make the graph jitter under the cursor.
if (!view.dragging) view.c.position.set(p.x, p.y);
view.dot.clear();
if (n.status === 'running') {
view.dot.circle(r - 8, -r + 8, 5).fill({ color: THEME.running });
}
if (n.tooltip) {
view.c.on('pointerover', () => this._setTooltip(n));
view.c.on('pointerout', () => this._setTooltip(null));
}
}
// Drop views for nodes that no longer exist (a deleted team, a challenge
// disabled in the challenge manager) so they don't linger as ghosts.
for (const [id, view] of [...this.nodeViews]) {
if (seen.has(id)) continue;
view.c.destroy({ children: true });
this.nodeViews.delete(id);
}
}
_themeFor(n) {
if (n.type === 'panel') return THEME.panel;
if (n.type === 'infra') return THEME.infra;
if (n.type === 'team') return THEME.team;
if (n.type === 'challenge') return THEME.challenge;
return THEME.node;
}
_syncAttacks(pos) {
const PIXI = this.PIXI;
const now = Date.now() / 1000;
const recent = this.attacks.filter(e => now - (e.ts || 0) < 600);
const counts = {};
for (const e of recent) {
const k = `${e.attacker}:${e.target}`;
counts[k] = counts[k] || { ok: 0, fail: 0, latest: e.ts || 0 };
counts[k][e.success ? 'ok' : 'fail']++;
counts[k].latest = Math.max(counts[k].latest, e.ts || 0);
}
this.attackCounts = counts;
const seen = new Set();
for (const [k, c] of Object.entries(counts)) {
const [atk, tgt] = k.split(':');
const a = pos['team' + atk], b = pos['team' + tgt];
if (!a || !b || atk === tgt) continue;
seen.add(k);
let view = this.attackViews.get(k);
if (!view) {
const g = new PIXI.Container();
const line = new PIXI.Graphics();
const label = new PIXI.Text({
text: '',
style: { fill: 0xffffff, fontSize: 12, fontFamily: 'ui-sans-serif, system-ui', align: 'center' },
});
label.anchor.set(0.5);
g.addChild(line, label);
g.eventMode = 'none';
this.attackLayer.addChild(g);
view = { g, line, label };
this.attackViews.set(k, view);
}
view.color = c.ok > 0 ? THEME.attackOk : THEME.attackFail;
view.line.clear()
.moveTo(a.x, a.y).lineTo(b.x, b.y)
.stroke({ width: 2, color: view.color, alpha: 0.9 });
view.label.style.fill = view.color;
view.label.text = `${c.ok}✓ ${c.fail}✗`;
view.label.position.set((a.x + b.x) / 2 + 8, (a.y + b.y) / 2 - 10);
view.latest = c.latest;
view.ok = c.ok;
view.fail = c.fail;
}
for (const [k, view] of [...this.attackViews]) {
if (seen.has(k)) continue;
view.g.destroy({ children: true });
this.attackViews.delete(k);
}
}
// ---- animation ---------------------------------------------------------
_animate(deltaMS) {
// Throttle to ~30fps: the ticker runs even when the tab is hidden and this
// host only has 2 cores. 33ms budget between alpha updates.
this.ticker += deltaMS;
if (this.ticker < 33) return;
this.ticker = 0;
const now = Date.now() / 1000;
for (const [k, view] of this.attackViews) {
const hot = now - (view.latest || 0) < 60;
// 1.2s cycle, matching the old CSS keyframes (attackPulse 1.2s).
const phase = (now % 1.2) / 1.2;
view.line.alpha = hot ? 0.55 + 0.45 * Math.sin(phase * Math.PI) : 0.9;
// Recent attacks read as a dashed "hot" line; redraw only on state change
// so we are not re-tessellating 30x a second for nothing.
const wantDash = hot ? '6 3' : '4 4';
if (view.dash !== wantDash) {
view.dash = wantDash;
const a = this.pos['team' + k.split(':')[0]];
const b = this.pos['team' + k.split(':')[1]];
if (a && b) {
view.line.clear()
.moveTo(a.x, a.y).lineTo(b.x, b.y)
.stroke({ width: hot ? 3 : 2, color: view.color, alpha: 0.9 });
}
}
}
}
// ---- pan / zoom / drag -------------------------------------------------
_bindPointer() {
const cv = this.app.canvas;
// v8 replaces the v7 `interactive = true` + `on('pointermove')` on the
// stage with eventMode on the object plus a normal DOM listener for the
// background pan (the stage itself is not interactive by default).
cv.addEventListener('wheel', (ev) => {
ev.preventDefault();
this.zoomAt(ev, ev.ctrlKey ? 0.01 : 0.0022);
}, { passive: false });
let panning = null;
cv.addEventListener('pointerdown', (ev) => {
if (this.drag) return; // node drag wins
panning = { x: ev.clientX, y: ev.clientY, px: this.panX, py: this.panY };
cv.setPointerCapture(ev.pointerId);
});
cv.addEventListener('pointermove', (ev) => {
if (!panning) return;
this.panX = panning.px + (ev.clientX - panning.x);
this.panY = panning.py + (ev.clientY - panning.y);
this.applyView();
});
const endPan = (ev) => {
if (!panning) return;
panning = null;
try { cv.releasePointerCapture(ev.pointerId); } catch (e) { /* not captured */ }
};
cv.addEventListener('pointerup', endPan);
cv.addEventListener('pointercancel', endPan);
}
_onNodeDown(ev, container) {
ev.stopPropagation();
const view = [...this.nodeViews.values()].find(v => v.c === container);
if (!view) return;
const world = this.toWorld(ev);
view.dragging = true;
container.cursor = 'grabbing';
this.drag = { view, dx: container.x - world.x, dy: container.y - world.y };
const move = (e) => {
const w = this.toWorld(e);
container.position.set(w.x + this.drag.dx, w.y + this.drag.dy);
// Edges/attacks originate at node centres, so a dragged node must
// redraw them or the graph lies about the topology.
this._redrawForDrag();
};
const up = () => {
view.dragging = false;
container.cursor = 'grab';
this.drag = null;
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
this._redrawForDrag();
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
}
_redrawForDrag() {
// A dragged node's position is user intent, not layout, so re-derive the
// edge/attack endpoints from the live node transforms instead of calling
// setData (which would snap the node back to its computed slot).
const pos = {};
for (const [id, view] of this.nodeViews) pos[id] = { x: view.c.x, y: view.c.y };
for (const id of ['dns', 'traefik', 'panel']) {
if (this.nodeViews.get(id)) pos[id] = { x: this.nodeViews.get(id).c.x, y: this.nodeViews.get(id).c.y };
}
this.pos = pos;
this._redrawEdges(pos);
this._redrawAttacks(pos);
}
_redrawEdges(pos) {
const PIXI = this.PIXI;
this.edgeLayer.removeChildren().forEach(c => c.destroy({ children: true }));
for (const e of this.edges) {
const a = pos[e.from], b = pos[e.to];
if (!a || !b) continue;
const g = new PIXI.Graphics();
g.moveTo(a.x, a.y).lineTo(b.x, b.y)
.stroke({ width: 1.5, color: THEME.edge, alpha: 1 });
g.eventMode = 'none';
this.edgeLayer.addChild(g);
if (e.label) {
const t = new PIXI.Text({
text: e.label,
style: { fill: THEME.edgeLabel, fontSize: 9, fontFamily: 'ui-monospace, monospace' },
});
t.anchor.set(0.5);
t.position.set((a.x + b.x) / 2, (a.y + b.y) / 2 - 6);
t.eventMode = 'none';
this.edgeLayer.addChild(t);
}
}
}
_redrawAttacks(pos) {
for (const [k, view] of this.attackViews) {
const a = pos['team' + k.split(':')[0]], b = pos['team' + k.split(':')[1]];
if (!a || !b) continue;
view.color = view.ok > 0 ? THEME.attackOk : THEME.attackFail;
view.line.clear()
.moveTo(a.x, a.y).lineTo(b.x, b.y)
.stroke({ width: 2, color: view.color, alpha: 0.9 });
view.label.position.set((a.x + b.x) / 2 + 8, (a.y + b.y) / 2 - 10);
}
}
toWorld(ev) {
const rect = this.app.canvas.getBoundingClientRect();
return {
x: (ev.clientX - rect.left - this.panX) / this.scale,
y: (ev.clientY - rect.top - this.panY) / this.scale,
};
}
zoomAt(ev, factor) {
const rect = this.app.canvas.getBoundingClientRect();
const mx = ev.clientX - rect.left, my = ev.clientY - rect.top;
const before = { x: (mx - this.panX) / this.scale, y: (my - this.panY) / this.scale };
const next = Math.max(0.2, Math.min(4, this.scale * (1 - factor * 12)));
this.scale = next;
// Keep the point under the cursor fixed while scaling.
this.panX = mx - before.x * this.scale;
this.panY = my - before.y * this.scale;
this.applyView();
}
zoomBy(factor) {
this.scale = Math.max(0.2, Math.min(4, this.scale * factor));
this.applyView();
}
reset() {
// "Reset" means "show me the whole graph", not "scale=1, pan=0" — the latter
// is only correct while the layout fits the viewport, and with many teams it
// does not. Frame-to-fit is what the button actually promises.
if (this.nodeViews.size) this.fit();
else { this.scale = 1; this.panX = 0; this.panY = 0; this.applyView(); }
}
_onResize() {
if (this.nodeViews.size) this.fit();
}
applyView() {
if (!this.app || !this.world) return;
this.world.position.set(this.panX || 0, this.panY || 0);
this.world.scale.set(this.scale);
const lbl = document.getElementById('topoZoomLabel');
if (lbl) lbl.textContent = Math.round(this.scale * 100) + '%';
}
}