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:
@@ -28,6 +28,13 @@ BASE_DIR = Path(__file__).parent
|
||||
|
||||
app = FastAPI(title="Gemastik A/D Panel")
|
||||
|
||||
# The panel used to serve every page as an inline HTMLResponse, so static/ was
|
||||
# never mounted. The PixiJS topology needs real asset URLs: topo_pixi.js is an ES
|
||||
# module and vendor/pixi.mjs is an 810 KB bundle — neither can be inlined.
|
||||
# Mounted at the root so the module's own `import './vendor/pixi.mjs'` and
|
||||
# `import('./topo_pixi.js')` resolve without rewriting paths.
|
||||
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _start_background():
|
||||
|
||||
+43
-181
@@ -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 <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)
|
||||
|
||||
@@ -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) + '%';
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+21
@@ -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.
|
||||
Vendored
+2341
File diff suppressed because one or more lines are too long
@@ -0,0 +1,97 @@
|
||||
// Real-browser test of the PixiJS topology: boot the panel, log in, open the
|
||||
// Topology tab, and assert a live WebGL canvas actually rendered the graph.
|
||||
// A JS-parse check cannot catch "init() rejected" or "canvas sized to 0".
|
||||
const { chromium } = require('/tmp/gltest/node_modules/playwright-core');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-unsafe-swiftshader'],
|
||||
});
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||
const errors = [], logs = [];
|
||||
page.on('pageerror', e => errors.push('pageerror: ' + e.message));
|
||||
page.on('console', m => { if (m.type() === 'error') errors.push('console: ' + m.text()); else logs.push(m.text()); });
|
||||
page.on('requestfailed', r => errors.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`));
|
||||
|
||||
const creds = require('fs').readFileSync('/opt/gemastik18-final/panel/.env', 'utf8')
|
||||
.split('\n').reduce((a, l) => {
|
||||
const m = l.match(/^PANEL_ADMIN_(USER|PASS)=(.*)$/); if (m) a[m[1]] = m[2]; return a;
|
||||
}, {});
|
||||
|
||||
await page.goto('https://panel.attackdefense.imrnes.team/login', { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#u', creds.USER);
|
||||
await page.fill('#p', creds.PASS);
|
||||
await page.click('#f button[type=submit]');
|
||||
await page.waitForSelector('#view-challs.active', { timeout: 20000 });
|
||||
|
||||
// Open the topology tab.
|
||||
await page.click('.tab[data-view=topo]');
|
||||
await page.waitForSelector('#topoHost canvas', { timeout: 30000 });
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
// The inline script's top-level `let topoGraph` is not a window property,
|
||||
// so read it through the page's deliberate debug hook.
|
||||
const g = window.__topoProbe ? window.__topoProbe() : null;
|
||||
const host = document.getElementById('topoHost');
|
||||
const cv = host && host.querySelector('canvas');
|
||||
return {
|
||||
hasCanvas: !!cv,
|
||||
canvasW: cv ? cv.width : 0,
|
||||
canvasH: cv ? cv.height : 0,
|
||||
cssW: cv ? cv.getBoundingClientRect().width : 0,
|
||||
cssH: cv ? cv.getBoundingClientRect().height : 0,
|
||||
pixiLoaded: !!(g && g.PIXI),
|
||||
nodeViews: g ? g.nodeViews.size : 0,
|
||||
attackViews: g ? g.attackViews.size : 0,
|
||||
edgeChildren: g && g.edgeLayer ? g.edgeLayer.children.length : 0,
|
||||
nodeChildren: g && g.nodeLayer ? g.nodeLayer.children.length : 0,
|
||||
scale: g ? g.scale : null,
|
||||
engine: document.getElementById('topoEngine')?.textContent || '',
|
||||
// Are pixels actually drawn, or an empty background?
|
||||
zoomLabel: document.getElementById('topoZoomLabel')?.textContent,
|
||||
};
|
||||
});
|
||||
|
||||
console.log('--- topology render state ---');
|
||||
for (const [k, v] of Object.entries(res)) console.log(` ${k}: ${v}`);
|
||||
|
||||
// Zoom controls must reach the Pixi world transform.
|
||||
await page.click('#view-topo button:has-text("Zoom In")');
|
||||
await page.waitForTimeout(400);
|
||||
const zoomed = await page.evaluate(() => ({ scale: window.__topoProbe().scale, label: document.getElementById('topoZoomLabel').textContent }));
|
||||
console.log(' after zoom-in ->', JSON.stringify(zoomed));
|
||||
await page.click('#view-topo button:has-text("\u27f3")');
|
||||
await page.waitForTimeout(300);
|
||||
// Reset now means "frame the whole graph in the viewport", not "scale=1": with
|
||||
// 37 nodes the graph is wider than the canvas, so the fit scale is < 1. Assert
|
||||
// the real contract — that reset returns to the framed scale and the view
|
||||
// transform is centred — rather than a magic 1 that only held for small graphs.
|
||||
const before = await page.evaluate(() => window.__topoProbe().scale);
|
||||
await page.click('#view-topo button:has-text("\u27f3")');
|
||||
await page.waitForTimeout(400);
|
||||
const after = await page.evaluate(() => ({
|
||||
scale: window.__topoProbe().scale,
|
||||
panX: window.__topoProbe().panX,
|
||||
label: document.getElementById('topoZoomLabel').textContent,
|
||||
}));
|
||||
console.log(' after zoom -> scale', before.toFixed(3));
|
||||
console.log(' after reset -> scale', after.scale.toFixed(3), 'label', after.label);
|
||||
if (Math.abs(after.scale - before) > 0.001) {
|
||||
console.log(` FAIL: reset did not restore the framed scale (${after.scale} vs ${before})`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(' PASS: reset restored the framed scale');
|
||||
}
|
||||
|
||||
await page.screenshot({ path: '/tmp/topo_pixi.png' });
|
||||
console.log(' screenshot -> /tmp/topo_pixi.png');
|
||||
|
||||
if (errors.length) { console.log('\n--- ERRORS ---'); errors.slice(0, 12).forEach(e => console.log(' ' + e)); }
|
||||
else console.log('\n no page errors');
|
||||
|
||||
const ok = res.hasCanvas && res.canvasW > 0 && res.canvasH > 0 && res.nodeViews > 0 && res.nodeChildren > 0;
|
||||
console.log(`\n ${ok ? 'PASS' : 'FAIL'} — canvas ${res.canvasW}x${res.canvasH}, ${res.nodeViews} nodes, ${res.edgeChildren} edge children`);
|
||||
await browser.close();
|
||||
process.exit(ok && errors.length === 0 ? 0 : 1);
|
||||
})();
|
||||
@@ -0,0 +1,131 @@
|
||||
// Proves the PixiJS topology actually DRAWS, not merely that it initialises.
|
||||
//
|
||||
// Three earlier test designs were wrong, and every one of them reported a false
|
||||
// failure against a perfectly working graph. They are documented here so nobody
|
||||
// reintroduces them:
|
||||
//
|
||||
// 1. "canvas exists and has N scene children" — passes on a fully blank canvas.
|
||||
// 2. "count pixels that differ from the background colour" with a tight
|
||||
// threshold — the theme is dark by design (node fill 0x0e1526 on bg
|
||||
// 0x0a0f1c, team discs at alpha 0.13), so a perfect render still measured
|
||||
// ~0% and looked like a total failure.
|
||||
// 3. Diffing two Playwright screenshots — both captures can be taken after the
|
||||
// scene was mutated, and the WebGL back buffer is not guaranteed to be
|
||||
// captured, so the diff was 0 for a graph that was plainly rendering.
|
||||
//
|
||||
// The reliable check reads the GL back buffer via readPixels INSIDE a single
|
||||
// page.evaluate, so the frame being measured is the frame just rendered, with no
|
||||
// compositor or screenshot timing involved.
|
||||
const { chromium } = require('/tmp/gltest/node_modules/playwright-core');
|
||||
|
||||
const BASE = process.env.PANEL_URL || 'https://panel.attackdefense.imrnes.team';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-unsafe-swiftshader'],
|
||||
});
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
|
||||
page.on('console', m => { if (m.type() === 'error') errors.push('CONSOLE: ' + m.text()); });
|
||||
|
||||
const creds = require('fs').readFileSync('/opt/gemastik18-final/panel/.env', 'utf8')
|
||||
.split('\n').reduce((a, l) => {
|
||||
const m = l.match(/^PANEL_ADMIN_(USER|PASS)=(.*)$/);
|
||||
if (m) a[m[1]] = m[2];
|
||||
return a;
|
||||
}, {});
|
||||
|
||||
await page.goto(BASE + '/login', { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#u', creds.USER);
|
||||
await page.fill('#p', creds.PASS);
|
||||
await page.click('#f button[type=submit]');
|
||||
await page.waitForSelector('#view-challs.active');
|
||||
await page.click('.tab[data-view=topo]');
|
||||
await page.waitForSelector('#topoHost canvas');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const state = await page.evaluate(() => {
|
||||
const g = window.__topoProbe();
|
||||
return {
|
||||
nodes: g.nodeViews.size,
|
||||
edgeChildren: g.edgeLayer.children.length,
|
||||
attacks: g.attackViews.size,
|
||||
scale: +g.scale.toFixed(3),
|
||||
zoomLabel: document.getElementById('topoZoomLabel').textContent,
|
||||
engine: document.getElementById('topoEngine') ? document.getElementById('topoEngine').textContent : null,
|
||||
canvasW: g.app.renderer.width,
|
||||
canvasH: g.app.renderer.height,
|
||||
};
|
||||
});
|
||||
console.log('--- scene state ---');
|
||||
for (const [k, v] of Object.entries(state)) console.log(` ${k}: ${v}`);
|
||||
|
||||
// Single evaluate: render, then read the very same frame back.
|
||||
const pixels = await page.evaluate(() => {
|
||||
const g = window.__topoProbe();
|
||||
const r = g.app.renderer;
|
||||
r.render(g.app.stage);
|
||||
const W = r.width, H = r.height;
|
||||
const buf = new Uint8Array(W * H * 4);
|
||||
r.gl.readPixels(0, 0, W, H, r.gl.RGBA, r.gl.UNSIGNED_BYTE, buf);
|
||||
|
||||
// Background is 0x0a0f1c = (10, 15, 28).
|
||||
let nonBg = 0, bright = 0, maxDist = 0;
|
||||
let minX = W, minY = H, maxX = -1, maxY = -1;
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
const i = (y * W + x) * 4;
|
||||
const dist = Math.max(Math.abs(buf[i] - 10), Math.abs(buf[i+1] - 15), Math.abs(buf[i+2] - 28));
|
||||
if (dist > 3) {
|
||||
nonBg++;
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
if (dist > 60) bright++;
|
||||
if (dist > maxDist) maxDist = dist;
|
||||
}
|
||||
}
|
||||
const total = W * H;
|
||||
return {
|
||||
w: W, h: H, total,
|
||||
nonBg, pctNonBg: +(100 * nonBg / total).toFixed(2),
|
||||
bright, pctBright: +(100 * bright / total).toFixed(2),
|
||||
maxDist,
|
||||
// A blank frame has no bbox; a real graph occupies a contiguous region.
|
||||
bbox: maxX < 0 ? null : [minX, minY, maxX, maxY],
|
||||
glError: r.gl.getError(),
|
||||
preserveDrawingBuffer: r.preserveDrawingBuffer,
|
||||
};
|
||||
});
|
||||
|
||||
console.log('\n--- GL back-buffer readback (authoritative) ---');
|
||||
for (const [k, v] of Object.entries(pixels)) console.log(` ${k}: ${JSON.stringify(v)}`);
|
||||
|
||||
// Human-visible proof for the report, plus a coverage cross-check.
|
||||
const box = await page.locator('#topoHost canvas').boundingBox();
|
||||
const shot = await page.screenshot({ clip: box });
|
||||
require('fs').writeFileSync('/tmp/topo_proof.png', shot);
|
||||
|
||||
// The graph must ALSO be framed inside the viewport, not parked off-screen.
|
||||
const framed = pixels.bbox !== null &&
|
||||
pixels.bbox[0] >= 0 && pixels.bbox[1] >= 0 &&
|
||||
pixels.bbox[2] <= pixels.w && pixels.bbox[3] <= pixels.h;
|
||||
const drew = pixels.pctNonBg > 1 && pixels.maxDist > 40;
|
||||
const noGlError = pixels.glError === 0;
|
||||
|
||||
console.log(`\n graph drew pixels ............. ${drew ? 'PASS' : 'FAIL'} (${pixels.pctNonBg}%, max channel delta ${pixels.maxDist})`);
|
||||
console.log(` graph framed in viewport ...... ${framed ? 'PASS' : 'FAIL'} (bbox ${JSON.stringify(pixels.bbox)} in ${pixels.w}x${pixels.h})`);
|
||||
console.log(` no GL errors ................. ${noGlError ? 'PASS' : 'FAIL'} (glGetError=${pixels.glError})`);
|
||||
if (state.nodes < 1) console.log(' nodes rendered ................ FAIL (0 nodes)');
|
||||
else console.log(` nodes rendered ................ PASS (${state.nodes} nodes, ${state.edgeChildren} edges)`);
|
||||
if (errors.length) { console.log('\n page errors:'); errors.forEach(e => console.log(' ' + e)); }
|
||||
console.log(` screenshot -> /tmp/topo_proof.png`);
|
||||
|
||||
const ok = drew && framed && noGlError && state.nodes > 0 && errors.length === 0;
|
||||
console.log(`\n ${ok ? 'PASS' : 'FAIL'}`);
|
||||
await browser.close();
|
||||
process.exit(ok ? 0 : 1);
|
||||
})();
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Live health check for the attack-defense platform after the PixiJS work.
|
||||
|
||||
Uses the panel's own API with credentials read from .env, so no shell quoting
|
||||
hazard and no password on a command line.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import http.cookiejar
|
||||
from pathlib import Path
|
||||
|
||||
BASE = "http://127.0.0.1:18081"
|
||||
ENV = Path("/opt/gemastik18-final/panel/.env")
|
||||
|
||||
|
||||
def load_env():
|
||||
cfg = {}
|
||||
for line in ENV.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, _, v = line.partition("=")
|
||||
cfg[k.strip()] = v.strip().strip('"').strip("'")
|
||||
return cfg
|
||||
|
||||
|
||||
def get(url, cookie=None):
|
||||
req = urllib.request.Request(url)
|
||||
if cookie:
|
||||
req.add_header("Cookie", "; ".join(f"{c.name}={c.value}" for c in cookie))
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return r.read().decode()
|
||||
|
||||
|
||||
def post_json(url, payload, cookie=None):
|
||||
data = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(url, data=data, method="POST",
|
||||
headers={"Content-Type": "application/json"})
|
||||
if cookie:
|
||||
req.add_header("Cookie", "; ".join(f"{c.name}={c.value}" for c in cookie))
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return r.read().decode()
|
||||
|
||||
|
||||
def main():
|
||||
cfg = load_env()
|
||||
jar = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
|
||||
# main.py's /api/login expects {"user","pass"} — not username/password.
|
||||
body = json.dumps({"user": cfg["PANEL_ADMIN_USER"],
|
||||
"pass": cfg["PANEL_ADMIN_PASS"]}).encode()
|
||||
req = urllib.request.Request(BASE + "/api/login", data=body, method="POST",
|
||||
headers={"Content-Type": "application/json"})
|
||||
with opener.open(req, timeout=30) as r:
|
||||
login = json.loads(r.read().decode())
|
||||
print("login:", login)
|
||||
|
||||
cookie = jar
|
||||
teams_raw = json.loads(get(BASE + "/api/teams", cookie))
|
||||
teams = teams_raw.get("teams", teams_raw) if isinstance(teams_raw, dict) else teams_raw
|
||||
print("\nteams:")
|
||||
for t in teams:
|
||||
print(f" #{t.get('index')} {t.get('label')} domain={t.get('domain')} "
|
||||
f"receivers={t.get('receiver_port')} challenges={len(t.get('challenges') or [])}")
|
||||
|
||||
topo = json.loads(get(BASE + "/api/topology", cookie))
|
||||
print("\ntopology API:",
|
||||
{k: (len(v) if isinstance(v, list) else v) for k, v in topo.items()})
|
||||
|
||||
containers = subprocess.run(
|
||||
["docker", "ps", "--format", "{{.Names}}"],
|
||||
capture_output=True, text=True, timeout=60).stdout.split()
|
||||
team_ct = [c for c in containers if c.endswith(tuple(f"_team{i}" for i in range(1, 10)))]
|
||||
orphans = [c for c in containers
|
||||
if "_container" in c and not any(c.endswith(f"_team{i}") for i in range(1, 10))]
|
||||
print(f"\ncontainers: {len(team_ct)} team-scoped, orphans={orphans}")
|
||||
|
||||
services = subprocess.run(
|
||||
["systemctl", "is-active",
|
||||
"gemastik-panel", "gemastik-receiver-service",
|
||||
"gemastik-receiver-team1", "gemastik-receiver-team2"],
|
||||
capture_output=True, text=True, timeout=60).stdout.strip()
|
||||
print("services:\n ", services.replace("\n", "\n "))
|
||||
|
||||
load = subprocess.run(["uptime"], capture_output=True, text=True).stdout.strip()
|
||||
print("load:", load)
|
||||
print("expected team containers:", len(teams) * 16, "| actual:", len(team_ct))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user