User report: the graph "disappeared". Reproduced, and the cause was not the
graph at all — the panel's main thread was blocked hard enough that the browser
stopped responding to clicks.
Root cause, found by measuring rather than guessing:
rAF 2 FPS, setTimeout(0) lag 1353ms, with the graph rendering correctly the
whole time. The scene was repainting continuously at 60fps even though it is
static between the 10s data refreshes. Each repaint cost ~305ms on this
host's software GL, so the main thread never got a free slot.
Fixes:
- Render on demand. The ticker is registered but not started; it runs only while
an attack pulse is in flight or the user is dragging, and stops once the scene
is quiet. applyView() (the single funnel for pan/zoom/drag/refit) repaints
synchronously. Result: 2 FPS -> 73 FPS, 1353ms -> 2ms lag. That is faster than
a blank page in the same harness (29 FPS), which confirms the ticker was the
cost, not the scene complexity.
- Stop rebuilding Pixi objects every refresh. _redrawEdges destroyed and
recreated ~70 Graphics + ~36 Text each cycle; every new Text allocates a
canvas, rasterises glyphs and uploads a texture. Edges are now kept per
from->to key and only their geometry is redrawn; a label's text is re-set only
when the string actually changes. Same for node titles/subtitles.
- Bind tooltip handlers once, at node creation. Re-binding inside the refresh
loop added a pointerover listener every 10s, so a single hover after an hour
fired thousands of handlers.
Two correctness bugs fixed while in there:
- init() race. The Topology tab button calls showView('topo') AND loadTopo(), and
showView() itself calls loadTopo(), so two ran concurrently. The re-entry guard
tested `topoGraph && topoGraph.app`, but topoGraph was assigned BEFORE the
awaited init(), so app was still null and a second renderer was built. Both
cleared host.innerHTML and appended their own canvas, so the last init to
finish won the DOM while the bridge still pointed at the other — a live canvas
that was no longer on the page. Now guarded by a single-flight promise, and
the instance is published only after init() resolves.
- Dropped the dead topoLoaded flag left over from the SVG renderer.
Hierarchical drag: dragging a team now carries its 16 challenge nodes with it.
The parent->children index is built from the edge list, the same source the
lines are drawn from, so the drag hierarchy cannot disagree with the picture;
challenges missing from the edge list still attach by id prefix. Children
translate rigidly (verified: 0.000px deviation across all 16).
Verified from a cold panel restart: all 5 topology tests pass, 73 FPS, 2ms
lag, 37 nodes drawn, graph framed at 4 viewport widths, no page errors, and
platform SLA unregressed at 32/32.
108 lines
4.6 KiB
JavaScript
108 lines
4.6 KiB
JavaScript
// The topology "disappears" for the user but passed my tests. Two suspects in
|
|
// index.html:
|
|
// 1. The Topology tab button calls `showView('topo'); loadTopo()`, and
|
|
// showView ITSELF calls loadTopo() when v==='topo' -> two concurrent calls.
|
|
// 2. ensureTopoGraph() assigns `topoGraph = new TopoGraph(host)` BEFORE awaiting
|
|
// init(), but the guard is `if (topoGraph && topoGraph.app)` — and app is
|
|
// null until init() finishes. A second concurrent call therefore builds a
|
|
// SECOND renderer. Both run host.innerHTML = '' and append their own canvas,
|
|
// so whichever init() finishes LAST wins the DOM while `topoGraph` still
|
|
// points at the other one -> a canvas that is running but detached from the
|
|
// page, i.e. nothing visible.
|
|
// Hammer the tab exactly like a user clicking it, and check how many canvases
|
|
// exist and which one the bridge holds.
|
|
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 errs = [];
|
|
page.on('pageerror', e => errs.push('PAGEERROR: ' + e.message));
|
|
page.on('console', m => { if (m.type() === 'error') errs.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');
|
|
|
|
// Count how many times the module actually initialises a renderer.
|
|
await page.evaluate(() => {
|
|
window.__inits = 0;
|
|
const host = document.getElementById('topoHost');
|
|
const mo = new MutationObserver(() => { /* canvas churn visible below */ });
|
|
mo.observe(host, { childList: true });
|
|
window.__mo = mo;
|
|
});
|
|
|
|
console.log('=== single tab click (what my tests did) ===');
|
|
await page.click('.tab[data-view=topo]');
|
|
await page.waitForTimeout(2500);
|
|
let s = await page.evaluate(() => {
|
|
const host = document.getElementById('topoHost');
|
|
const g = window.__topoProbe ? window.__topoProbe() : null;
|
|
return {
|
|
canvases: host.querySelectorAll('canvas').length,
|
|
hostChildren: host.children.length,
|
|
bridgeHasApp: !!(g && g.app),
|
|
bridgeCanvasAttached: !!(g && g.app && g.app.canvas && g.app.canvas.isConnected),
|
|
nodes: g ? g.nodeViews.size : -1,
|
|
};
|
|
});
|
|
console.log(' ', JSON.stringify(s));
|
|
|
|
console.log('\n=== rapid re-entry (leave tab, come back, x3) ===');
|
|
for (let i = 0; i < 3; i++) {
|
|
await page.click('.tab[data-view=challs]');
|
|
await page.waitForTimeout(120);
|
|
await page.click('.tab[data-view=topo]');
|
|
await page.waitForTimeout(900);
|
|
const t = await page.evaluate(() => {
|
|
const host = document.getElementById('topoHost');
|
|
const g = window.__topoProbe ? window.__topoProbe() : null;
|
|
return {
|
|
canvases: host.querySelectorAll('canvas').length,
|
|
hostChildren: host.children.length,
|
|
bridgeCanvasAttached: !!(g && g.app && g.app.canvas && g.app.canvas.isConnected),
|
|
nodes: g ? g.nodeViews.size : -1,
|
|
};
|
|
});
|
|
console.log(` round ${i + 1}:`, JSON.stringify(t));
|
|
}
|
|
|
|
console.log('\n=== final pixel check on the VISIBLE canvas ===');
|
|
const r = await page.evaluate(() => {
|
|
const host = document.getElementById('topoHost');
|
|
const cv = host.querySelector('canvas');
|
|
if (!cv) return { error: 'no canvas in host at all' };
|
|
const g = window.__topoProbe();
|
|
const rd = g.app.renderer;
|
|
rd.render(g.app.stage);
|
|
const W = rd.width, H = rd.height;
|
|
const buf = new Uint8Array(W * H * 4);
|
|
rd.gl.readPixels(0, 0, W, H, rd.gl.RGBA, rd.gl.UNSIGNED_BYTE, buf);
|
|
let nonBg = 0;
|
|
for (let i = 0; i < buf.length; i += 4)
|
|
if (Math.max(Math.abs(buf[i]-10), Math.abs(buf[i+1]-15), Math.abs(buf[i+2]-28)) > 3) nonBg++;
|
|
return {
|
|
bridgeCanvasIsTheVisibleOne: cv === g.app.canvas,
|
|
pct: +(100 * nonBg / (W * H)).toFixed(2),
|
|
visibleCanvasW: cv.width, bridgeCanvasW: rd.width,
|
|
};
|
|
});
|
|
console.log(' ', JSON.stringify(r));
|
|
if (errs.length) { console.log('\n errors:'); errs.slice(0, 6).forEach(e => console.log(' ' + e)); }
|
|
await browser.close();
|
|
})();
|