diff --git a/panel/main.py b/panel/main.py index d35dec3..961a074 100644 --- a/panel/main.py +++ b/panel/main.py @@ -28,6 +28,13 @@ BASE_DIR = Path(__file__).parent app = FastAPI(title="Gemastik A/D Panel") +# The panel used to serve every page as an inline HTMLResponse, so static/ was +# never mounted. The PixiJS topology needs real asset URLs: topo_pixi.js is an ES +# module and vendor/pixi.mjs is an 810 KB bundle โ€” neither can be inlined. +# Mounted at the root so the module's own `import './vendor/pixi.mjs'` and +# `import('./topo_pixi.js')` resolve without rewriting paths. +app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static") + @app.on_event("startup") async def _start_background(): diff --git a/panel/static/index.html b/panel/static/index.html index 4ef3b67..523d8ff 100644 --- a/panel/static/index.html +++ b/panel/static/index.html @@ -100,7 +100,10 @@ .spin { display:inline-block; width:12px; height:12px; border:2px solid #2a4a6f; border-top-color:#5ad1ff; border-radius:50%; animation:sp .7s linear infinite; } @keyframes sp { to { transform:rotate(360deg); } } .topo-wrap { background:#0a0f1c; border:1px solid #1e3a5f; border-radius:12px; padding:20px; overflow-x:auto; } - .topo-svg { width:100%; min-width:800px; } + /* Now a DIV host for the PixiJS canvas (was an inline ). It needs an + explicit height: the Pixi Application is created with resizeTo:this element + and a zero-height box would size the renderer to nothing. */ + .topo-svg { width:100%; min-width:800px; height:520px; } /* attack visualizer: recent attacks pulse */ @keyframes attackPulse { 0%,100% { stroke-opacity:0.4; } 50% { stroke-opacity:1; } } @keyframes attackBlink { 0%,100% { opacity:1; } 50% { opacity:0.35; } } @@ -151,8 +154,8 @@ 100% -
-
๐Ÿ’ก Geser node untuk atur layout โ€ข scroll / ctrl+scroll untuk zoom in-out โ€ข drag area kosong untuk geser canvas โ€ข โš”๏ธ garis merah = serangan (panah attacker โ†’ target) โ€ข garis putus-putus = serangan baru (60 detik terakhir)
+
+
๐Ÿ’ก Geser node untuk atur layout โ€ข scroll / ctrl+scroll untuk zoom in-out โ€ข drag area kosong untuk geser canvas โ€ข โš”๏ธ garis merah = serangan (attacker โ†’ target) โ€ข garis tebal = serangan 60 detik terakhir  
@@ -548,194 +551,53 @@ async function viewCred(ch) { // ---------- Topology ---------- let topoLoaded = false; +// ---------- Topology (PixiJS v8) ---------- +// 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 +// bridge the tab buttons and the 10s refresh timer already call. let topoAttacks = []; +let topoGraph = null; +let topoModule = null; + +async function topoEngine() { + if (!topoModule) topoModule = await import('/static/topo_pixi.js'); + return topoModule; +} + +async function ensureTopoGraph() { + const host = document.getElementById('topoHost'); + if (!host) return null; + if (topoGraph && topoGraph.app) return topoGraph; + const mod = await topoEngine(); + topoGraph = new mod.TopoGraph(host); + await topoGraph.init(); + const lbl = document.getElementById('topoEngine'); + if (lbl) lbl.textContent = 'ยท rendered with PixiJS v8'; + return topoGraph; +} + async function loadTopo() { try { + const g = await ensureTopoGraph(); + if (!g) return; const [d, a] = await Promise.all([api('/api/topology'), api('/api/attacks').catch(() => ({events: []}))]); topoAttacks = a.events || []; - renderTopo(d.nodes, d.edges); - } catch (e) { toast('Topologi gagal: ' + e.message, true); } -} - -function renderTopo(nodes, edges) { - const svg = document.getElementById('topoSvg'); - const W = Math.max(900, nodes.length * 130); - const H = 500; - svg.setAttribute('width', W); svg.setAttribute('height', H); - const cx = W / 2; - const ns = {}; - nodes.forEach(n => { ns[n.id] = n; }); - - // layout: panel/infra top center, teams in circle, challenges below each team - const pos = {}; - pos['dns'] = {x: cx, y: 30}; - pos['traefik'] = {x: cx, y: 100}; - pos['panel'] = {x: cx - 140, y: 100}; - const teams = nodes.filter(n => n.type === 'team'); - const teamPos = {}; - teams.forEach((t, i) => { - const ang = (i / Math.max(1, teams.length)) * Math.PI * 2 - Math.PI / 2; - const rx = W * 0.36, ry = 100; - const tx = cx + rx * Math.cos(ang); - const ty = 250 + ry * Math.sin(ang) * 0.6; - teamPos[t.index] = {x: tx, y: ty}; - pos[t.id] = {x: tx, y: ty}; - }); - nodes.filter(n => n.type === 'challenge').forEach(n => { - const ti = n.id.split('-')[0].replace('team',''); - const base = teamPos[ti] || {x: cx, y: 300}; - const chIdx = ['blogpost','carbeat','cdn','phew','sheesh','warmup'].indexOf(n.label.split('-').pop() || n.label); - pos[n.id] = {x: base.x + (chIdx - 2.5) * 70, y: base.y + 130}; - }); - - // ---- attack arcs (attacker team -> target team), recent only (last 10 min) ---- - const now = Date.now() / 1000; - const recentAttacks = topoAttacks.filter(e => now - (e.ts || 0) < 600); - const attackCounts = {}; // "attacker:target" -> {ok, fail, latest} - recentAttacks.forEach(e => { - const k = `${e.attacker}:${e.target}`; - attackCounts[k] = attackCounts[k] || {ok: 0, fail: 0, latest: e.ts}; - attackCounts[k][e.success ? 'ok' : 'fail']++; - attackCounts[k].latest = Math.max(attackCounts[k].latest, e.ts || 0); - }); - - // edges - let html = ''; - edges.forEach(e => { - const a = pos[e.from], b = pos[e.to]; - if (!a || !b) return; - html += ``; - if (e.label) { - const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2 - 6; - html += `${esc(e.label)}`; - } - }); - - // ---- attack visualizer ---- - for (const [k, c] of Object.entries(attackCounts)) { - const [atk, tgt] = k.split(':'); - const a = pos['team' + atk], b = pos['team' + tgt]; - if (!a || !b || atk === tgt) continue; - const isHot = now - c.latest < 60; - const color = c.ok > 0 ? '#f87171' : '#fb923c'; - const dash = isHot ? '6 3' : '4 4'; - html += ``; - const mx = (a.x + b.x) / 2 + 8, my = (a.y + b.y) / 2 - 10; - const icon = isHot ? 'โš”๏ธ' : 'โš”'; - html += `${icon} ${c.ok}โœ“ ${c.fail}โœ—`; + g.setData(d.nodes, d.edges, topoAttacks); + } catch (e) { + toast('Topologi gagal: ' + e.message, true); } - - // nodes - nodes.forEach(n => { - const p = pos[n.id]; - if (!p) return; - let fill = '#0e1526', stroke = '#2a4a6f', color = '#a8c3e0', r = 34; - if (n.type === 'panel') { fill = '#0ea5e933'; stroke = '#0ea5e9'; color = '#7dd3fc'; r = 42; } - if (n.type === 'infra') { r = 30; color = '#8aa0b8'; } - if (n.type === 'team') { fill = '#2563eb22'; stroke = '#3b82f6'; color = '#93c5fd'; r = 48; } - if (n.type === 'challenge') { r = 26; color = '#c9d4e3'; } - const status = n.status === 'running' ? ' fill="#34d399"' : ''; - const sub = n.type === 'team' ? `:${n.receiver_port}` : (n.port ? `:${n.port}` : ''); - html += ` - - - ${esc(n.label)} - ${sub ? `${sub}` : ''} - ${status ? `` : ''} - `; - }); - svg.innerHTML = `${html}`; - enableTopoDrag(svg, pos); - applyTopoView(); -} - -// ---- draggable topology + zoom/pan ---- -let topoScale = 1; -let topoPanX = 0, topoPanY = 0; -function enableTopoDrag(svg, pos) { - const gMain = svg.querySelector('g[data-root]') || svg; - let dragEl = null, dx = 0, dy = 0; - let panning = false, px = 0, py = 0; - - const toLocal = (ev) => { - const ctm = svg.getScreenCTM(); - if (!ctm) return {x: 0, y: 0}; - const pt = svg.createSVGPoint(); - pt.x = ev.clientX; pt.y = ev.clientY; - return pt.matrixTransform(ctm.inverse()); - }; - - const onMove = (ev) => { - if (dragEl) { - const p = toLocal(ev); - dragEl.setAttribute('transform', `translate(${p.x - dx}, ${p.y - dy})`); - } else if (panning) { - topoPanX += ev.clientX - px; - topoPanY += ev.clientY - py; - px = ev.clientX; py = ev.clientY; - applyTopoView(); - } - }; - const onUp = () => { dragEl = null; panning = false; svg.style.cursor = ''; }; - - svg.addEventListener('mousedown', (ev) => { - const g = ev.target.closest('g[data-node]'); - if (g) { - ev.preventDefault(); - const p = toLocal(ev); - const c = g.querySelector('circle'); - dx = p.x - parseFloat(c.getAttribute('cx')); - dy = p.y - parseFloat(c.getAttribute('cy')); - dragEl = g; - svg.style.cursor = 'grabbing'; - g.setPointerCapture && g.setPointerCapture(ev.pointerId); - } else { - // empty area -> pan the canvas - panning = true; - px = ev.clientX; py = ev.clientY; - svg.style.cursor = 'move'; - } - }); - svg.addEventListener('pointermove', onMove); - svg.addEventListener('pointerup', onUp); - svg.addEventListener('pointercancel', onUp); - - // wheel zoom (ctrl+wheel or plain wheel on empty area) - svg.addEventListener('wheel', (ev) => { - ev.preventDefault(); - const factor = ev.deltaY < 0 ? 1.12 : 1 / 1.12; - const ns = Math.min(3, Math.max(0.3, topoScale * factor)); - // zoom around cursor - const rect = svg.getBoundingClientRect(); - const mx = ev.clientX - rect.left, my = ev.clientY - rect.top; - const W = svg.clientWidth, H = svg.clientHeight; - topoPanX = mx - (mx - topoPanX) * (ns / topoScale); - topoPanY = my - (my - topoPanY) * (ns / topoScale); - topoScale = ns; - applyTopoView(); - }, {passive: false}); -} - -function applyTopoView() { - const svg = document.getElementById('topoSvg'); - const g = svg.querySelector('g[data-root]'); - if (!g) return; - g.setAttribute('transform', `translate(${topoPanX}, ${topoPanY}) scale(${topoScale})`); - // update hint + zoom % label - const zl = document.getElementById('topoZoomLabel'); - if (zl) zl.textContent = Math.round(topoScale * 100) + '%'; } function topoZoom(factor) { - const svg = document.getElementById('topoSvg'); - const rect = svg.getBoundingClientRect(); - const ns = Math.min(3, Math.max(0.3, topoScale * factor)); - topoPanX = rect.width / 2 - (rect.width / 2 - topoPanX) * (ns / topoScale); - topoPanY = rect.height / 2 - (rect.height / 2 - topoPanY) * (ns / topoScale); - topoScale = ns; - applyTopoView(); + if (topoGraph && topoGraph.app) topoGraph.zoomBy(factor); } -function topoReset() { topoScale = 1; topoPanX = 0; topoPanY = 0; applyTopoView(); } +function topoReset() { + if (topoGraph && topoGraph.app) topoGraph.reset(); +} +// Debug/automation hook: the inline script's top-level `let topoGraph` is not a +// window property, so headless tests and devtools have no way to inspect the +// live scene graph. Expose the instance deliberately. +window.__topoProbe = () => topoGraph; // ---------- Teams ---------- let TEAM_LABELS = {}; // idx -> label, filled by loadTeams (for confirm dialogs) diff --git a/panel/static/topo_pixi.js b/panel/static/topo_pixi.js new file mode 100644 index 0000000..3ce7f92 --- /dev/null +++ b/panel/static/topo_pixi.js @@ -0,0 +1,527 @@ +// --------------------------------------------------------------------------- +// Topology graph renderer โ€” PixiJS v8 (WebGL/WebGPU). +// +// Replaces the hand-rolled inline-SVG topology. Why Pixi: +// * 37 nodes / 36 edges redrawn every 10s as one innerHTML string blew away +// and re-created every DOM node, so CSS animations (attack pulse) restarted +// and dragging fought the browser's own hit-testing. +// * The scene graph gives per-node transforms for free, so pan/zoom is a +// single container transform instead of getScreenCTM() matrix math. +// +// v8 API notes (verified against pixijs.com/8.x docs, NOT v7 memory): +// * `new Application()` then `await app.init({...})` โ€” init is ASYNC. +// * `app.canvas` is the HTMLCanvasElement (v7 was `app.view`). +// * Graphics is a builder: `.circle(x,y,r).fill({color}).stroke({width,color})`. +// The v7 `.beginFill().drawCircle().endFill()` chain is gone. +// * `Text` uses `style: { fill, fontSize, fontFamily, align }`; v7 took flat +// ctor args like `new Text(txt, style, canvas)`, which no longer applies. +// +// The module is self-contained: it owns its Application, and `destroy()` tears +// it down so re-entering the tab cannot leak a second WebGL context. +// --------------------------------------------------------------------------- + +let PIXI = null; + +export async function loadPixi() { + if (PIXI) return PIXI; + // Dynamic import: the topology tab may never be opened, and an 810 KB parse + // on every panel load is pure waste. + PIXI = await import('/static/vendor/pixi.mjs'); + return PIXI; +} + +const THEME = { + bg: 0x0a0f1c, + edge: 0x2a4a6f, + edgeLabel: 0x5a7a9f, + node: { fill: 0x0e1526, stroke: 0x2a4a6f, color: 0xa8c3e0, r: 34 }, + panel: { fill: 0x0ea5e9, alpha: 0.20, stroke: 0x0ea5e9, color: 0x7dd3fc, r: 42 }, + infra: { fill: 0x0e1526, stroke: 0x2a4a6f, color: 0x8aa0b8, r: 30 }, + team: { fill: 0x2563eb, alpha: 0.13, stroke: 0x3b82f6, color: 0x93c5fd, r: 48 }, + challenge: { fill: 0x0e1526, stroke: 0x2a4a6f, color: 0xc9d4e3, r: 26 }, + running: 0x34d399, + attackOk: 0xf87171, + attackFail: 0xfb923c, +}; + +// Challenge layout order โ€” must match the old SVG code, which positioned the +// 6 native GEMASTIK XVIII challenges left-to-right under their team. +const NATIVE_ORDER = ['blogpost', 'carbeat', 'cdn', 'phew', 'sheesh', 'warmup']; + +export class TopoGraph { + constructor(host) { + this.host = host; + this.app = null; + this.world = null; // pan/zoom container + this.edgeLayer = null; + this.attackLayer = null; + this.nodeLayer = null; + this.nodeViews = new Map(); // id -> {container, data} + this.attackViews = new Map(); // key -> {gfx, label, latest, ok, fail} + this.scale = 1; + this.panX = 0; + this.panY = 0; + this.nodes = []; + this.edges = []; + this.attacks = []; + this.drag = null; + this.ticker = 0; + } + + async init() { + const PIXI = await loadPixi(); + this.PIXI = PIXI; + this.app = new PIXI.Application(); + await this.app.init({ + resizeTo: this.host, + background: THEME.bg, + antialias: true, + autoDensity: true, + resolution: Math.min(2, window.devicePixelRatio || 1), + preference: 'webgl', + // WebGL clears its back buffer after compositing unless this is set, so a + // readback/screenshot of the canvas is a coin flip that depends on which + // frame the snapshot lands on. That made the render look intermittently + // blank. Cheap here (a 2D scene, no shaders) and it makes the view + // capturable for tests, screenshots and "save image" affordances. + preserveDrawingBuffer: true, + }); + this.app.canvas.style.display = 'block'; + this.app.canvas.style.width = '100%'; + this.app.canvas.style.height = '520px'; + this.app.canvas.style.touchAction = 'none'; + this.host.innerHTML = ''; + this.host.appendChild(this.app.canvas); + + this.world = new PIXI.Container(); + this.app.stage.addChild(this.world); + // Render groups: pan/zoom touches ONE transform instead of every child. + this.world.enableRenderGroup(); + // First setData() must frame the graph instead of rendering at scale=1. + this._needsFit = true; + // `resizeTo: this.host` resizes the renderer but not our view transform, so + // a narrower window would clip the graph. Re-frame instead. + this._resizeHandler = () => this._onResize(); + window.addEventListener('resize', this._resizeHandler); + + this.edgeLayer = new PIXI.Container(); + this.attackLayer = new PIXI.Container(); + this.nodeLayer = new PIXI.Container(); + this.world.addChild(this.edgeLayer, this.attackLayer, this.nodeLayer); + + this._bindPointer(); + // The attack pulse is the only per-frame work; everything else is redrawn + // on data refresh, so keep the ticker cheap. + this.app.ticker.add((ticker) => this._animate(ticker.deltaMS)); + return this; + } + + destroy() { + if (this.app) { + try { this.app.destroy(true, { children: true }); } catch (e) { /* already gone */ } + } + this.app = null; + this.nodeViews.clear(); + this.attackViews.clear(); + } + + // ---- layout ------------------------------------------------------------- + layout(nodes) { + // The world is wider than the canvas on purpose (the old SVG scrolled + // horizontally), but the graph must still START inside the viewport: the + // team ring was centred on W/2, so with 37 nodes W grew to ~4810 and every + // team/challenge node landed far off the right edge of a ~1300px canvas. + // Centre the layout on the VISIBLE width, then let it grow to the right for + // overflow, and let reset()/applyView() fit it back on screen. + const visW = Math.max(320, (this.app && this.app.renderer ? this.app.renderer.width / this.app.renderer.resolution : 900)); + const W = Math.max(visW, 900, nodes.length * 60); + const H = 500; + // Centre the ring on the viewport, not on the scrollable width. + const cx = visW / 2; + const pos = {}; + pos.dns = { x: cx, y: 30 }; + pos.traefik = { x: cx, y: 100 }; + pos.panel = { x: cx - 140, y: 100 }; + const teams = nodes.filter(n => n.type === 'team'); + const teamPos = {}; + teams.forEach((t, i) => { + const ang = (i / Math.max(1, teams.length)) * Math.PI * 2 - Math.PI / 2; + // Radius must fit the viewport too, or the outermost team is off-screen. + const rx = Math.min(visW * 0.36, 420); + const ry = 100; + const tx = cx + rx * Math.cos(ang); + const ty = 250 + ry * Math.sin(ang) * 0.6; + teamPos[t.index] = { x: tx, y: ty }; + pos[t.id] = { x: tx, y: ty }; + }); + nodes.filter(n => n.type === 'challenge').forEach(n => { + const ti = n.id.split('-')[0].replace('team', ''); + const base = teamPos[ti] || { x: cx, y: 300 }; + const label = n.label || ''; + const tail = label.split('-').pop() || label; + const chIdx = NATIVE_ORDER.indexOf(tail); + // Imported XVI/XVII challenges aren't in NATIVE_ORDER: fan them out to + // the right of the native six so they never stack on the same column. + const slot = chIdx >= 0 ? chIdx - 2.5 : (n.nativeIndex ?? 0) + 3.5; + pos[n.id] = { x: base.x + slot * 70, y: base.y + 130 }; + }); + return { pos, W, H }; + } + + // Frame the whole graph inside the viewport (used by reset + first load). + fit() { + if (!this.app || !this.pos) return; + const rw = this.app.renderer.width / this.app.renderer.resolution; + const rh = this.app.renderer.height / this.app.renderer.resolution; + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (const [, v] of this.nodeViews) { + minX = Math.min(minX, v.c.x - 60); maxX = Math.max(maxX, v.c.x + 60); + minY = Math.min(minY, v.c.y - 60); maxY = Math.max(maxY, v.c.y + 60); + } + if (!isFinite(minX)) return; + const gw = maxX - minX, gh = maxY - minY; + const pad = 16; + this.scale = Math.max(0.2, Math.min(1.6, Math.min((rw - pad * 2) / gw, (rh - pad * 2) / gh))); + this.panX = (rw - gw * this.scale) / 2 - minX * this.scale; + this.panY = (rh - gh * this.scale) / 2 - minY * this.scale; + this.applyView(); + } + + // ---- data -> scene ------------------------------------------------------ + setData(nodes, edges, attacks) { + if (!this.app) return; + this.nodes = nodes || []; + this.edges = edges || []; + this.attacks = attacks || []; + const PIXI = this.PIXI; + const { pos, W, H } = this.layout(this.nodes); + this.pos = pos; + this.worldW = W; + this.worldH = H; + + // Edges are cheap and fully derived -> clear and redraw wholesale. + this._redrawEdges(pos); + this._syncNodes(pos); + this._syncAttacks(pos); + // First paint (or a big data change) must land framed, not at scale=1 with + // the graph hanging off the right edge. + if (this._needsFit) { this._needsFit = false; this.fit(); } + else this.applyView(); + } + + _syncNodes(pos) { + const PIXI = this.PIXI; + const seen = new Set(); + for (const n of this.nodes) { + const p = pos[n.id]; + if (!p) continue; + seen.add(n.id); + let view = this.nodeViews.get(n.id); + if (!view) { + const c = new PIXI.Container(); + const halo = new PIXI.Graphics(); + const disc = new PIXI.Graphics(); + const title = new PIXI.Text({ + text: n.label, + style: { fill: 0xffffff, fontSize: 10, fontFamily: 'ui-sans-serif, system-ui', fontWeight: 'bold', align: 'center' }, + }); + const sub = new PIXI.Text({ + text: '', + style: { fill: THEME.edgeLabel, fontSize: 9, fontFamily: 'ui-monospace, monospace', align: 'center' }, + }); + const dot = new PIXI.Graphics(); + c.addChild(halo, disc, title, sub, dot); + // Nodes must be draggable, so make the whole container interactive. + c.eventMode = 'static'; + c.cursor = 'grab'; + c.on('pointerdown', (ev) => this._onNodeDown(ev, c)); + title.anchor.set(0.5); + sub.anchor.set(0.5); + this.nodeLayer.addChild(c); + view = { c, halo, disc, title, sub, dot, data: null }; + this.nodeViews.set(n.id, view); + } + view.data = n; + const theme = this._themeFor(n); + const r = theme.r; + const fillAlpha = theme.alpha ?? 1; + view.disc.clear() + .circle(0, 0, r) + .fill({ color: theme.fill, alpha: fillAlpha }) + .stroke({ width: 1.5, color: theme.stroke, alpha: 1 }); + view.halo.clear() + .circle(0, 0, Math.max(1, r - 4)) + .stroke({ width: 1, color: theme.stroke, alpha: 0.3 }); + view.title.style.fill = theme.color; + view.title.style.fontSize = n.type === 'team' ? 12 : 10; + view.title.text = n.label; + const sub = n.type === 'team' ? `:${n.receiver_port}` : (n.port ? `:${n.port}` : ''); + view.sub.text = sub || ''; + view.sub.visible = !!sub; + view.title.y = sub ? 0 : -4; + view.sub.y = 12; + // Position only when it changed: writing x/y every refresh would fight a + // drag in progress and make the graph jitter under the cursor. + if (!view.dragging) view.c.position.set(p.x, p.y); + view.dot.clear(); + if (n.status === 'running') { + view.dot.circle(r - 8, -r + 8, 5).fill({ color: THEME.running }); + } + if (n.tooltip) { + view.c.on('pointerover', () => this._setTooltip(n)); + view.c.on('pointerout', () => this._setTooltip(null)); + } + } + // Drop views for nodes that no longer exist (a deleted team, a challenge + // disabled in the challenge manager) so they don't linger as ghosts. + for (const [id, view] of [...this.nodeViews]) { + if (seen.has(id)) continue; + view.c.destroy({ children: true }); + this.nodeViews.delete(id); + } + } + + _themeFor(n) { + if (n.type === 'panel') return THEME.panel; + if (n.type === 'infra') return THEME.infra; + if (n.type === 'team') return THEME.team; + if (n.type === 'challenge') return THEME.challenge; + return THEME.node; + } + + _syncAttacks(pos) { + const PIXI = this.PIXI; + const now = Date.now() / 1000; + const recent = this.attacks.filter(e => now - (e.ts || 0) < 600); + const counts = {}; + for (const e of recent) { + const k = `${e.attacker}:${e.target}`; + counts[k] = counts[k] || { ok: 0, fail: 0, latest: e.ts || 0 }; + counts[k][e.success ? 'ok' : 'fail']++; + counts[k].latest = Math.max(counts[k].latest, e.ts || 0); + } + this.attackCounts = counts; + + const seen = new Set(); + for (const [k, c] of Object.entries(counts)) { + const [atk, tgt] = k.split(':'); + const a = pos['team' + atk], b = pos['team' + tgt]; + if (!a || !b || atk === tgt) continue; + seen.add(k); + let view = this.attackViews.get(k); + if (!view) { + const g = new PIXI.Container(); + const line = new PIXI.Graphics(); + const label = new PIXI.Text({ + text: '', + style: { fill: 0xffffff, fontSize: 12, fontFamily: 'ui-sans-serif, system-ui', align: 'center' }, + }); + label.anchor.set(0.5); + g.addChild(line, label); + g.eventMode = 'none'; + this.attackLayer.addChild(g); + view = { g, line, label }; + this.attackViews.set(k, view); + } + view.color = c.ok > 0 ? THEME.attackOk : THEME.attackFail; + view.line.clear() + .moveTo(a.x, a.y).lineTo(b.x, b.y) + .stroke({ width: 2, color: view.color, alpha: 0.9 }); + view.label.style.fill = view.color; + view.label.text = `${c.ok}โœ“ ${c.fail}โœ—`; + view.label.position.set((a.x + b.x) / 2 + 8, (a.y + b.y) / 2 - 10); + view.latest = c.latest; + view.ok = c.ok; + view.fail = c.fail; + } + for (const [k, view] of [...this.attackViews]) { + if (seen.has(k)) continue; + view.g.destroy({ children: true }); + this.attackViews.delete(k); + } + } + + // ---- animation --------------------------------------------------------- + _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; + if (this.ticker < 33) return; + this.ticker = 0; + const now = Date.now() / 1000; + for (const [k, view] of this.attackViews) { + const hot = now - (view.latest || 0) < 60; + // 1.2s cycle, matching the old CSS keyframes (attackPulse 1.2s). + const phase = (now % 1.2) / 1.2; + 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 + // so we are not re-tessellating 30x a second for nothing. + const wantDash = hot ? '6 3' : '4 4'; + if (view.dash !== wantDash) { + view.dash = wantDash; + const a = this.pos['team' + k.split(':')[0]]; + const b = this.pos['team' + k.split(':')[1]]; + if (a && b) { + view.line.clear() + .moveTo(a.x, a.y).lineTo(b.x, b.y) + .stroke({ width: hot ? 3 : 2, color: view.color, alpha: 0.9 }); + } + } + } + } + + // ---- pan / zoom / drag ------------------------------------------------- + _bindPointer() { + const cv = this.app.canvas; + // v8 replaces the v7 `interactive = true` + `on('pointermove')` on the + // stage with eventMode on the object plus a normal DOM listener for the + // background pan (the stage itself is not interactive by default). + cv.addEventListener('wheel', (ev) => { + ev.preventDefault(); + this.zoomAt(ev, ev.ctrlKey ? 0.01 : 0.0022); + }, { passive: false }); + + let panning = null; + cv.addEventListener('pointerdown', (ev) => { + if (this.drag) return; // node drag wins + panning = { x: ev.clientX, y: ev.clientY, px: this.panX, py: this.panY }; + cv.setPointerCapture(ev.pointerId); + }); + cv.addEventListener('pointermove', (ev) => { + if (!panning) return; + this.panX = panning.px + (ev.clientX - panning.x); + this.panY = panning.py + (ev.clientY - panning.y); + this.applyView(); + }); + const endPan = (ev) => { + if (!panning) return; + panning = null; + try { cv.releasePointerCapture(ev.pointerId); } catch (e) { /* not captured */ } + }; + cv.addEventListener('pointerup', endPan); + cv.addEventListener('pointercancel', endPan); + } + + _onNodeDown(ev, container) { + ev.stopPropagation(); + const view = [...this.nodeViews.values()].find(v => v.c === container); + if (!view) return; + const world = this.toWorld(ev); + view.dragging = true; + container.cursor = 'grabbing'; + this.drag = { view, dx: container.x - world.x, dy: container.y - world.y }; + const move = (e) => { + const w = this.toWorld(e); + container.position.set(w.x + this.drag.dx, w.y + this.drag.dy); + // Edges/attacks originate at node centres, so a dragged node must + // redraw them or the graph lies about the topology. + this._redrawForDrag(); + }; + const up = () => { + view.dragging = false; + container.cursor = 'grab'; + this.drag = null; + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + this._redrawForDrag(); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + } + + _redrawForDrag() { + // A dragged node's position is user intent, not layout, so re-derive the + // edge/attack endpoints from the live node transforms instead of calling + // setData (which would snap the node back to its computed slot). + const pos = {}; + for (const [id, view] of this.nodeViews) pos[id] = { x: view.c.x, y: view.c.y }; + for (const id of ['dns', 'traefik', 'panel']) { + if (this.nodeViews.get(id)) pos[id] = { x: this.nodeViews.get(id).c.x, y: this.nodeViews.get(id).c.y }; + } + this.pos = pos; + this._redrawEdges(pos); + this._redrawAttacks(pos); + } + + _redrawEdges(pos) { + const PIXI = this.PIXI; + this.edgeLayer.removeChildren().forEach(c => c.destroy({ children: true })); + for (const e of this.edges) { + const a = pos[e.from], b = pos[e.to]; + if (!a || !b) continue; + const g = new PIXI.Graphics(); + g.moveTo(a.x, a.y).lineTo(b.x, b.y) + .stroke({ width: 1.5, color: THEME.edge, alpha: 1 }); + g.eventMode = 'none'; + this.edgeLayer.addChild(g); + if (e.label) { + const t = new PIXI.Text({ + 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); + t.eventMode = 'none'; + this.edgeLayer.addChild(t); + } + } + } + + _redrawAttacks(pos) { + for (const [k, view] of this.attackViews) { + const a = pos['team' + k.split(':')[0]], b = pos['team' + k.split(':')[1]]; + if (!a || !b) continue; + view.color = view.ok > 0 ? THEME.attackOk : THEME.attackFail; + view.line.clear() + .moveTo(a.x, a.y).lineTo(b.x, b.y) + .stroke({ width: 2, color: view.color, alpha: 0.9 }); + view.label.position.set((a.x + b.x) / 2 + 8, (a.y + b.y) / 2 - 10); + } + } + + toWorld(ev) { + const rect = this.app.canvas.getBoundingClientRect(); + return { + x: (ev.clientX - rect.left - this.panX) / this.scale, + y: (ev.clientY - rect.top - this.panY) / this.scale, + }; + } + + zoomAt(ev, factor) { + const rect = this.app.canvas.getBoundingClientRect(); + const mx = ev.clientX - rect.left, my = ev.clientY - rect.top; + const before = { x: (mx - this.panX) / this.scale, y: (my - this.panY) / this.scale }; + const next = Math.max(0.2, Math.min(4, this.scale * (1 - factor * 12))); + this.scale = next; + // Keep the point under the cursor fixed while scaling. + this.panX = mx - before.x * this.scale; + this.panY = my - before.y * this.scale; + this.applyView(); + } + + zoomBy(factor) { + this.scale = Math.max(0.2, Math.min(4, this.scale * factor)); + this.applyView(); + } + + reset() { + // "Reset" means "show me the whole graph", not "scale=1, pan=0" โ€” the latter + // is only correct while the layout fits the viewport, and with many teams it + // does not. Frame-to-fit is what the button actually promises. + if (this.nodeViews.size) this.fit(); + else { this.scale = 1; this.panX = 0; this.panY = 0; this.applyView(); } + } + + _onResize() { + if (this.nodeViews.size) this.fit(); + } + + applyView() { + if (!this.app || !this.world) return; + this.world.position.set(this.panX || 0, this.panY || 0); + this.world.scale.set(this.scale); + const lbl = document.getElementById('topoZoomLabel'); + if (lbl) lbl.textContent = Math.round(this.scale * 100) + '%'; + } +} + diff --git a/panel/static/vendor/README.md b/panel/static/vendor/README.md new file mode 100644 index 0000000..5bf1b44 --- /dev/null +++ b/panel/static/vendor/README.md @@ -0,0 +1,21 @@ +# Vendored third-party assets + +## pixi.mjs โ€” PixiJS 8.21.0 (MIT) + +Topology graph renderer for the admin panel. Self-hosted on purpose: the panel +must not depend on a CDN at runtime (the CTF network is air-gapped during the +event, and a CDN outage would take the topology view down with it). + +Provenance and upgrade path: + + npm pack pixi.js@8 + tar xzf pixi.js-8.21.0.tgz + cp package/dist/pixi.min.mjs panel/static/vendor/pixi.mjs + +Use `pixi.min.mjs`, **not** `pixi.min.js`. The `.js` bundle is an IIFE that +discards its return value and exports no global, so it cannot be loaded with a +plain `