Files
root cf47770798 Fix the topology freezing and make team drags carry their challenges
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.
2026-09-27 01:17:56 +08:00

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();
})();