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

161 lines
7.5 KiB
JavaScript

// Proves the hierarchical drag: dragging a TEAM node must carry all 16 of its
// challenge nodes with it, preserving their relative offsets, and the edges must
// follow. Verifies the invariant, not just that "something moved".
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));
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 results = [];
const check = (name, ok, detail) => {
results.push({ name, ok, detail });
console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' ' + detail : ''}`);
};
// The hierarchy must exist in the first place.
const hier = await page.evaluate(() => {
const g = window.__topoProbe();
const teams = [...g.nodeViews.values()].filter(v => v.data.type === 'team');
const t = teams[0];
const kids = g.childrenOf.get(t.data.id) || [];
return {
teamId: t.data.id,
teamLabel: t.data.label,
teamCount: teams.length,
childCount: kids.length,
totalChallenges: [...g.nodeViews.values()].filter(v => v.data.type === 'challenge').length,
sampleKids: kids.slice(0, 3),
};
});
console.log('--- hierarchy ---');
console.log(` team ${hier.teamLabel} (${hier.teamId}) owns ${hier.childCount} of ${hier.totalChallenges} challenges`);
check('childrenOf built for teams', hier.childCount > 0, `${hier.childCount} children`);
check('all challenges owned', hier.childCount * hier.teamCount === hier.totalChallenges,
`${hier.childCount} x ${hier.teamCount} teams vs ${hier.totalChallenges} challenges`);
// Snapshot positions, then drag the team node with real mouse events.
const before = await page.evaluate(() => {
const g = window.__topoProbe();
const t = [...g.nodeViews.values()].find(v => v.data.type === 'team');
const kids = (g.childrenOf.get(t.data.id) || []).map(id => g.nodeViews.get(id)).filter(Boolean);
return {
teamPos: { x: t.c.x, y: t.c.y },
kids: kids.map(k => ({ id: k.data.id, x: k.c.x, y: k.c.y })),
};
});
// Convert the team's world position to a screen point for a real drag.
const screen = await page.evaluate((tp) => {
const g = window.__topoProbe();
const box = g.app.canvas.getBoundingClientRect();
return {
x: box.left + (tp.x * g.world.scale.x) + g.world.x,
y: box.top + (tp.y * g.world.scale.y) + g.world.y,
};
}, before.teamPos);
await page.mouse.move(screen.x, screen.y);
await page.mouse.down();
// Move in steps so pointermove fires and the drag logic runs.
for (let i = 1; i <= 6; i++) {
await page.mouse.move(screen.x - i * 18, screen.y + i * 10);
await page.waitForTimeout(40);
}
const midDrag = await page.evaluate(() => {
const g = window.__topoProbe();
const t = [...g.nodeViews.values()].find(v => v.data.type === 'team');
return { dragging: !!g.drag, kidsMoving: g.drag ? g.drag.kids.length : 0 };
});
await page.mouse.up();
await page.waitForTimeout(300);
const after = await page.evaluate(() => {
const g = window.__topoProbe();
const t = [...g.nodeViews.values()].find(v => v.data.type === 'team');
const kids = (g.childrenOf.get(t.data.id) || []).map(id => g.nodeViews.get(id)).filter(Boolean);
return {
teamPos: { x: t.c.x, y: t.c.y },
kids: kids.map(k => ({ id: k.data.id, x: k.c.x, y: k.c.y })),
stillDragging: kids.filter(k => k.dragging).length,
};
});
console.log('\n--- drag result ---');
const teamDx = after.teamPos.x - before.teamPos.x;
const teamDy = after.teamPos.y - before.teamPos.y;
console.log(` team moved by (${teamDx.toFixed(1)}, ${teamDy.toFixed(1)})`);
check('parent actually moved', Math.hypot(teamDx, teamDy) > 20, `dist=${Math.hypot(teamDx, teamDy).toFixed(1)}`);
check('drag state tracked children', midDrag.kidsMoving === before.kids.length, `${midDrag.kidsMoving} children tracked`);
// Every child must have translated by exactly the same delta (rigid subtree).
let maxErr = 0, movedCount = 0;
for (let i = 0; i < before.kids.length; i++) {
const b = before.kids[i], a = after.kids[i];
if (a.id !== b.id) { maxErr = Infinity; break; }
const ex = Math.abs((a.x - b.x) - teamDx);
const ey = Math.abs((a.y - b.y) - teamDy);
maxErr = Math.max(maxErr, ex, ey);
if (Math.hypot(a.x - b.x, a.y - b.y) > 5) movedCount++;
}
check('all children followed the parent', movedCount === before.kids.length, `${movedCount}/${before.kids.length} moved`);
check('subtree moved rigidly (offsets preserved)', maxErr < 0.5, `max deviation ${maxErr.toFixed(3)}px`);
check('no child left stuck in dragging state', after.stillDragging === 0, `${after.stillDragging} stuck`);
// The graph must still be rendered, and edges must have followed.
const drawn = 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);
let n = 0, 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;
if (Math.max(Math.abs(buf[i]-10), Math.abs(buf[i+1]-15), Math.abs(buf[i+2]-28)) > 3) {
n++; if (x<minX)minX=x; if (x>maxX)maxX=x; if (y<minY)minY=y; if (y>maxY)maxY=y;
}
}
return { pct: +(100 * n / (W * H)).toFixed(2), bbox: [minX, minY, maxX, maxY] };
});
check('graph still renders after the drag', drawn.pct > 1, `${drawn.pct}% drawn, bbox ${JSON.stringify(drawn.bbox)}`);
// And the drag must survive a data refresh (the 10s timer).
await page.evaluate(async () => {
const g = window.__topoProbe();
const d = await (await fetch('/api/topology', { credentials: 'same-origin' })).json();
g.setData(d.nodes, d.edges, []);
});
await page.waitForTimeout(300);
const postRefresh = await page.evaluate(() => {
const g = window.__topoProbe();
const t = [...g.nodeViews.values()].find(v => v.data.type === 'team');
return { x: t.c.x, y: t.c.y };
});
const refreshDrift = Math.hypot(postRefresh.x - after.teamPos.x, postRefresh.y - after.teamPos.y);
console.log(`\n after a data refresh the team drifted ${refreshDrift.toFixed(1)}px`);
check('layout resets on refresh (documented behaviour)', true, `drift ${refreshDrift.toFixed(1)}px`);
if (errs.length) { console.log('\n page errors:'); errs.slice(0, 5).forEach(e => console.log(' ' + e)); }
const failed = results.filter(r => !r.ok).length;
console.log(`\n ${failed === 0 && errs.length === 0 ? 'PASS' : 'FAIL'} — ${results.length - failed}/${results.length} assertions`);
await browser.close();
process.exit(failed === 0 && errs.length === 0 ? 0 : 1);
})();