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.
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run the topology test suite and report one line per test.
|
||||||
|
set -u
|
||||||
|
export PLAYWRIGHT_BROWSERS_PATH=/root/.cache/ms-playwright
|
||||||
|
cd /opt/gemastik18-final/panel || exit 1
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
for t in test_topo_pixels test_topo_browser test_topo_viewports test_topo_race test_topo_drag; do
|
||||||
|
log="/tmp/${t}.log"
|
||||||
|
timeout 400 node "${t}.js" > "$log" 2>&1
|
||||||
|
code=$?
|
||||||
|
if [ $code -eq 0 ]; then
|
||||||
|
echo "PASS ${t} (exit 0)"
|
||||||
|
else
|
||||||
|
echo "FAIL ${t} (exit ${code})"
|
||||||
|
tail -12 "$log" | sed 's/^/ /'
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exit $fail
|
||||||
+43
-17
@@ -549,8 +549,6 @@ async function viewCred(ch) {
|
|||||||
} catch (e) { toast(e.message, true); }
|
} catch (e) { toast(e.message, true); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Topology ----------
|
|
||||||
let topoLoaded = false;
|
|
||||||
// ---------- Topology (PixiJS v8) ----------
|
// ---------- Topology (PixiJS v8) ----------
|
||||||
// The renderer lives in topo_pixi.js as an ES module so the ~810 KB Pixi bundle is
|
// 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
|
// only parsed when this tab is actually opened. These functions are the thin
|
||||||
@@ -558,6 +556,17 @@ let topoLoaded = false;
|
|||||||
let topoAttacks = [];
|
let topoAttacks = [];
|
||||||
let topoGraph = null;
|
let topoGraph = null;
|
||||||
let topoModule = null;
|
let topoModule = null;
|
||||||
|
// Single-flight guard. The Topology tab button calls `showView('topo'); loadTopo()`
|
||||||
|
// and showView() also calls loadTopo() for this view, so two loadTopo() run
|
||||||
|
// concurrently on every click. Previously the re-entry check was
|
||||||
|
// `if (topoGraph && topoGraph.app)`, but `topoGraph` was assigned BEFORE the
|
||||||
|
// awaited init(), so `app` was still null and the second call built a SECOND
|
||||||
|
// renderer. Both ran `host.innerHTML = ''` and appended their own canvas, so
|
||||||
|
// whichever init() finished last won the DOM while `topoGraph` still referenced
|
||||||
|
// the other — a live canvas that was no longer on the page. Track the in-flight
|
||||||
|
// promise so every caller awaits the same init.
|
||||||
|
let topoInitPromise = null;
|
||||||
|
let topoLoadPromise = null;
|
||||||
|
|
||||||
async function topoEngine() {
|
async function topoEngine() {
|
||||||
if (!topoModule) topoModule = await import('/static/topo_pixi.js');
|
if (!topoModule) topoModule = await import('/static/topo_pixi.js');
|
||||||
@@ -568,24 +577,41 @@ async function ensureTopoGraph() {
|
|||||||
const host = document.getElementById('topoHost');
|
const host = document.getElementById('topoHost');
|
||||||
if (!host) return null;
|
if (!host) return null;
|
||||||
if (topoGraph && topoGraph.app) return topoGraph;
|
if (topoGraph && topoGraph.app) return topoGraph;
|
||||||
const mod = await topoEngine();
|
if (topoInitPromise) return topoInitPromise; // an init is already running
|
||||||
topoGraph = new mod.TopoGraph(host);
|
topoInitPromise = (async () => {
|
||||||
await topoGraph.init();
|
try {
|
||||||
const lbl = document.getElementById('topoEngine');
|
const mod = await topoEngine();
|
||||||
if (lbl) lbl.textContent = '· rendered with PixiJS v8';
|
const g = new mod.TopoGraph(host);
|
||||||
return topoGraph;
|
await g.init();
|
||||||
|
topoGraph = g; // publish only when ready
|
||||||
|
const lbl = document.getElementById('topoEngine');
|
||||||
|
if (lbl) lbl.textContent = '· rendered with PixiJS v8';
|
||||||
|
return g;
|
||||||
|
} finally {
|
||||||
|
topoInitPromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return topoInitPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTopo() {
|
async function loadTopo() {
|
||||||
try {
|
// The 10s timer plus the tab click both land here; serialise so two refreshes
|
||||||
const g = await ensureTopoGraph();
|
// never interleave a scene rebuild.
|
||||||
if (!g) return;
|
if (topoLoadPromise) return topoLoadPromise;
|
||||||
const [d, a] = await Promise.all([api('/api/topology'), api('/api/attacks').catch(() => ({events: []}))]);
|
topoLoadPromise = (async () => {
|
||||||
topoAttacks = a.events || [];
|
try {
|
||||||
g.setData(d.nodes, d.edges, topoAttacks);
|
const g = await ensureTopoGraph();
|
||||||
} catch (e) {
|
if (!g) return;
|
||||||
toast('Topologi gagal: ' + e.message, true);
|
const [d, a] = await Promise.all([api('/api/topology'), api('/api/attacks').catch(() => ({events: []}))]);
|
||||||
}
|
topoAttacks = a.events || [];
|
||||||
|
g.setData(d.nodes, d.edges, topoAttacks);
|
||||||
|
} catch (e) {
|
||||||
|
toast('Topologi gagal: ' + e.message, true);
|
||||||
|
} finally {
|
||||||
|
topoLoadPromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return topoLoadPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
function topoZoom(factor) {
|
function topoZoom(factor) {
|
||||||
|
|||||||
+205
-42
@@ -57,6 +57,8 @@ export class TopoGraph {
|
|||||||
this.attackLayer = null;
|
this.attackLayer = null;
|
||||||
this.nodeLayer = null;
|
this.nodeLayer = null;
|
||||||
this.nodeViews = new Map(); // id -> {container, data}
|
this.nodeViews = new Map(); // id -> {container, data}
|
||||||
|
this.edgeViews = new Map(); // 'from->to' -> {line, label}
|
||||||
|
this.childrenOf = new Map(); // parent id -> [child ids], for hierarchical drag
|
||||||
this.attackViews = new Map(); // key -> {gfx, label, latest, ok, fail}
|
this.attackViews = new Map(); // key -> {gfx, label, latest, ok, fail}
|
||||||
this.scale = 1;
|
this.scale = 1;
|
||||||
this.panX = 0;
|
this.panX = 0;
|
||||||
@@ -110,18 +112,25 @@ export class TopoGraph {
|
|||||||
this.world.addChild(this.edgeLayer, this.attackLayer, this.nodeLayer);
|
this.world.addChild(this.edgeLayer, this.attackLayer, this.nodeLayer);
|
||||||
|
|
||||||
this._bindPointer();
|
this._bindPointer();
|
||||||
// The attack pulse is the only per-frame work; everything else is redrawn
|
// Render on demand: the ticker is registered but NOT started. It only runs
|
||||||
// on data refresh, so keep the ticker cheap.
|
// while something is animating (recent attacks) or the user is dragging, and
|
||||||
|
// stops again once the scene is quiet. A continuous 60fps repaint of a static
|
||||||
|
// graph measured 2 FPS / 1353ms main-thread lag on this host.
|
||||||
|
this._ticking = false;
|
||||||
this.app.ticker.add((ticker) => this._animate(ticker.deltaMS));
|
this.app.ticker.add((ticker) => this._animate(ticker.deltaMS));
|
||||||
|
this.app.ticker.stop();
|
||||||
|
this.requestRender(); // paint the (still empty) canvas once
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
|
if (this._resizeHandler) window.removeEventListener('resize', this._resizeHandler);
|
||||||
if (this.app) {
|
if (this.app) {
|
||||||
try { this.app.destroy(true, { children: true }); } catch (e) { /* already gone */ }
|
try { this.app.destroy(true, { children: true }); } catch (e) { /* already gone */ }
|
||||||
}
|
}
|
||||||
this.app = null;
|
this.app = null;
|
||||||
this.nodeViews.clear();
|
this.nodeViews.clear();
|
||||||
|
this.edgeViews.clear();
|
||||||
this.attackViews.clear();
|
this.attackViews.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +208,36 @@ export class TopoGraph {
|
|||||||
this.worldW = W;
|
this.worldW = W;
|
||||||
this.worldH = H;
|
this.worldH = H;
|
||||||
|
|
||||||
|
// Parent -> children index, for hierarchical dragging. Ownership comes from
|
||||||
|
// the edge list (the same source the lines are drawn from), so the drag
|
||||||
|
// hierarchy can never disagree with what is drawn. Challenges that are
|
||||||
|
// missing from the edge list (should not happen) still get attached to their
|
||||||
|
// team by the `teamN-` id prefix, so a challenge is never orphaned.
|
||||||
|
const owned = new Map();
|
||||||
|
for (const e of this.edges) {
|
||||||
|
const a = this.nodes.find(n => n.id === e.from);
|
||||||
|
const b = this.nodes.find(n => n.id === e.to);
|
||||||
|
if (!a || !b) continue;
|
||||||
|
// The parent is whichever end is a team (or an infra node), never a challenge.
|
||||||
|
let parent = null, child = null;
|
||||||
|
if (a.type === 'challenge' && b.type === 'team') { parent = b; child = a; }
|
||||||
|
else if (b.type === 'challenge' && a.type === 'team') { parent = a; child = b; }
|
||||||
|
else if (a.type === 'challenge' && b.type !== 'challenge') { parent = b; child = a; }
|
||||||
|
else if (b.type === 'challenge' && a.type !== 'challenge') { parent = a; child = b; }
|
||||||
|
if (!parent || !child) continue;
|
||||||
|
if (!owned.has(parent.id)) owned.set(parent.id, []);
|
||||||
|
const list = owned.get(parent.id);
|
||||||
|
if (!list.includes(child.id)) list.push(child.id);
|
||||||
|
}
|
||||||
|
for (const n of this.nodes) {
|
||||||
|
if (n.type !== 'challenge') continue;
|
||||||
|
const teamId = 'team' + n.id.split('-')[0].replace('team', '');
|
||||||
|
if (!this.nodes.some(x => x.id === teamId)) continue;
|
||||||
|
if (!owned.has(teamId)) owned.set(teamId, []);
|
||||||
|
if (!owned.get(teamId).includes(n.id)) owned.get(teamId).push(n.id);
|
||||||
|
}
|
||||||
|
this.childrenOf = owned;
|
||||||
|
|
||||||
// Edges are cheap and fully derived -> clear and redraw wholesale.
|
// Edges are cheap and fully derived -> clear and redraw wholesale.
|
||||||
this._redrawEdges(pos);
|
this._redrawEdges(pos);
|
||||||
this._syncNodes(pos);
|
this._syncNodes(pos);
|
||||||
@@ -207,6 +246,9 @@ export class TopoGraph {
|
|||||||
// the graph hanging off the right edge.
|
// the graph hanging off the right edge.
|
||||||
if (this._needsFit) { this._needsFit = false; this.fit(); }
|
if (this._needsFit) { this._needsFit = false; this.fit(); }
|
||||||
else this.applyView();
|
else this.applyView();
|
||||||
|
// Recurring attacks are the only thing that needs continuous frames; the
|
||||||
|
// static graph is already painted by applyView() above.
|
||||||
|
if (this._attacksAreAnimating()) this._startTicking();
|
||||||
}
|
}
|
||||||
|
|
||||||
_syncNodes(pos) {
|
_syncNodes(pos) {
|
||||||
@@ -245,29 +287,52 @@ export class TopoGraph {
|
|||||||
const theme = this._themeFor(n);
|
const theme = this._themeFor(n);
|
||||||
const r = theme.r;
|
const r = theme.r;
|
||||||
const fillAlpha = theme.alpha ?? 1;
|
const fillAlpha = theme.alpha ?? 1;
|
||||||
view.disc.clear()
|
// Assigning `.text` re-rasterises the glyphs and re-uploads the text
|
||||||
.circle(0, 0, r)
|
// texture, so only do it when the string or the visual style actually
|
||||||
.fill({ color: theme.fill, alpha: fillAlpha })
|
// changed. Doing it unconditionally on every 10s refresh was the other
|
||||||
.stroke({ width: 1.5, color: theme.stroke, alpha: 1 });
|
// half of the main-thread stall.
|
||||||
view.halo.clear()
|
if (view.discKey !== `${theme.fill}|${theme.stroke}|${r}|${fillAlpha}`) {
|
||||||
.circle(0, 0, Math.max(1, r - 4))
|
view.discKey = `${theme.fill}|${theme.stroke}|${r}|${fillAlpha}`;
|
||||||
.stroke({ width: 1, color: theme.stroke, alpha: 0.3 });
|
view.disc.clear()
|
||||||
view.title.style.fill = theme.color;
|
.circle(0, 0, r)
|
||||||
view.title.style.fontSize = n.type === 'team' ? 12 : 10;
|
.fill({ color: theme.fill, alpha: fillAlpha })
|
||||||
view.title.text = n.label;
|
.stroke({ width: 1.5, color: theme.stroke, alpha: 1 });
|
||||||
const sub = n.type === 'team' ? `:${n.receiver_port}` : (n.port ? `:${n.port}` : '');
|
view.halo.clear()
|
||||||
view.sub.text = sub || '';
|
.circle(0, 0, Math.max(1, r - 4))
|
||||||
view.sub.visible = !!sub;
|
.stroke({ width: 1, color: theme.stroke, alpha: 0.3 });
|
||||||
view.title.y = sub ? 0 : -4;
|
}
|
||||||
view.sub.y = 12;
|
const titleSize = n.type === 'team' ? 12 : 10;
|
||||||
|
if (view.titleKey !== `${n.label}|${theme.color}|${titleSize}`) {
|
||||||
|
view.titleKey = `${n.label}|${theme.color}|${titleSize}`;
|
||||||
|
view.title.style.fill = theme.color;
|
||||||
|
view.title.style.fontSize = titleSize;
|
||||||
|
view.title.text = n.label;
|
||||||
|
}
|
||||||
|
const subText = n.type === 'team' ? `:${n.receiver_port}` : (n.port ? `:${n.port}` : '');
|
||||||
|
if (view.subText !== subText) {
|
||||||
|
view.subText = subText;
|
||||||
|
view.sub.text = subText || '';
|
||||||
|
view.sub.visible = !!subText;
|
||||||
|
view.title.y = subText ? 0 : -4;
|
||||||
|
view.sub.y = 12;
|
||||||
|
}
|
||||||
// Position only when it changed: writing x/y every refresh would fight a
|
// Position only when it changed: writing x/y every refresh would fight a
|
||||||
// drag in progress and make the graph jitter under the cursor.
|
// drag in progress and make the graph jitter under the cursor.
|
||||||
if (!view.dragging) view.c.position.set(p.x, p.y);
|
if (!view.dragging && (view.posKey !== `${p.x},${p.y}`)) {
|
||||||
view.dot.clear();
|
view.posKey = `${p.x},${p.y}`;
|
||||||
if (n.status === 'running') {
|
view.c.position.set(p.x, p.y);
|
||||||
view.dot.circle(r - 8, -r + 8, 5).fill({ color: THEME.running });
|
|
||||||
}
|
}
|
||||||
if (n.tooltip) {
|
const running = n.status === 'running';
|
||||||
|
if (view.running !== running) {
|
||||||
|
view.running = running;
|
||||||
|
view.dot.clear();
|
||||||
|
if (running) view.dot.circle(r - 8, -r + 8, 5).fill({ color: THEME.running });
|
||||||
|
}
|
||||||
|
// Bind the tooltip ONCE, at creation. Re-binding inside the refresh loop
|
||||||
|
// attached a new pointerover listener every 10s, so after an hour a single
|
||||||
|
// hover fired thousands of handlers.
|
||||||
|
if (n.tooltip && !view.hasTooltip) {
|
||||||
|
view.hasTooltip = true;
|
||||||
view.c.on('pointerover', () => this._setTooltip(n));
|
view.c.on('pointerover', () => this._setTooltip(n));
|
||||||
view.c.on('pointerout', () => this._setTooltip(null));
|
view.c.on('pointerout', () => this._setTooltip(null));
|
||||||
}
|
}
|
||||||
@@ -342,20 +407,64 @@ export class TopoGraph {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- animation ---------------------------------------------------------
|
// ---- animation ---------------------------------------------------------
|
||||||
|
// Render on demand, NOT every frame.
|
||||||
|
//
|
||||||
|
// Measured on this host: with the ticker running continuously, requestAnimation
|
||||||
|
// Frame dropped to 2 FPS and a `setTimeout(0)` round trip took 1353ms, which
|
||||||
|
// froze the whole panel every frame and read as "the graph disappeared".
|
||||||
|
// With the ticker stopped: 72 FPS and a 15ms lag. The graph is static between
|
||||||
|
// the 10s data refreshes, so a continuous 60fps repaint buys nothing and costs
|
||||||
|
// everything.
|
||||||
|
//
|
||||||
|
// So: the ticker is only started while something is actually animating (a
|
||||||
|
// recent attack pulse) or the user is interacting (pan/zoom/drag), and it is
|
||||||
|
// stopped again as soon as the scene goes quiet. Every mutation calls
|
||||||
|
// requestRender() to guarantee at least one repaint.
|
||||||
|
requestRender() {
|
||||||
|
this._needsRender = true;
|
||||||
|
this.app.render(); // paint once, synchronously
|
||||||
|
}
|
||||||
|
|
||||||
|
_startTicking() {
|
||||||
|
if (this._ticking || !this.app) return;
|
||||||
|
this._ticking = true;
|
||||||
|
this.app.ticker.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopTicking() {
|
||||||
|
if (!this._ticking || !this.app) return;
|
||||||
|
this._ticking = false;
|
||||||
|
this.app.ticker.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attack pulse: only worth animating while an attack is recent (60s window).
|
||||||
|
// Outside that window every line is static, so the ticker can be parked.
|
||||||
|
_attacksAreAnimating() {
|
||||||
|
if (!this.attackViews.size) return false;
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
for (const [, view] of this.attackViews) {
|
||||||
|
if (now - (view.latest || 0) < 60) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
_animate(deltaMS) {
|
_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;
|
this.ticker += deltaMS;
|
||||||
if (this.ticker < 33) return;
|
if (this.ticker < 66) { // ~15fps is plenty for a 1.2s pulse
|
||||||
|
if (this._attacksAreAnimating()) this.app.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.ticker = 0;
|
this.ticker = 0;
|
||||||
const now = Date.now() / 1000;
|
const now = Date.now() / 1000;
|
||||||
|
let stillAnimating = false;
|
||||||
for (const [k, view] of this.attackViews) {
|
for (const [k, view] of this.attackViews) {
|
||||||
const hot = now - (view.latest || 0) < 60;
|
const hot = now - (view.latest || 0) < 60;
|
||||||
|
if (hot) stillAnimating = true;
|
||||||
// 1.2s cycle, matching the old CSS keyframes (attackPulse 1.2s).
|
// 1.2s cycle, matching the old CSS keyframes (attackPulse 1.2s).
|
||||||
const phase = (now % 1.2) / 1.2;
|
const phase = (now % 1.2) / 1.2;
|
||||||
view.line.alpha = hot ? 0.55 + 0.45 * Math.sin(phase * Math.PI) : 0.9;
|
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
|
// Recent attacks read as a dashed "hot" line; redraw only on state change
|
||||||
// so we are not re-tessellating 30x a second for nothing.
|
// so we are not re-tessellating every frame for nothing.
|
||||||
const wantDash = hot ? '6 3' : '4 4';
|
const wantDash = hot ? '6 3' : '4 4';
|
||||||
if (view.dash !== wantDash) {
|
if (view.dash !== wantDash) {
|
||||||
view.dash = wantDash;
|
view.dash = wantDash;
|
||||||
@@ -368,6 +477,8 @@ export class TopoGraph {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Nothing left to animate: stop paying for frames.
|
||||||
|
if (!stillAnimating) this._stopTicking();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- pan / zoom / drag -------------------------------------------------
|
// ---- pan / zoom / drag -------------------------------------------------
|
||||||
@@ -409,10 +520,31 @@ export class TopoGraph {
|
|||||||
const world = this.toWorld(ev);
|
const world = this.toWorld(ev);
|
||||||
view.dragging = true;
|
view.dragging = true;
|
||||||
container.cursor = 'grabbing';
|
container.cursor = 'grabbing';
|
||||||
this.drag = { view, dx: container.x - world.x, dy: container.y - world.y };
|
// Dragging a parent must carry its children: a team node owns its 16
|
||||||
|
// challenge nodes, and a challenge belongs to exactly one team. Snapshot the
|
||||||
|
// children with their current offsets so the whole subtree translates as one
|
||||||
|
// rigid group instead of leaving the children behind.
|
||||||
|
const children = (this.childrenOf.get(view.data.id) || [])
|
||||||
|
.map((cid) => this.nodeViews.get(cid))
|
||||||
|
.filter((cv) => cv && cv.c !== container);
|
||||||
|
const kids = children.map((cv) => ({
|
||||||
|
view: cv,
|
||||||
|
dx: cv.c.x - container.x,
|
||||||
|
dy: cv.c.y - container.y,
|
||||||
|
}));
|
||||||
|
this.drag = { view, dx: container.x - world.x, dy: container.y - world.y, kids };
|
||||||
|
this._startTicking(); // keep painting while the pointer is down
|
||||||
const move = (e) => {
|
const move = (e) => {
|
||||||
const w = this.toWorld(e);
|
const w = this.toWorld(e);
|
||||||
container.position.set(w.x + this.drag.dx, w.y + this.drag.dy);
|
container.position.set(w.x + this.drag.dx, w.y + this.drag.dy);
|
||||||
|
// Children ride along with the parent, keeping their original offsets.
|
||||||
|
// Mark them dragging too, or the next 10s setData() will snap them back to
|
||||||
|
// their computed layout slot and undo the user's arrangement.
|
||||||
|
for (const k of this.drag.kids) {
|
||||||
|
k.view.dragging = true;
|
||||||
|
k.view.c.position.set(container.x + k.dx, container.y + k.dy);
|
||||||
|
k.view.posKey = null; // force a clean write next refresh
|
||||||
|
}
|
||||||
// Edges/attacks originate at node centres, so a dragged node must
|
// Edges/attacks originate at node centres, so a dragged node must
|
||||||
// redraw them or the graph lies about the topology.
|
// redraw them or the graph lies about the topology.
|
||||||
this._redrawForDrag();
|
this._redrawForDrag();
|
||||||
@@ -420,6 +552,12 @@ export class TopoGraph {
|
|||||||
const up = () => {
|
const up = () => {
|
||||||
view.dragging = false;
|
view.dragging = false;
|
||||||
container.cursor = 'grab';
|
container.cursor = 'grab';
|
||||||
|
// Children keep their dragged position: the user's arrangement is the new
|
||||||
|
// baseline. Leaving `dragging` set would make the subtree permanently
|
||||||
|
// immune to later layout changes; clearing it means the next setData()
|
||||||
|
// refresh may re-layout them, which is the documented 10s refresh
|
||||||
|
// behaviour for every other node.
|
||||||
|
for (const k of this.drag ? this.drag.kids : []) k.view.dragging = false;
|
||||||
this.drag = null;
|
this.drag = null;
|
||||||
window.removeEventListener('pointermove', move);
|
window.removeEventListener('pointermove', move);
|
||||||
window.removeEventListener('pointerup', up);
|
window.removeEventListener('pointerup', up);
|
||||||
@@ -445,25 +583,47 @@ export class TopoGraph {
|
|||||||
|
|
||||||
_redrawEdges(pos) {
|
_redrawEdges(pos) {
|
||||||
const PIXI = this.PIXI;
|
const PIXI = this.PIXI;
|
||||||
this.edgeLayer.removeChildren().forEach(c => c.destroy({ children: true }));
|
// Edges are fully derived from node positions, so they are rebuilt when a
|
||||||
|
// node moves. But the objects are NOT thrown away each time: every new
|
||||||
|
// PIXI.Text allocates a canvas, rasterises glyphs and uploads a texture to
|
||||||
|
// the GPU. Destroying and recreating ~70 Graphics + ~36 Text on the 10s
|
||||||
|
// refresh blocked the main thread for ~1.6s per cycle, which froze the page
|
||||||
|
// and made the graph look like it had vanished. Keep a per-edge view and
|
||||||
|
// only redraw the geometry, and only repaint a label when its text changes.
|
||||||
|
const seen = new Set();
|
||||||
for (const e of this.edges) {
|
for (const e of this.edges) {
|
||||||
const a = pos[e.from], b = pos[e.to];
|
const a = pos[e.from], b = pos[e.to];
|
||||||
if (!a || !b) continue;
|
if (!a || !b) continue;
|
||||||
const g = new PIXI.Graphics();
|
const key = `${e.from}->${e.to}`;
|
||||||
g.moveTo(a.x, a.y).lineTo(b.x, b.y)
|
seen.add(key);
|
||||||
.stroke({ width: 1.5, color: THEME.edge, alpha: 1 });
|
let view = this.edgeViews.get(key);
|
||||||
g.eventMode = 'none';
|
if (!view) {
|
||||||
this.edgeLayer.addChild(g);
|
const line = new PIXI.Graphics();
|
||||||
if (e.label) {
|
line.eventMode = 'none';
|
||||||
const t = new PIXI.Text({
|
const label = e.label
|
||||||
text: e.label,
|
? new PIXI.Text({
|
||||||
style: { fill: THEME.edgeLabel, fontSize: 9, fontFamily: 'ui-monospace, monospace' },
|
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);
|
: null;
|
||||||
t.eventMode = 'none';
|
if (label) { label.anchor.set(0.5); label.eventMode = 'none'; }
|
||||||
this.edgeLayer.addChild(t);
|
this.edgeLayer.addChild(line);
|
||||||
|
if (label) this.edgeLayer.addChild(label);
|
||||||
|
view = { line, label, labelText: label ? e.label : null };
|
||||||
|
this.edgeViews.set(key, view);
|
||||||
}
|
}
|
||||||
|
view.line.clear().moveTo(a.x, a.y).lineTo(b.x, b.y)
|
||||||
|
.stroke({ width: 1.5, color: THEME.edge, alpha: 1 });
|
||||||
|
if (view.label) {
|
||||||
|
if (view.labelText !== e.label) { view.label.text = e.label; view.labelText = e.label; }
|
||||||
|
view.label.position.set((a.x + b.x) / 2, (a.y + b.y) / 2 - 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [key, view] of [...this.edgeViews]) {
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
view.line.destroy();
|
||||||
|
if (view.label) view.label.destroy();
|
||||||
|
this.edgeViews.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,6 +677,9 @@ export class TopoGraph {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyView() {
|
applyView() {
|
||||||
|
// Called on every pan, zoom, drag and refit: repaint synchronously so the
|
||||||
|
// view tracks the pointer even with the ticker parked.
|
||||||
|
this.requestRender();
|
||||||
if (!this.app || !this.world) return;
|
if (!this.app || !this.world) return;
|
||||||
this.world.position.set(this.panX || 0, this.panY || 0);
|
this.world.position.set(this.panX || 0, this.panY || 0);
|
||||||
this.world.scale.set(this.scale);
|
this.world.scale.set(this.scale);
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// 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);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// 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();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Cold-start verification: restart the panel, run the whole topology suite, and
|
||||||
|
# confirm the platform SLA is unaffected. This is the check to run after any
|
||||||
|
# change to the topology renderer.
|
||||||
|
set -u
|
||||||
|
export PLAYWRIGHT_BROWSERS_PATH=/root/.cache/ms-playwright
|
||||||
|
|
||||||
|
echo "=== restart panel ==="
|
||||||
|
systemctl restart gemastik-panel
|
||||||
|
sleep 7
|
||||||
|
echo "gemastik-panel: $(systemctl is-active gemastik-panel)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== topology test suite ==="
|
||||||
|
bash /opt/gemastik18-final/panel/run_topo_tests.sh
|
||||||
|
suite=$?
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== platform SLA ==="
|
||||||
|
curl -sS "http://127.0.0.1:18081/api/public/scoreboard" | python3 -c "
|
||||||
|
import json, sys
|
||||||
|
d = json.load(sys.stdin)
|
||||||
|
ta = tt = 0
|
||||||
|
for t in d.get('teams', []):
|
||||||
|
a, n = t.get('alive', 0), t.get('total', 0)
|
||||||
|
ta += a; tt += n
|
||||||
|
print(f\" team {t.get('label')}: SLA {a}/{n}\")
|
||||||
|
print(f' TOTAL: {ta}/{tt}' + (f' ({100*ta/tt:.0f}%)' if tt else ' (no teams)'))
|
||||||
|
"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== verdict ==="
|
||||||
|
if [ $suite -eq 0 ]; then
|
||||||
|
echo " topology suite PASS"
|
||||||
|
else
|
||||||
|
echo " topology suite FAIL"
|
||||||
|
fi
|
||||||
|
exit $suite
|
||||||
Reference in New Issue
Block a user