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 committed 2026-09-27 00:31:47 +08:00
1 parent 50cb782ded
commit 1461c874c3
8 files changed
+3260 -181

No files matched your search

+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)
+527
View File
@@ -0,0 +1,527 @@
// ---------------------------------------------------------------------------
// 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) + '%';
}
}
+21
View File
@@ -0,0 +1,21 @@
# Vendored third-party assets
## pixi.mjs — PixiJS 8.21.0 (MIT)
Topology graph renderer for the admin panel. Self-hosted on purpose: the panel
must not depend on a CDN at runtime (the CTF network is air-gapped during the
event, and a CDN outage would take the topology view down with it).
Provenance and upgrade path:
npm pack pixi.js@8
tar xzf pixi.js-8.21.0.tgz
cp package/dist/pixi.min.mjs panel/static/vendor/pixi.mjs
Use `pixi.min.mjs`, **not** `pixi.min.js`. The `.js` bundle is an IIFE that
discards its return value and exports no global, so it cannot be loaded with a
plain `<script>` tag. The `.mjs` build has real named exports and is loaded via
`await import('/static/vendor/pixi.mjs')` in `static/topo_pixi.js`.
Loading it dynamically (rather than a `<script>` in every page) keeps the ~800 KB
parse cost off panel loads that never open the Topology tab.
+2341
View File
File diff suppressed because it is too large. Load diff