Files
root 1461c874c3 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.
2026-09-27 00:31:47 +08:00

94 lines
3.5 KiB
Python

#!/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()