// CIS490 dashboard front-end. // // Layers: // 1. Transport + typed message bus // 2. Scrollytelling controller (hotkeys, click, prev/next, FAB) // 3. Public API + demo machinery // 4. Widgets — one per scene // // Events on the bus: // // Real (from server feeders): // hello — {type, clients} one-shot on connect // snapshot — {type, total_episodes, total_alerts, // total_bytes, host_counts, // recent_episodes: [...]} every 30 s + on connect // episode — {type, episode_id, host_id, sha256, // size_bytes, received_at} one per index.jsonl line // alert — {type, host_id, symptom, detail, // suggested_fix, detected_at} one per alerts.jsonl line // // Real (from future producers — overwrite synthetic if demo is on): // phase — {type, phase} // prediction — {type, episode_id, window_idx, predicted, actual} // model_metric — {type, model, accuracy} // embedding — {type, x, y, phase} // model_perf — {type, model, latency_us, accuracy} // attack_profile — {type, name, shape, curve: [...]} // // Local (browser-only, never hit the wire): // demo_start — {type} emitted when demo toggles on // demo_stop — {type} emitted when demo toggles off // // Demo-only widgets render NOTHING by default (they show an "awaiting" // row). They populate synthetic data on demo_start and clear on // demo_stop. Real producer events overwrite either way. (function () { 'use strict'; // ──────────────────────────────────────────────────────────────── // 1. Transport + bus // ──────────────────────────────────────────────────────────────── const statusEl = document.getElementById('status'); const subscribers = new Map(); const wildcardSubs = new Set(); function on(type, fn) { if (type === '*') { wildcardSubs.add(fn); return () => wildcardSubs.delete(fn); } if (!subscribers.has(type)) subscribers.set(type, new Set()); subscribers.get(type).add(fn); return () => subscribers.get(type).delete(fn); } function dispatch(msg) { const t = (msg && msg.type) || '__no_type__'; const subs = subscribers.get(t); if (subs) subs.forEach(fn => { try { fn(msg); } catch (e) { console.error(e); } }); wildcardSubs.forEach(fn => { try { fn(msg); } catch (e) { console.error(e); } }); } let ws = null; function connect() { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; ws = new WebSocket(`${proto}//${location.host}/ws`); ws.onopen = () => { statusEl.textContent = 'live'; statusEl.className = 'status ok'; }; ws.onclose = () => { statusEl.textContent = 'reconnecting…'; statusEl.className = 'status bad'; setTimeout(connect, 1500); }; ws.onerror = () => {}; ws.onmessage = ev => { let msg; try { msg = JSON.parse(ev.data); } catch { msg = { type: 'raw', raw: ev.data }; } dispatch(msg); }; } connect(); function send(msg) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); } // ──────────────────────────────────────────────────────────────── // 2. Scrollytelling controller // ──────────────────────────────────────────────────────────────── const scenes = Array.from(document.querySelectorAll('.scene[data-stage]')); const stageViews = new Map(); document.querySelectorAll('.stage-view[data-view]').forEach(el => { stageViews.set(el.dataset.view, el); }); const sceneEnterHandlers = new Map(); const sceneExitHandlers = new Map(); function onScene(name, { onEnter, onExit } = {}) { if (onEnter) sceneEnterHandlers.set(name, onEnter); if (onExit) sceneExitHandlers.set(name, onExit); } const sceneIdxEl = document.getElementById('scene-idx'); const sceneTotalEl = document.getElementById('scene-total'); const prevBtn = document.getElementById('prev-btn'); const nextBtn = document.getElementById('next-btn'); const fab = document.getElementById('next-fab'); sceneTotalEl.textContent = String(scenes.length); // Mount window: the active scene plus its immediate neighbours // get [data-mounted]; everything else is skipped from paint via // CSS (content-visibility: hidden on .scene, display: none on // the matching .stage-view). The window is recomputed every time // the active scene changes — and pre-computed before any // programmatic scroll so the destination is ready to render // before it scrolls into view. function updateMountedRange(idx) { for (let i = 0; i < scenes.length; i++) { const inWindow = Math.abs(i - idx) <= 1; const scene = scenes[i]; const view = stageViews.get(scene.dataset.stage); if (inWindow) { scene.setAttribute('data-mounted', ''); if (view) view.setAttribute('data-mounted', ''); } else { scene.removeAttribute('data-mounted'); if (view) view.removeAttribute('data-mounted'); } } } let activeIdx = -1; function setActiveIdx(idx) { if (idx === activeIdx) return; if (activeIdx >= 0) { const prevName = scenes[activeIdx].dataset.stage; const view = stageViews.get(prevName); if (view) view.removeAttribute('data-active'); const fn = sceneExitHandlers.get(prevName); if (fn) try { fn(); } catch (e) { console.error(e); } scenes[activeIdx].removeAttribute('data-active'); } activeIdx = idx; updateMountedRange(idx); sceneIdxEl.textContent = String(idx + 1); const name = scenes[idx].dataset.stage; const view = stageViews.get(name); if (view) view.setAttribute('data-active', ''); scenes[idx].setAttribute('data-active', ''); const fn = sceneEnterHandlers.get(name); if (fn) try { fn(); } catch (e) { console.error(e); } prevBtn.disabled = idx === 0; nextBtn.disabled = idx === scenes.length - 1; if (fab) fab.disabled = idx === scenes.length - 1; } const sceneRatios = new Map(); const io = new IntersectionObserver(entries => { entries.forEach(e => sceneRatios.set(e.target, e.intersectionRatio)); let bestIdx = activeIdx >= 0 ? activeIdx : 0; let bestRatio = -1; scenes.forEach((s, i) => { const r = sceneRatios.get(s) || 0; if (r > bestRatio) { bestRatio = r; bestIdx = i; } }); setActiveIdx(bestIdx); }, { threshold: [0, 0.25, 0.5, 0.75, 1], rootMargin: '-30% 0px -30% 0px', }); scenes.forEach(s => io.observe(s)); setActiveIdx(0); function scrollToScene(idx) { idx = Math.max(0, Math.min(scenes.length - 1, idx)); if (idx === activeIdx) return; // Pre-mount the destination's window before initiating the smooth // scroll. Without this, an Home/End jump scrolls past unmounted // scenes that are content-visibility: hidden, and the target only // mounts once activation flips at the end of the animation — // showing a flash of empty space mid-scroll. updateMountedRange(idx); scenes[idx].scrollIntoView({ behavior: 'smooth', block: 'start' }); } function next() { scrollToScene(activeIdx + 1); } function prev() { scrollToScene(activeIdx - 1); } window.addEventListener('keydown', e => { if (e.metaKey || e.ctrlKey || e.altKey) return; const tag = (e.target && e.target.tagName) || ''; if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target && e.target.isContentEditable)) return; switch (e.key) { case 'ArrowDown': case 'ArrowRight': case 'PageDown': case 'j': case ' ': e.preventDefault(); next(); break; case 'ArrowUp': case 'ArrowLeft': case 'PageUp': case 'k': e.preventDefault(); prev(); break; case 'Home': e.preventDefault(); scrollToScene(0); break; case 'End': e.preventDefault(); scrollToScene(scenes.length - 1); break; case 'c': case 'C': e.preventDefault(); setClickNav(!clickNavOn); break; case 'd': case 'D': e.preventDefault(); setDemo(!demoActive); break; } }); prevBtn.addEventListener('click', prev); nextBtn.addEventListener('click', next); if (fab) fab.addEventListener('click', next); // Click-on-stage to advance — gated by the click-nav toggle so // interactive widgets (db table rows, search boxes) don't compete // with the next-slide gesture by default. Topbar arrows / FAB / // hotkeys always work regardless. const clickNavBtn = document.getElementById('click-nav-btn'); let clickNavOn = false; function setClickNav(on) { clickNavOn = on; clickNavBtn.textContent = `click-nav: ${on ? 'on' : 'off'}`; clickNavBtn.classList.toggle('active', on); } clickNavBtn.addEventListener('click', e => { e.stopPropagation(); setClickNav(!clickNavOn); }); setClickNav(false); const stageCol = document.getElementById('stage-col'); stageCol.addEventListener('click', e => { if (!clickNavOn) return; if (e.target.closest('[data-no-advance]')) return; if (e.target.closest('button, a, input, select, textarea')) return; next(); }); // ──────────────────────────────────────────────────────────────── // 3. Public API + demo machinery // ──────────────────────────────────────────────────────────────── window.dashboard = { on, send, scene: onScene, dispatch, next, prev, scrollToScene }; const demoBtn = document.getElementById('demo-btn'); let demoTimer = null; let demoActive = false; // Demo mode is intentionally narrow. Real data wins everywhere — // demo only seeds widgets that haven't received any real producer // output yet. demoTick is now a no-op; the per-widget demo_start // handlers do the (one-shot) seeding, so we don't drown out real // data with periodic synthetic noise. function demoTick() {} function setDemo(active) { if (demoActive === active) return; demoActive = active; if (active) { dispatch({ type: 'demo_start' }); demoTimer = setInterval(demoTick, 350); demoBtn.textContent = 'demo: on'; demoBtn.classList.add('active'); } else { if (demoTimer) clearInterval(demoTimer); demoTimer = null; dispatch({ type: 'demo_stop' }); demoBtn.textContent = 'demo: off'; demoBtn.classList.remove('active'); } } demoBtn.addEventListener('click', e => { e.stopPropagation(); setDemo(!demoActive); }); // Helper for empty-state rows. function awaitingNote(text) { const d = document.createElement('div'); d.className = 'awaiting'; d.textContent = text; return d; } // ──────────────────────────────────────────────────────────────── // 3.5. Theme machinery (OKLCH palette · continuous harmony · 5 BGs) // ──────────────────────────────────────────────────────────────── // Harmony is parameterized continuously by `count` (1..6 colors) // and `spread` (0..300° angular range). Together they cover every // named scheme: mono = count 1; complementary = count 2 spread 180; // analogous = count 3-5 with low spread; triadic = count 3 spread // 240; tetradic = count 4 spread 270; etc. Markers fan symmetrically // around the primary so a 60° spread with 3 colors is [0, +30, -30]. // Any marker can be dragged individually to break the symmetry. // // Animations across the bg-canvas read --anim-speed; the bg blur // reads --bg-blur. State persists in localStorage. (function () { // Per-theme settings spec. Each entry becomes a slider in the // panel under the active-theme section. `step` rounds the value // and decides how many decimals to show. const THEMES = { drift: { label: 'drift settings', params: { count: { default: 6, min: 3, max: 12, step: 1, label: 'blobs', rebuild: true }, sizeVw: { default: 36, min: 12, max: 60, step: 1, label: 'size (vw)', rebuild: true }, blur: { default: 70, min: 20, max: 150, step: 1, label: 'blur (px)' }, opacity: { default: 0.55, min: 0.1, max: 1, step: 0.05, label: 'opacity' }, }, }, lava: { label: 'lava settings', params: { count: { default: 8, min: 4, max: 16, step: 1, label: 'bubbles', rebuild: true }, sizeMean: { default: 9, min: 3, max: 18, step: 0.5, label: 'mean size (vw)', rebuild: true }, sizeSpread: { default: 4, min: 0, max: 8, step: 0.5, label: 'size σ (spread)', rebuild: true }, gooStr: { default: 26, min: 8, max: 50, step: 1, label: 'merge strength' }, gooBlur: { default: 22, min: 8, max: 40, step: 1, label: 'merge blur (σ)' }, }, }, vaporwave: { label: 'vaporwave settings', params: { gridSize: { default: 80, min: 30, max: 200, step: 5, label: 'grid cell (px)' }, perspective: { default: 62, min: 30, max: 80, step: 1, label: 'horizon angle (°)' }, sunSize: { default: 50, min: 20, max: 80, step: 1, label: 'sun size (vmin)' }, horizonPct: { default: 55, min: 35, max: 70, step: 1, label: 'horizon position (%)' }, blindWidth: { default: 11, min: 6, max: 30, step: 1, label: 'blind width (px)' }, }, }, laser: { label: 'laser settings', params: { count: { default: 5, min: 2, max: 12, step: 1, label: 'beams', rebuild: true }, thickness: { default: 3, min: 1, max: 12, step: 1, label: 'thickness (px)' }, blur: { default: 2, min: 0, max: 10, step: 1, label: 'blur (px)' }, opacity: { default: 0.55, min: 0.1, max: 1, step: 0.05, label: 'opacity' }, }, }, }; function defaultThemeSettings() { const out = {}; for (const t in THEMES) { out[t] = {}; for (const p in THEMES[t].params) { out[t][p] = THEMES[t].params[p].default; } } return out; } const DEFAULTS = { background: 'black', L: 70, C: 0.15, H: 250, count: 3, spread: 60, offsets: null, lVar: 0, cVar: 0, animSpeed: 1.0, bgBlur: 0, tint: 0.10, contentBackdrop: 0.30, // 0 = fully transparent foreground; 1 = fully opaque themes: defaultThemeSettings(), }; const state = Object.assign({}, DEFAULTS); // Even, symmetric distribution: primary at 0, siblings fan // alternately to +/- around it. e.g. n=4 spread=270 → [0, 90, // -90, 180] (which is tetradic, just stated symmetrically). function evenOffsets(n, spread) { const r = [0]; if (n <= 1) return r; const step = spread / (n - 1); for (let i = 1; i < n; i++) { const sign = i % 2 === 1 ? 1 : -1; const k = Math.ceil(i / 2); r.push(sign * k * step); } return r; } try { const stored = JSON.parse(localStorage.getItem('cis490-theme') || '{}'); delete stored.harmony; // discrete-harmony field (gone) Object.assign(state, stored); if (!Array.isArray(state.offsets) || state.offsets.length !== state.count) { state.offsets = evenOffsets(state.count, state.spread); } if (typeof state.spread === 'number' && state.spread <= 5) { state.spread = 60; state.offsets = evenOffsets(state.count, state.spread); } // Merge in theme defaults for any missing per-theme params // (state shape grows over time as new sliders ship). const fresh = defaultThemeSettings(); state.themes = state.themes || {}; for (const t in fresh) { state.themes[t] = Object.assign({}, fresh[t], state.themes[t] || {}); } } catch {} if (!state.offsets) state.offsets = evenOffsets(state.count, state.spread); const $ = id => document.getElementById(id); const els = { bg: $('theme-bg'), L: $('theme-l'), Lv: $('theme-l-val'), C: $('theme-c'), Cv: $('theme-c-val'), H: $('theme-h'), Hv: $('theme-h-val'), count: $('theme-count'), countV: $('theme-count-val'), spread: $('theme-spread'), spreadV: $('theme-spread-val'), hint: $('theme-harmony-hint'), lvar: $('theme-lvar'), lvarV: $('theme-lvar-val'), cvar: $('theme-cvar'), cvarV: $('theme-cvar-val'), speed: $('theme-speed'), speedV: $('theme-speed-val'), blur: $('theme-blur'), blurV: $('theme-blur-val'), tint: $('theme-tint'), tintV: $('theme-tint-val'), backdrop: $('theme-backdrop'), backdropV: $('theme-backdrop-val'), swatches: $('theme-swatches'), markers: $('wheel-markers'), wheel: $('theme-wheel'), meta: $('theme-meta'), panel: $('theme-panel'), btn: $('theme-btn'), close: $('theme-close'), reset: $('theme-reset'), }; // Best-effort name for the current (count, spread) combination — // labels purely informational, not used for state. function harmonyLabel() { const n = state.count, s = state.spread; if (n === 1) return 'mono'; if (n === 2) { if (s >= 170 && s <= 190) return 'complementary'; if (s < 60) return 'split-mono'; return null; } if (n === 3) { if (s <= 60) return 'analogous'; if (s >= 230 && s <= 250) return 'triadic'; if (s >= 170 && s <= 210) return 'split-complementary'; } if (n === 4 && s >= 260 && s <= 280) return 'tetradic'; return null; } // Compute one palette color (OKLCH triple) for index i. function colorAt(i) { const off = state.offsets[i] || 0; const h = (state.H + off + 360) % 360; const sign = i === 0 ? 0 : (i % 2 === 1 ? 1 : -1); const L = Math.max(5, Math.min(98, state.L + sign * state.lVar)); const C = Math.max(0, state.C + sign * state.cVar); return { L, C, H: h }; } function ok(c) { return `oklch(${c.L}% ${c.C.toFixed(3)} ${c.H.toFixed(1)})`; } function applyCSSVars() { const root = document.documentElement.style; for (let i = 0; i < 5; i++) { const c = colorAt(i % state.offsets.length); root.setProperty(`--c${i + 1}`, ok(c)); } const primary = colorAt(0); root.setProperty('--accent', ok(primary)); root.setProperty('--accent-soft', `oklch(${primary.L}% ${primary.C.toFixed(3)} ${primary.H.toFixed(1)} / 0.15)`); root.setProperty('--theme-l', `${state.L}%`); // Unitless version for math in calc() (e.g. clamp ramps). // var(--theme-l) is "70%" — usable directly inside oklch but // not as a unitless input to calc steps; --theme-l-num is the // raw number (e.g. 70). root.setProperty('--theme-l-num', state.L); root.setProperty('--theme-c', state.C); root.setProperty('--theme-h', state.H); // Unitless H, parallel to --theme-l-num — used by the KNN // scatter's cluster-color generator (it needs to do // arithmetic on H, can't with a degree-suffixed value). root.setProperty('--theme-h-num', state.H); root.setProperty('--anim-speed', state.animSpeed); // Only apply the filter when there's something to blur — at // zero, applying `filter: blur(0px)` still creates a stacking // context and forces a compositor layer that can flatten 3D // children and produce artifacts on neighboring elements. if (state.bgBlur > 0) { root.setProperty('--bg-filter', `blur(${state.bgBlur}px)`); } else { root.removeProperty('--bg-filter'); } root.setProperty('--tint-strength', state.tint); root.setProperty('--content-backdrop', state.contentBackdrop); document.body.dataset.theme = state.background; applyThemeSpecificVars(); } // Per-theme CSS variables derived from state.themes.. Only // the variables for the currently-active theme need to be set, // but it's cheap to set them all so the dropdown switch is // instant. function applyThemeSpecificVars() { const root = document.documentElement.style; const t = state.themes; // drift root.setProperty('--drift-blur', `${t.drift.blur}px`); root.setProperty('--drift-opacity', t.drift.opacity); // vaporwave root.setProperty('--vw-grid-size', `${t.vaporwave.gridSize}px`); root.setProperty('--vw-perspective', `${t.vaporwave.perspective}deg`); root.setProperty('--vw-sun-size', `${t.vaporwave.sunSize}vmin`); root.setProperty('--vw-horizon', `${t.vaporwave.horizonPct}%`); root.setProperty('--vw-blind', `${t.vaporwave.blindWidth}px`); // laser root.setProperty('--laser-thickness', `${t.laser.thickness}px`); root.setProperty('--laser-blur', `${t.laser.blur}px`); root.setProperty('--laser-opacity', t.laser.opacity); // lava goo filter — has to be set on the SVG element directly const fb = document.querySelector('#goo feGaussianBlur'); const fc = document.querySelector('#goo feColorMatrix'); if (fb) fb.setAttribute('stdDeviation', String(t.lava.gooBlur)); if (fc) { const a = t.lava.gooStr; const b = Math.round(a / 2 + 1); fc.setAttribute('values', `1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ${a} -${b}`); } } function applyForm() { els.bg.value = state.background; els.L.value = state.L; els.Lv.textContent = state.L; els.C.value = state.C; els.Cv.textContent = state.C.toFixed(3); els.H.value = state.H; els.Hv.textContent = Math.round(state.H); els.count.value = state.count; els.countV.textContent = state.count; els.spread.value = state.spread; els.spreadV.textContent = Math.round(state.spread); els.lvar.value = state.lVar; els.lvarV.textContent = state.lVar; els.cvar.value = state.cVar; els.cvarV.textContent = state.cVar.toFixed(3); els.speed.value = state.animSpeed; els.speedV.textContent = state.animSpeed.toFixed(2); els.blur.value = state.bgBlur; els.blurV.textContent = state.bgBlur; els.tint.value = state.tint; els.tintV.textContent = state.tint.toFixed(2); els.backdrop.value = state.contentBackdrop; els.backdropV.textContent = state.contentBackdrop.toFixed(2); const label = harmonyLabel(); els.hint.textContent = label ? `≈ ${label}` : '· custom'; const primary = colorAt(0); els.meta.textContent = ok(primary); els.swatches.innerHTML = ''; for (let i = 0; i < state.offsets.length; i++) { const c = colorAt(i); const s = document.createElement('div'); s.className = 'theme-swatch'; s.style.background = ok(c); s.title = ok(c); els.swatches.appendChild(s); } } // Marker rendering: full rebuild (called when offsets array // length changes, e.g. on harmony switch) vs. in-place update // (called every other time, including during drag — does NOT // tear down DOM, so pointer capture stays alive). const RADIUS = 84; function positionMarker(m, i) { const c = colorAt(i); const angleRad = (c.H - 90) * Math.PI / 180; const x = 100 + RADIUS * Math.cos(angleRad); const y = 100 + RADIUS * Math.sin(angleRad); m.style.left = x.toFixed(1) + 'px'; m.style.top = y.toFixed(1) + 'px'; m.style.background = ok(c); } function renderMarkersFull() { els.markers.innerHTML = ''; state.offsets.forEach((_, i) => { const m = document.createElement('div'); m.className = 'wheel-marker' + (i === 0 ? ' primary' : ''); m.dataset.idx = String(i); m.title = i === 0 ? 'primary · drag to rotate the whole palette' : 'drag to move just this color'; positionMarker(m, i); m.addEventListener('pointerdown', startDrag); els.markers.appendChild(m); }); } function updateMarkers() { const ms = els.markers.querySelectorAll('.wheel-marker'); ms.forEach((m, i) => positionMarker(m, i)); } function apply({ rebuildMarkers = false, rebuildBg = false } = {}) { applyCSSVars(); applyForm(); if (rebuildMarkers) renderMarkersFull(); else updateMarkers(); if (rebuildBg) rebuildBackgroundElements(); showActiveBgSection(); try { localStorage.setItem('cis490-theme', JSON.stringify(state)); } catch {} } // ── Per-theme background element generation ─────────────────── // drift / lava / laser each have collections of small DOM nodes // whose count and individual properties depend on per-theme // settings. Rebuild only when count or statistical-spread params // change; styling-only updates (blur, opacity, thickness) flow // through CSS variables and don't need a rebuild. // Box-Muller for size variance. function gauss(mean, std) { let u = 0, v = 0; while (u === 0) u = Math.random(); while (v === 0) v = Math.random(); return mean + std * Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); } // Graceful removal helpers. retireOnIteration waits for the // element's animation to complete its current cycle, then // removes it — the "back to translateY(0)" frame of the rise // animations is offscreen, so removal is invisible. fadeOut // transitions opacity to 0 over 800ms then removes; used for // lasers (no offscreen moment in their continuous rotation). function retireOnIteration(el) { let removed = false; const kill = () => { if (!removed) { removed = true; el.remove(); } }; el.addEventListener('animationiteration', kill, { once: true }); // Safety net: enforce removal after a generous max so a // setting-spam doesn't leak old nodes forever. setTimeout(() => { if (el.isConnected) kill(); }, 60_000); } function fadeOutAndRemove(el) { el.style.transition = 'opacity 800ms ease-out'; requestAnimationFrame(() => { el.style.opacity = '0'; }); setTimeout(() => { if (el.isConnected) el.remove(); }, 900); } function makeDriftBlob(i) { const t = state.themes.drift; const b = document.createElement('span'); b.className = 'drift-blob'; b.style.width = b.style.height = `${t.sizeVw}vw`; b.style.left = `${(Math.random() * 90).toFixed(1)}%`; b.style.bottom = `-${(15 + Math.random() * 25).toFixed(0)}%`; b.style.background = `var(--c${(i % 5) + 1})`; const dur = (14 + Math.random() * 16).toFixed(1); const delay = (-Math.random() * parseFloat(dur)).toFixed(1); b.style.animation = `drift-rise calc(${dur}s / var(--anim-speed, 1)) ease-in-out ${delay}s infinite`; return b; } function makeLavaBubble(i) { const t = state.themes.lava; const b = document.createElement('span'); b.className = 'goo-bubble'; const sz = Math.max(2, Math.min(28, gauss(t.sizeMean, t.sizeSpread))); b.style.width = b.style.height = `${sz.toFixed(1)}vw`; b.style.left = `${(Math.random() * 92).toFixed(1)}%`; b.style.bottom = `-${(sz + 4).toFixed(1)}vw`; b.style.background = `var(--c${(i % 5) + 1})`; const dur = (14 + Math.random() * 14).toFixed(1); const delay = (-Math.random() * parseFloat(dur)).toFixed(1); b.style.animation = `goo-rise calc(${dur}s / var(--anim-speed, 1)) ease-in-out ${delay}s infinite`; return b; } function makeLaserBeam(i) { const b = document.createElement('span'); b.className = 'laser-beam'; b.style.background = `linear-gradient(90deg, transparent, var(--c${(i % 5) + 1}), transparent)`; const dur = (8 + Math.random() * 12).toFixed(1); const delay = (-Math.random() * parseFloat(dur)).toFixed(1); const dir = i % 2 === 0 ? 'normal' : 'reverse'; b.style.animation = `laser-sweep calc(${dur}s / var(--anim-speed, 1)) linear ${delay}s infinite ${dir}`; return b; } function rebuildDrift() { const root = document.getElementById('bg-drift'); if (!root) return; // Phase out existing blobs at end of their current cycle — // they finish their visible rise/fall before vanishing instead // of all popping out of existence at once. Array.from(root.children).forEach(retireOnIteration); const t = state.themes.drift; for (let i = 0; i < t.count; i++) root.appendChild(makeDriftBlob(i)); } function rebuildLava() { const root = document.getElementById('bg-lava-bubbles'); if (!root) return; Array.from(root.children).forEach(retireOnIteration); const t = state.themes.lava; for (let i = 0; i < t.count; i++) root.appendChild(makeLavaBubble(i)); } function rebuildLaser() { const root = document.getElementById('bg-laser-beams'); if (!root) return; // Lasers have no offscreen moment in their rotation, so fade. Array.from(root.children).forEach(fadeOutAndRemove); const t = state.themes.laser; for (let i = 0; i < t.count; i++) root.appendChild(makeLaserBeam(i)); } function rebuildBackgroundElements() { rebuildDrift(); rebuildLava(); rebuildLaser(); } // (Vaporwave floor is now CSS-only again — see the .vw-floor* // rules in dashboard.css. Heavy compositor-layer hints // prevent the scroll-induced re-rasterization.) // ── Drag handling ────────────────────────────────────────────── // Primary marker (idx 0): drag rotates the entire palette // (changes state.H). Other markers: drag moves just that // marker (changes state.offsets[i]). pointermove is on the // captured marker itself, NOT the document, so we keep capture // even if the cursor leaves the wheel area. let dragMarker = null; let dragIdx = -1; let dragIsPrimary = false; function startDrag(e) { e.preventDefault(); e.stopPropagation(); dragMarker = e.currentTarget; dragIdx = parseInt(dragMarker.dataset.idx, 10); dragIsPrimary = (dragIdx === 0); dragMarker.classList.add('dragging'); try { dragMarker.setPointerCapture(e.pointerId); } catch {} dragMarker.addEventListener('pointermove', onDrag); dragMarker.addEventListener('pointerup', endDrag, { once: true }); dragMarker.addEventListener('pointercancel', endDrag, { once: true }); } function angleFromCenter(clientX, clientY) { const r = els.wheel.getBoundingClientRect(); const cx = r.left + r.width / 2; const cy = r.top + r.height / 2; let a = Math.atan2(clientY - cy, clientX - cx) * 180 / Math.PI + 90; if (a < 0) a += 360; return a; } function onDrag(e) { const angle = angleFromCenter(e.clientX, e.clientY); if (dragIsPrimary) { state.H = (angle + 360) % 360; } else { // Marker's absolute hue should equal `angle`; its hue is // (H + offset). So offset = angle - H, normalized to -180..180. const target = (angle - state.H + 540) % 360 - 180; state.offsets[dragIdx] = target; } apply(); } function endDrag(e) { if (!dragMarker) return; dragMarker.classList.remove('dragging'); dragMarker.removeEventListener('pointermove', onDrag); try { dragMarker.releasePointerCapture(e.pointerId); } catch {} dragMarker = null; dragIdx = -1; } // ── Panel show/hide + hotkey ─────────────────────────────────── // Toggle via the `is-open` class (not the hidden attribute) so // the slide-in transform transition is preserved in both // directions. function togglePanel(force) { const willShow = typeof force === 'boolean' ? force : !els.panel.classList.contains('is-open'); els.panel.classList.toggle('is-open', willShow); els.btn.classList.toggle('active', willShow); } window.addEventListener('keydown', e => { if (e.metaKey || e.ctrlKey || e.altKey) return; const tag = (e.target && e.target.tagName) || ''; if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target && e.target.isContentEditable)) return; if (e.key === 't' || e.key === 'T') { e.preventDefault(); togglePanel(); } }); els.btn.addEventListener('click', e => { e.stopPropagation(); togglePanel(); }); els.close.addEventListener('click', e => { e.stopPropagation(); togglePanel(false); }); els.reset.addEventListener('click', e => { e.stopPropagation(); Object.assign(state, DEFAULTS); state.offsets = evenOffsets(state.count, state.spread); apply({ rebuildMarkers: true }); }); els.bg.addEventListener('change', e => { state.background = e.target.value; apply(); }); // ── Per-theme settings panel ────────────────────────────────── // Build one section per theme (each a
) inside // #theme-bg-settings. CSS hides all but the active one (via the // `.is-active` class managed by showActiveBgSection). const $bgSettings = document.getElementById('theme-bg-settings'); for (const themeName in THEMES) { const spec = THEMES[themeName]; const section = document.createElement('details'); section.className = 'theme-advanced theme-bg-section'; section.dataset.theme = themeName; section.open = true; const summary = document.createElement('summary'); summary.textContent = spec.label; section.appendChild(summary); const sliders = document.createElement('div'); sliders.className = 'theme-sliders'; for (const key in spec.params) { const p = spec.params[key]; const id = `theme-${themeName}-${key}`; const v = state.themes[themeName][key]; const fmt = p.step >= 1 ? Math.round(v) : v.toFixed(p.step >= 0.1 ? 2 : 3); const label = document.createElement('label'); label.innerHTML = `${p.label} · ${fmt}` + ``; sliders.appendChild(label); } section.appendChild(sliders); $bgSettings.appendChild(section); // Bind inputs after they're in the DOM. for (const key in spec.params) { const p = spec.params[key]; const id = `theme-${themeName}-${key}`; const input = document.getElementById(id); const valSpan = document.getElementById(`${id}-val`); if (!input || !valSpan) continue; input.addEventListener('input', e => { const v = parseFloat(e.target.value); state.themes[themeName][key] = v; valSpan.textContent = p.step >= 1 ? Math.round(v) : v.toFixed(p.step >= 0.1 ? 2 : 3); apply({ rebuildBg: !!p.rebuild }); }); } } function showActiveBgSection() { document.querySelectorAll('.theme-bg-section').forEach(s => { s.classList.toggle('is-active', s.dataset.theme === state.background); }); } // count + spread together regenerate offsets to an evenly-fanned // distribution. Any prior per-marker drags are overwritten — // the user is asking for a clean palette by touching these. function regenAndApply() { state.offsets = evenOffsets(state.count, state.spread); apply({ rebuildMarkers: true }); } els.count.addEventListener('input', e => { state.count = Math.max(1, Math.min(6, parseInt(e.target.value, 10))); regenAndApply(); }); els.spread.addEventListener('input', e => { state.spread = Math.max(0, Math.min(300, parseFloat(e.target.value))); regenAndApply(); }); const bind = (input, setter) => input.addEventListener('input', e => { setter(parseFloat(e.target.value)); apply(); }); bind(els.L, v => state.L = v); bind(els.C, v => state.C = v); bind(els.H, v => state.H = v); bind(els.lvar, v => state.lVar = v); bind(els.cvar, v => state.cVar = v); bind(els.speed, v => state.animSpeed = v); bind(els.blur, v => state.bgBlur = v); bind(els.tint, v => state.tint = v); bind(els.backdrop, v => state.contentBackdrop = v); // Stop panel interactions from bubbling to stage-col click-to-advance. els.panel.addEventListener('click', e => e.stopPropagation()); els.panel.addEventListener('pointerdown', e => e.stopPropagation()); apply({ rebuildMarkers: true, rebuildBg: true }); })(); // ──────────────────────────────────────────────────────────────── // 4. Widgets // ──────────────────────────────────────────────────────────────── // ── Stack scene · pyproject.toml + receiver/app.py header ───── // Static content. The point is to surface the "stdlib-first, // every dep annotated" stance to the audience without making them // open a terminal. (function () { const PYPROJECT = `# Single project, three install profiles. The base "dependencies" # list is what every host needs (the receiver, the orchestrator, # the dashboard); training and dev pull in heavier tooling on demand. [project] name = "cis490" version = "0.0.1" description = "CIS490 behavioral malware detection — dataset, transport, training" requires-python = ">=3.11" # Runtime: HTTP receiver + orchestrator + image build. dependencies = [ "starlette>=0.36", # ASGI app for the receiver and dashboard "uvicorn[standard]>=0.27", # production-grade ASGI server "msgpack>=1.0", # MSF RPC wire format (Tier-3 exploit driver) "pycdlib>=1.14", # build NoCloud cidata ISOs in pure Python ] [dependency-groups] # Pulled in only when training. Kept off the receiver Pi. training = [ "pyarrow>=15", "polars>=1.0", # columnar dataset I/O "numpy>=1.26", "scipy>=1.11", "scikit-learn>=1.4", # KNN, KMeans, PCA, metrics "xgboost>=2.0", # gradient-boosted trees baseline "torch>=2.2", # LSTM / GRU / RNN / CNN / Transformer "zstandard>=0.22", # streams episode tarballs without buffering ] dev = [ "pytest>=8", "pytest-asyncio>=0.23", # async-aware test runner "httpx>=0.27", "paramiko>=3", # in-guest HTTP / SSH for tests "matplotlib>=3.8", "tornado>=6", # plotting (training reports) ] `; const RECEIVER = `# The receiver is the public-facing endpoint that ingests episode # tarballs from fleet hosts. Starlette ASGI for the HTTP surface; # everything else is intentionally stdlib. from __future__ import annotations import json, logging, secrets, time from pathlib import Path from starlette.applications import Starlette from starlette.responses import JSONResponse, Response from starlette.routing import Route # Per-host episodes get streamed onto disk by the EpisodeStore; # version_gate rejects schemas the analysis pipeline can't read. from .store import EpisodeStore, is_valid_id from .version_gate import VersionGate log = logging.getLogger("cis490.receiver") SUFFIX = ".tar.zst" # zstd-compressed tar — what the fleet ships SCHEMA_VERSION = 1 # bumped if the on-disk format changes # Authenticate every upload with a shared bearer token. The # constant-time compare matters: a naive == leaks token length and # byte-by-byte progress through timing, which a careful attacker # can use to recover the secret one character at a time. def _bearer_check(request, expected): if expected is None: return None # auth disabled (dev mode) auth = request.headers.get("authorization", "") if not auth.startswith("Bearer "): return JSONResponse({"error": "missing bearer token"}, status_code=401) presented = auth[len("Bearer "):] if not secrets.compare_digest(presented, expected): return JSONResponse({"error": "bad bearer token"}, status_code=401) return None # auth ok — caller proceeds `; const PY_KEYWORDS = new Set([ 'from', 'import', 'def', 'class', 'return', 'async', 'await', 'if', 'else', 'elif', 'for', 'while', 'in', 'as', 'with', 'lambda', 'None', 'True', 'False', 'raise', 'try', 'except', 'finally', 'yield', 'global', 'nonlocal', 'pass', 'break', 'continue', 'not', 'and', 'or', 'is', 'self', ]); function escapeHtml(s) { return s.replace(/&/g, '&').replace(//g, '>'); } // Find the first `#` that's not inside a string literal. function findCommentStart(line) { let inString = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inString) { if (ch === '\\') { i++; continue; } if (ch === inString) inString = false; } else if (ch === '"' || ch === "'") { inString = ch; } else if (ch === '#') { return i; } } return -1; } // Char-by-char Python tokenizer. Handles strings, identifiers, // numbers — enough for an imports block. Keyword regex would // fire inside strings; we tokenize properly to avoid that. function tokenizePython(s) { let out = ''; let i = 0; while (i < s.length) { const ch = s[i]; if (ch === '"' || ch === "'") { const quote = ch; let j = i + 1; while (j < s.length && s[j] !== quote) { if (s[j] === '\\') j++; j++; } out += `${s.slice(i, j + 1)}`; i = j + 1; } else if (/[A-Za-z_]/.test(ch)) { let j = i; while (j < s.length && /[A-Za-z0-9_]/.test(s[j])) j++; const word = s.slice(i, j); if (PY_KEYWORDS.has(word)) out += `${word}`; else out += word; i = j; } else if (/\d/.test(ch)) { let j = i; while (j < s.length && /[\d.]/.test(s[j])) j++; out += `${s.slice(i, j)}`; i = j; } else { out += ch; i++; } } return out; } function highlightPython(code) { return escapeHtml(code).split('\n').map(line => { const idx = findCommentStart(line); const codePart = idx >= 0 ? line.slice(0, idx) : line; const comment = idx >= 0 ? line.slice(idx) : ''; return tokenizePython(codePart) + (comment ? `${comment}` : ''); }).join('\n'); } function highlightToml(code) { return escapeHtml(code).split('\n').map(line => { const idx = findCommentStart(line); const codePart = idx >= 0 ? line.slice(0, idx) : line; const comment = idx >= 0 ? line.slice(idx) : ''; // Order matters: section headers, then key=, then strings, // then numbers. Each replace operates on the result of the // previous so we don't double-wrap. const coded = codePart .replace(/(\[[^\]\n]+\])/g, '$1') .replace(/^([A-Za-z_][\w-]*)(\s*=)/, '$1$2') .replace(/("[^"\n]*"|'[^'\n]*')/g, '$1') .replace(/\b(\d+(?:\.\d+)?)\b/g, '$1'); return coded + (comment ? `${comment}` : ''); }).join('\n'); } const TRAINER = `"""Long Short-Term Memory over channel × time windows. Same input/output as GRU, swap the cell. ~30% more parameters than the GRU at the same hidden size; included so the comparison report can speak to the cell-choice question.""" from __future__ import annotations from torch import nn # The registry lets the trainer pick a model by string name from # the training manifest. _SeqBase handles the shared bookkeeping # (feature selection, standardization, checkpoint I/O) so each # model class only writes its architecture. from training.models import register from training.models._torch_seq import _SeqBase @register("lstm") class LSTM(_SeqBase): # _build_module is called once per training run with shapes # derived from the actual dataset, not hardcoded constants — # so the same model class works at any window length / channel # count. Defaults reflect what produced the leaderboard numbers. def _build_module(self, *, n_channels_in, n_timesteps, n_classes, hidden=128, n_layers=2, dropout=0.1, bidirectional=False): return _LSTMClassifier( n_channels_in=n_channels_in, n_classes=n_classes, hidden=hidden, n_layers=n_layers, dropout=dropout, bidirectional=bidirectional, ) # Plain PyTorch module; the wrapper above is what the rest of the # pipeline talks to. Splitting them keeps the model architecture # pure-torch and easy to inspect / swap. class _LSTMClassifier(nn.Module): def __init__(self, *, n_channels_in, n_classes, hidden, n_layers, dropout, bidirectional): super().__init__() # batch_first=True so the tensor flows as (batch, time, # channels), matching the dataloader layout. Stacking layers # with dropout-between is only meaningful when n_layers > 1. self.lstm = nn.LSTM( input_size=n_channels_in, hidden_size=hidden, num_layers=n_layers, dropout=dropout if n_layers > 1 else 0.0, batch_first=True, bidirectional=bidirectional, ) # Bidirectional LSTMs concat forward + backward states, so # the head sees 2× hidden when that flag is on. d_out = hidden * (2 if bidirectional else 1) # Dropout before the linear head is a cheap regularizer # without changing the LSTM's own behaviour. self.head = nn.Sequential( nn.Dropout(dropout), nn.Linear(d_out, n_classes), ) # Dataset gives (batch, channels, time). Transpose to put time # in the middle so PyTorch's batch_first LSTM accepts it. def forward(self, x): # (B, C, T) -> (B, T, C) x = x.transpose(1, 2) out, _ = self.lstm(x) # out: (B, T, hidden*dir) # Use the last timestep's hidden state for classification — # by then the LSTM has integrated the whole window. return self.head(out[:, -1, :]) `; const TRAIN_LOOP = `# One generic loop runs every neural model. The model class only # defines architecture; this loop owns the optimizer, learning-rate # schedule, mixed precision, gradient clipping, and the early-stop # bookkeeping. Same code trains LSTM, GRU, CNN, Transformer. def train_nn(*, model, X_train, y_train, X_val, y_val, n_classes, epochs=60, batch_size=512, base_lr=1e-3, weight_decay=1e-4, warmup_frac=0.05, grad_clip=1.0, patience=8, device="auto") -> TrainResult: """Train a model; return TrainResult with the best-on-val state_dict already loaded back into model.module.""" # Auto-pick CUDA when present so the same script runs on the # Pi (CPU) and the A100 (GPU + AMP) without code changes. if device == "auto": device = "cuda" if torch.cuda.is_available() else "cpu" use_amp = device == "cuda" mod = model.module.to(device) # Inverse-frequency class weights (capped). The dataset is # ~50% infected_running and only ~5% armed — without weighting, # CE happily ignores the rare classes and reports "good" # accuracy by predicting the majority class for everything. cw = _compute_class_weights(y_train, n_classes) loss_fn = nn.CrossEntropyLoss( weight=torch.from_numpy(cw).to(device)) # AdamW = Adam with decoupled weight decay; cleaner regularisation # than L2-in-the-loss for transformers and recurrent nets. opt = torch.optim.AdamW(mod.parameters(), lr=base_lr, weight_decay=weight_decay) # GradScaler enables mixed-precision training on CUDA: most ops # run in fp16 (faster, less memory) but the scaler keeps # gradients in a safe range so they don't underflow to zero. scaler = torch.amp.GradScaler("cuda") if use_amp else None best_f1, best_state, no_improve = -1.0, None, 0 step, total_steps = 0, epochs * len(train_dl) warmup = int(total_steps * warmup_frac) # 5% of total = warmup for ep in range(1, epochs + 1): mod.train() for xb, yb in train_dl: xb, yb = xb.to(device), yb.to(device) # Cosine schedule with a linear warmup. Warmup avoids # the early-training "loss explodes from a fresh AdamW" # problem; cosine then anneals smoothly toward zero. for g in opt.param_groups: g["lr"] = _cosine_lr(step, total_steps=total_steps, warmup_steps=warmup, base_lr=base_lr) opt.zero_grad(set_to_none=True) # cheaper than zero_() if use_amp: # AMP path: forward in autocast, scaler handles # backward + step so fp16 grads don't underflow. with torch.amp.autocast("cuda"): loss = loss_fn(mod(xb), yb) scaler.scale(loss).backward() scaler.unscale_(opt) # Grad clip after unscale — recurrent nets can spike # gradients early in training; clipping keeps them sane. nn.utils.clip_grad_norm_(mod.parameters(), grad_clip) scaler.step(opt); scaler.update() else: # CPU / fp32 path — no scaler bookkeeping needed. loss = loss_fn(mod(xb), yb) loss.backward() nn.utils.clip_grad_norm_(mod.parameters(), grad_clip) opt.step() step += 1 # Track the held-out-by-host macro-F1, NOT accuracy. With # imbalanced classes a constant predictor can hit 0.5 # accuracy; macro-F1 averages per-class F1, so the rare # phases actually count. f1 = _macro_f1(y_val, _predict(mod, val_dl), n_classes) if f1 > best_f1 + 1e-4: # New best — snapshot the weights. Cheaper than checkpointing # to disk every epoch since we only need the final winner. best_f1, best_state, no_improve = f1, mod.state_dict(), 0 else: # No improvement; tick the patience counter. no_improve += 1 if no_improve >= patience: break # early stop — saves an A100-hour or two # Restore the best-on-val weights. The last epoch's weights are # almost always worse than the best — overfit creep on train. mod.load_state_dict(best_state) return TrainResult(best_f1=best_f1, best_state=best_state, ...) `; document.getElementById('code-pyproject').innerHTML = highlightToml(PYPROJECT); document.getElementById('code-receiver').innerHTML = highlightPython(RECEIVER); document.getElementById('code-train-lstm').innerHTML = highlightPython(TRAINER); document.getElementById('code-train-loop').innerHTML = highlightPython(TRAIN_LOOP); })(); // ── Ingest counter + 60-second sparkline ────────────────────── // Real-data widget: populated by snapshot + episode events. No // demo gating — when demo is off, it just shows real activity. (function () { const totalEl = document.getElementById('ingest-total'); const rateEl = document.getElementById('ingest-rate'); const bytesEl = document.getElementById('ingest-bytes'); const pathEl = document.getElementById('ingest-spark-path'); const fillEl = document.getElementById('ingest-spark-fill'); const W = 600, H = 120, BUCKETS = 60; const buckets = new Array(BUCKETS).fill(0); let total = 0, totalBytes = 0; function bucketIndex() { return Math.floor(Date.now() / 1000) % BUCKETS; } let lastBucket = bucketIndex(); function rotateIfNeeded() { const cur = bucketIndex(); while (lastBucket !== cur) { lastBucket = (lastBucket + 1) % BUCKETS; buckets[lastBucket] = 0; } } function fmtBytes(n) { if (!n) return '0 B'; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return n.toFixed(n >= 100 || i === 0 ? 0 : 1) + ' ' + u[i]; } function render() { rotateIfNeeded(); totalEl.textContent = total.toLocaleString(); const sum = buckets.reduce((a, b) => a + b, 0); rateEl.textContent = (sum / BUCKETS).toFixed(1); if (bytesEl) bytesEl.textContent = fmtBytes(totalBytes); const max = Math.max(1, ...buckets); const pts = []; for (let i = 0; i < BUCKETS; i++) { const idx = (lastBucket + 1 + i) % BUCKETS; const x = (i / (BUCKETS - 1)) * W; const y = H - (buckets[idx] / max) * (H - 8) - 4; pts.push([x, y]); } const d = pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`).join(' '); pathEl.setAttribute('d', d); fillEl.setAttribute('d', `${d} L${W},${H} L0,${H} Z`); } on('snapshot', m => { if (typeof m.total_episodes === 'number') total = m.total_episodes; if (typeof m.total_bytes === 'number') totalBytes = m.total_bytes; render(); }); on('episode', m => { rotateIfNeeded(); buckets[lastBucket] += 1; total += 1; if (typeof m.size_bytes === 'number') totalBytes += m.size_bytes; render(); }); setInterval(render, 1000); render(); })(); // ── Per-host bars ───────────────────────────────────────────── // Real-data widget. Snapshot seeds absolute counts; episode events // increment. (function () { const root = document.getElementById('host-bars'); const counts = new Map(); const rows = new Map(); function clearEmpty() { const e = root.querySelector('.bars-empty, .awaiting'); if (e) e.remove(); } function ensureRow(host) { if (rows.has(host)) return rows.get(host); clearEmpty(); const row = document.createElement('div'); row.className = 'bar-row'; const name = document.createElement('div'); name.className = 'bar-host'; name.textContent = host; const track = document.createElement('div'); track.className = 'bar-track'; const fill = document.createElement('div'); fill.className = 'bar-fill'; fill.style.width = '0%'; track.appendChild(fill); const label = document.createElement('div'); label.className = 'bar-count'; label.textContent = '0'; row.append(name, track, label); root.appendChild(row); const entry = { row, fill, label }; rows.set(host, entry); return entry; } function render() { const max = Math.max(1, ...counts.values()); Array.from(counts.keys()).forEach(h => { const r = ensureRow(h); const c = counts.get(h); r.fill.style.width = ((c / max) * 100).toFixed(1) + '%'; r.label.textContent = c.toLocaleString(); }); Array.from(counts.keys()).sort((a, b) => counts.get(b) - counts.get(a)) .forEach(h => root.appendChild(rows.get(h).row)); } on('snapshot', m => { if (m.host_counts && typeof m.host_counts === 'object') { Object.entries(m.host_counts).forEach(([h, c]) => counts.set(h, c)); render(); } }); on('episode', m => { if (!m.host_id) return; counts.set(m.host_id, (counts.get(m.host_id) || 0) + 1); render(); }); })(); // ── Phase mix (dataset-derived) ─────────────────────────────── // Real-data widget. Driven by the dashboard's `phase_mix` feeder, // which periodically samples random episode tarballs on disk and // aggregates labels.jsonl phase durations across them. The feeder // tucks the result into `broadcaster.state["phase_mix"]` so it // arrives in the snapshot the WS sends on connect, and republishes // a `phase_mix` event each time it recomputes. (function () { const stack = document.getElementById('phase-stack'); const legend = document.getElementById('phase-legend'); const eyebrow = document.getElementById('phase-mix-eyebrow'); const sub = document.getElementById('phase-mix-sub'); const PHASES = ['clean', 'armed', 'infecting', 'infected_running', 'dormant']; const segs = new Map(); PHASES.forEach(p => { const seg = document.createElement('div'); seg.className = `phase-seg ${p}`; stack.appendChild(seg); segs.set(p, seg); const li = document.createElement('span'); const swatchVar = p === 'infected_running' ? 'running' : p; li.innerHTML = `${p}`; legend.appendChild(li); }); function fmtInt(n) { return (typeof n === 'number') ? n.toLocaleString() : '—'; } // Tracks whether real phase_mix data has arrived. demo_start uses // this to decide whether to inject a synthetic fallback — if real // data is already present (from snapshot or the phase_mix feeder), // the demo toggle does NOT clobber it. let hasRealMix = false; function applyMix(mix) { if (!mix) return; hasRealMix = true; const w = mix.weighted_seconds || {}; const c = mix.counts || {}; // Prefer time-weighted proportions; fall back to label counts. const useWeighted = Object.values(w).some(v => v > 0); const src = useWeighted ? w : c; // Sum only the canonical phases so non-displayed phases (e.g. // `failed` from the orchestrator) don't shrink the visible bars. const total = PHASES.reduce((a, p) => a + (src[p] || 0), 0) || 1; PHASES.forEach(p => { segs.get(p).style.flexGrow = ((src[p] || 0) / total).toFixed(4); }); if (eyebrow) { const tag = useWeighted ? 'time-weighted' : 'label-count'; eyebrow.textContent = `phase mix · ${fmtInt(mix.sampled_episodes)} of ${fmtInt(mix.population_episodes)} episodes · ${tag}`; } if (sub) { const hours = useWeighted ? Math.round(PHASES.reduce((a, p) => a + (w[p] || 0), 0) / 3600) : null; sub.innerHTML = `Aggregated across ${fmtInt(mix.sampled_episodes)} ` + `randomly-sampled episodes ` + `(${fmtInt(mix.total_labels)} phase records` + (hours != null ? `, ~${fmtInt(hours)} hours` : '') + `). Refreshes every ~10 min from disk.`; } } on('snapshot', m => { if (m.phase_mix) applyMix(m.phase_mix); }); on('phase_mix', applyMix); on('demo_start', () => { // Synthetic fallback: only fires if no real phase_mix has // arrived yet (e.g. dashboard offline, or feeder hasn't done its // first compute). The numbers below mirror a real production run // so the bar reads correctly during a deck-only demo. If real // data later arrives via snapshot or phase_mix event, applyMix // overwrites this on the spot. if (hasRealMix) return; applyMix({ weighted_seconds: { clean: 2659.1, armed: 725.7, infecting: 1607.4, infected_running: 8308.3, dormant: 3059.3, }, counts: { clean: 451, armed: 198, infecting: 379, infected_running: 614, dormant: 223, }, sampled_episodes: 500, population_episodes: 78705, total_labels: 1865, }); // applyMix flipped hasRealMix to true — flip back so a future // real arrival is still recognised as the first real one. hasRealMix = false; }); on('demo_stop', () => { // Demo toggle off doesn't wipe the dataset mix — the dataset is // ground truth, the demo only fakes per-event widgets. }); })(); // ── Database explorer ───────────────────────────────────────── // Real-data widget. Initial population from snapshot.recent_episodes // (last 200 lines of index.jsonl). New episodes prepend live. (function () { const tabsEl = document.getElementById('db-tabs'); const searchEl = document.getElementById('db-search'); const tbodyEl = document.getElementById('db-tbody'); const detailEl = document.getElementById('db-detail'); const detailMeta = document.getElementById('db-detail-meta'); const detailChart = document.getElementById('db-detail-chart'); const detailLegend = document.getElementById('db-detail-legend'); const countEl = document.getElementById('db-count'); let records = []; // newest first let activeHost = null; // null = all let query = ''; function fmtBytes(n) { if (!n) return '—'; const u = ['B', 'KB', 'MB', 'GB']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return n.toFixed(n >= 100 || i === 0 ? 0 : 1) + ' ' + u[i]; } function fmtTime(iso) { if (!iso) return '—'; try { return new Date(iso).toLocaleTimeString('en-US', { hour12: false }); } catch { return iso; } } function shortId(id) { if (!id) return '—'; return id.length > 24 ? id.slice(0, 16) + '…' + id.slice(-6) : id; } function rebuildTabs() { const hosts = Array.from(new Set(records.map(r => r.host_id).filter(Boolean))).sort(); tabsEl.innerHTML = ''; const all = document.createElement('button'); all.className = 'db-tab' + (activeHost === null ? ' active' : ''); all.textContent = `all · ${records.length}`; all.addEventListener('click', e => { e.stopPropagation(); activeHost = null; rebuildTabs(); rebuildTable(); }); tabsEl.appendChild(all); hosts.forEach(h => { const b = document.createElement('button'); b.className = 'db-tab' + (activeHost === h ? ' active' : ''); const c = records.filter(r => r.host_id === h).length; b.textContent = `${h} · ${c}`; b.addEventListener('click', e => { e.stopPropagation(); activeHost = h; rebuildTabs(); rebuildTable(); }); tabsEl.appendChild(b); }); } function matches(rec) { if (activeHost && rec.host_id !== activeHost) return false; if (!query) return true; const q = query.toLowerCase(); return (rec.host_id || '').toLowerCase().includes(q) || (rec.episode_id || '').toLowerCase().includes(q) || (rec.sha256 || '').toLowerCase().includes(q); } function rebuildTable() { const filtered = records.filter(matches); countEl.textContent = `${filtered.length} of ${records.length}`; tbodyEl.innerHTML = ''; if (!filtered.length) { const tr = document.createElement('tr'); const td = document.createElement('td'); td.colSpan = 4; td.className = 'awaiting'; td.textContent = records.length === 0 ? 'awaiting snapshot…' : 'no rows match the current filter'; tr.appendChild(td); tbodyEl.appendChild(tr); return; } const frag = document.createDocumentFragment(); filtered.slice(0, 200).forEach(rec => { const tr = document.createElement('tr'); tr.className = 'db-row'; tr.dataset.id = rec.episode_id || ''; tr.innerHTML = ` ${rec.host_id || '—'} ${shortId(rec.episode_id)} ${fmtTime(rec.received_at)} ${fmtBytes(rec.size_bytes)}`; tr.addEventListener('click', e => { e.stopPropagation(); detailEl.hidden = false; tbodyEl.querySelectorAll('.db-row').forEach(r => r.classList.remove('selected')); tr.classList.add('selected'); showEpisode(rec); }); frag.appendChild(tr); }); tbodyEl.appendChild(frag); } // Fetch + render the per-episode telemetry chart. Decompresses // and parses the .tar.zst on the server (see /api/episode in // app.py); here we compute deltas on the cumulative counters // and draw lines + phase bands. async function showEpisode(rec) { detailMeta.innerHTML = ` ${rec.host_id || '—'} ${rec.episode_id || '—'} ${fmtBytes(rec.size_bytes)} ${rec.received_at || ''}`; detailChart.innerHTML = 'loading…'; detailLegend.innerHTML = ''; try { const url = `/api/episode/${encodeURIComponent(rec.host_id)}/${encodeURIComponent(rec.episode_id)}`; const resp = await fetch(url); if (!resp.ok) { if (resp.status === 404) throw new Error('episode tarball not on disk'); throw new Error(`HTTP ${resp.status}`); } const data = await resp.json(); renderEpisodeChart(data); } catch (err) { detailChart.innerHTML = `${err.message}`; } } function renderEpisodeChart(data) { const W = 1000, H = 360; const pad = { t: 16, r: 18, b: 32, l: 56 }; const innerW = W - pad.l - pad.r; const innerH = H - pad.t - pad.b; const samples = data.samples || []; const labels = (data.labels || []) .filter(l => typeof l.t_mono_ns === 'number') .sort((a, b) => a.t_mono_ns - b.t_mono_ns); if (samples.length < 2) { detailChart.innerHTML = 'no telemetry samples'; return; } const tMin = samples[0].t_mono_ns; const tMax = samples[samples.length - 1].t_mono_ns; const tRange = Math.max(1, tMax - tMin); // Per-interval deltas of the cumulative counters. The README // envelope uses CPU jiffies (user + sys) and IO bytes (read + // write); both are running totals in /proc, so subtracting // adjacent samples gives instantaneous-ish rates. const cpu = [], io = []; for (let i = 1; i < samples.length; i++) { const a = samples[i - 1], b = samples[i]; if (b.t_mono_ns - a.t_mono_ns <= 0) continue; const cv = ((b.cpu_user_jiffies || 0) - (a.cpu_user_jiffies || 0)) + ((b.cpu_sys_jiffies || 0) - (a.cpu_sys_jiffies || 0)); const iv = ((b.io_read_bytes || 0) - (a.io_read_bytes || 0)) + ((b.io_write_bytes || 0) - (a.io_write_bytes || 0)); cpu.push({ t: b.t_mono_ns, v: Math.max(0, cv) }); io.push({ t: b.t_mono_ns, v: Math.max(0, iv) }); } const cpuMax = Math.max(1, ...cpu.map(p => p.v)); const ioMax = Math.max(1, ...io.map(p => p.v)); const tToX = t => pad.l + ((t - tMin) / tRange) * innerW; const cpuToY = v => pad.t + innerH - (v / cpuMax) * innerH; const ioToY = v => pad.t + innerH - (v / ioMax) * innerH; const cpuPath = cpu.map((p, i) => `${i === 0 ? 'M' : 'L'}${tToX(p.t).toFixed(1)},${cpuToY(p.v).toFixed(1)}`).join(' '); const ioPath = io.map((p, i) => `${i === 0 ? 'M' : 'L'}${tToX(p.t).toFixed(1)},${ioToY(p.v).toFixed(1)}`).join(' '); // Colored band per labeled-phase span. const phaseBands = []; const phasesUsed = new Set(); for (let i = 0; i < labels.length; i++) { const start = labels[i].t_mono_ns; const end = i + 1 < labels.length ? labels[i + 1].t_mono_ns : tMax; const phase = labels[i].phase; if (!phase) continue; phasesUsed.add(phase); const x = tToX(Math.max(start, tMin)); const w = tToX(Math.min(end, tMax)) - x; if (w > 0.5) { phaseBands.push( ``); } } const axisY = pad.t + innerH; const durSec = (tRange / 1e9).toFixed(1); detailChart.innerHTML = ` ${phaseBands.join('')} peak 0 0 s ${durSec} s `; const phaseList = Array.from(phasesUsed).map(p => { const v = p === 'infected_running' ? 'phase-running' : `phase-${p}`; return `${p}`; }).join(''); detailLegend.innerHTML = ` cpu jiffies / interval io bytes / interval ${phaseList}`; } on('snapshot', m => { if (Array.isArray(m.recent_episodes)) { records = m.recent_episodes.slice(); rebuildTabs(); rebuildTable(); } }); on('episode', m => { // Prepend; cap. records.unshift({ episode_id: m.episode_id, host_id: m.host_id, sha256: m.sha256, size_bytes: m.size_bytes, received_at: m.received_at, }); if (records.length > 200) records.length = 200; // Cheap update: only rebuild if scene visible. rebuildTabs(); rebuildTable(); }); on('demo_stop', () => { // Drop demo-injected records (their ids start with "demo-"). const before = records.length; records = records.filter(r => !(r.episode_id && r.episode_id.startsWith('demo-'))); if (records.length !== before) { rebuildTabs(); rebuildTable(); } }); searchEl.addEventListener('input', e => { query = e.target.value; rebuildTable(); }); searchEl.addEventListener('click', e => e.stopPropagation()); rebuildTabs(); rebuildTable(); })(); // ── Attack envelope thumbnails — DEMO ONLY ─────────────────── (function () { const root = document.getElementById('profile-grid'); const W = 200, H = 56; function emptyState() { root.innerHTML = ''; root.appendChild(awaitingNote('awaiting attack_profile events · turn demo on for examples')); } function curveToPath(values) { const max = Math.max(1, ...values); return values.map((v, i) => { const x = (i / (values.length - 1)) * W; const y = H - (v / max) * (H - 6) - 3; return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`; }).join(' '); } function gen(seed, fn, n = 80) { let s = seed; const rand = () => { s = (s * 1664525 + 1013904223) >>> 0; return (s & 0xffff) / 0xffff; }; return Array.from({ length: n }, (_, i) => fn(i / n, rand)); } const cards = new Map(); function render(profile) { let card = cards.get(profile.name); if (!card) { if (root.querySelector('.awaiting')) root.innerHTML = ''; card = document.createElement('div'); card.className = 'profile-card'; card.innerHTML = `
`; root.appendChild(card); cards.set(profile.name, card); } card.querySelector('.profile-name').textContent = profile.name; card.querySelector('.profile-shape').textContent = profile.shape || ''; card.querySelector('path').setAttribute('d', curveToPath(profile.curve)); } function clearAll() { cards.clear(); emptyState(); } function syntheticProfiles() { return [ { name: 'cpu-saturate', shape: 'sustained 1-vCPU peg (XMRig)', curve: gen(1, (t, r) => 0.1 + (t > 0.15 ? 0.85 : 0) + 0.05 * r()) }, { name: 'scan-and-dial', shape: 'SYN-style probes + dial-home', curve: gen(2, (t, r) => t < 0.2 ? 0.05 : 0.15 + 0.7 * Math.exp(-Math.pow((t-0.5)*4, 2)) + 0.05 * r()) }, { name: 'io-walk', shape: 'fs traversal + 4 KiB urandom writes', curve: gen(3, (t, r) => 0.2 + 0.3 * Math.sin(t * 14) + 0.4 * (t > 0.3 && t < 0.85 ? 1 : 0) + 0.05 * r()) }, { name: 'bursty-c2', shape: 'long idle + 3-packet egress bursts', curve: gen(4, (t, r) => 0.05 + (Math.sin(t * 30) > 0.95 ? 0.9 : 0) + 0.02 * r()) }, { name: 'low-and-slow', shape: 'minimal CPU + periodic memory churn', curve: gen(5, (t, r) => 0.12 + 0.08 * Math.sin(t * 6) + 0.05 * r()) }, { name: 'shell-resident', shape: 'one long TCP socket + command ticks', curve: gen(6, (t, r) => 0.08 + (Math.sin(t * 22) > 0.7 ? 0.5 : 0) + 0.03 * r()) }, ]; } // Attack envelopes show real `attack_profile` events only — no // demo synthesis. The producer side has the canonical envelopes // from the orchestrator's profile catalog; demo mode shouldn't // overwrite that with hand-tuned curves. on('attack_profile', m => { if (!m.name || !Array.isArray(m.curve)) return; render({ name: m.name, shape: m.shape || '', curve: m.curve }); }); emptyState(); })(); // ── Chunking timeline — DEMO ONLY ──────────────────────────── (function () { const ruleEl = document.getElementById('chunk-rule'); const rowEl = document.getElementById('chunk-row'); const axisEl = document.getElementById('chunk-axis'); const N = 6; function clearAll() { ruleEl.innerHTML = ''; rowEl.innerHTML = ''; axisEl.innerHTML = ''; rowEl.appendChild(awaitingNote('awaiting prediction events · turn demo on for examples')); } function buildExample() { const labels = ['clean', 'clean', 'armed', 'infecting', 'infected_running', 'dormant']; ruleEl.innerHTML = ''; rowEl.innerHTML = ''; axisEl.innerHTML = ''; for (let i = 0; i < N; i++) ruleEl.appendChild(Object.assign(document.createElement('div'), { className: 'tick' })); for (let i = 0; i < N; i++) { const c = document.createElement('div'); c.className = `chunk-cell ${labels[i]}`; c.textContent = labels[i].replace('_', ' '); rowEl.appendChild(c); } for (let i = 0; i < N; i++) { const t = document.createElement('span'); t.textContent = `${i * 10}s`; axisEl.appendChild(t); } } on('demo_start', buildExample); on('demo_stop', clearAll); on('prediction', m => { // Real predictions can update individual cells. if (typeof m.window_idx !== 'number') return; const cells = rowEl.querySelectorAll('.chunk-cell'); const cell = cells[m.window_idx]; if (!cell) return; const phase = m.predicted || m.actual; if (!phase) return; cell.className = `chunk-cell ${phase}`; cell.textContent = phase.replace('_', ' '); }); clearAll(); })(); // ── Model comparison bars — DEMO ONLY (until model_metric arrives) ─ (function () { const root = document.getElementById('model-bars'); const rows = new Map(); function emptyState() { root.innerHTML = ''; root.appendChild(awaitingNote('awaiting model_metric events · turn demo on for examples')); } // Derive a gradient deterministically from the model name. Any // string the producer publishes (mlp / mlp_realistic / cnn_oracle // / knn_semi / something we never anticipated) maps to a stable // hue, so the bar always paints. Beats hardcoding CSS rules per // known name and missing whatever the producer adds tomorrow. function modelHue(s) { // FNV-1a 32-bit, cheap and deterministic across browsers. let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = (h * 16777619) >>> 0; } return h % 360; } function gradientFor(model) { const hue = modelHue(String(model)); return `linear-gradient(90deg, oklch(72% 0.18 ${hue}), oklch(46% 0.20 ${hue}))`; } function ensureRow(model) { if (rows.has(model)) return rows.get(model); if (root.querySelector('.awaiting')) root.innerHTML = ''; const row = document.createElement('div'); row.className = 'model-row'; row.innerHTML = `
${model}
0.000
`; root.appendChild(row); const fill = row.querySelector('.model-fill'); // Inline style beats any CSS rule, so the gradient survives no // matter what's in dashboard.css. fill.style.background = gradientFor(model); const entry = { row, fill, acc: row.querySelector('.model-acc') }; rows.set(model, entry); return entry; } function render(model, accuracy) { const r = ensureRow(model); const visible = Math.max(0, Math.min(1, accuracy)); r.fill.style.width = (visible * 100).toFixed(1) + '%'; r.acc.textContent = accuracy.toFixed(3); } // Exclusive: demo on → only synthetic; demo off → only real. // cachedReal accumulates real model_metric events even while // demo is on, so toggling demo off restores the real picture // without waiting for the producer to re-publish. let demoActive = false; const cachedReal = new Map(); function repaintFrom(src) { rows.clear(); root.innerHTML = ''; if (src.size === 0) { emptyState(); return; } src.forEach((acc, model) => render(model, acc)); } on('demo_start', () => { demoActive = true; const synth = new Map([ ['knn', 0.736], ['rnn', 0.872], ['gru', 0.911], ['lstm', 0.928], ['bert', 0.954], ]); repaintFrom(synth); }); on('demo_stop', () => { demoActive = false; repaintFrom(cachedReal); }); on('model_metric', m => { if (!m.model || typeof m.accuracy !== 'number') return; cachedReal.set(m.model, m.accuracy); if (!demoActive) render(m.model, m.accuracy); }); emptyState(); })(); // ── KNN 3-D scatter (canvas) ────────────────────────────────── // Drag-to-rotate 3-D scatter on the KNN scene. Listens for // `embedding` events with optional z / predicted / cluster fields; // mode toggle picks which categorical dimension colors each point // (ground-truth phase, KNN-predicted label, cluster id). (function () { const wrap = document.querySelector('.scatter3d-wrap'); const canvas = document.getElementById('knn-scatter-canvas'); const legend = document.getElementById('knn-legend'); if (!canvas || !wrap || !legend) return; const ctx = canvas.getContext('2d'); const PHASES = ['clean', 'armed', 'infecting', 'infected_running', 'dormant']; const phaseCenters3d = { clean: [0.18, 0.75, 0.30], armed: [0.42, 0.58, 0.65], infecting: [0.72, 0.40, 0.55], infected_running: [0.85, 0.18, 0.20], dormant: [0.30, 0.30, 0.85], }; const points = []; let mode = 'phase'; let rotX = 0.45, rotY = 0.55; const HOME_X = rotX, HOME_Y = rotY; let dragging = false, lastPx = 0, lastPy = 0; let didDrag = false; // Hidden probe to resolve var(--…) and oklch(…) to RGB for canvas. const probe = document.createElement('div'); probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none'; document.body.appendChild(probe); const colorCache = new Map(); function cssColor(varExpr) { if (colorCache.has(varExpr)) return colorCache.get(varExpr); probe.style.color = varExpr; const c = getComputedStyle(probe).color || 'rgb(150,150,150)'; colorCache.set(varExpr, c); return c; } function refreshColors() { colorCache.clear(); } function phaseColor(p) { const v = p === 'infected_running' ? 'phase-running' : `phase-${p}`; return cssColor(`var(--${v})`); } function clusterColor(i) { const baseH = parseFloat(getComputedStyle(document.documentElement) .getPropertyValue('--theme-h-num') || '250'); const h = (baseH + i * 47) % 360; return cssColor(`oklch(72% 0.18 ${h.toFixed(1)})`); } function rebuildLegend() { legend.innerHTML = ''; if (mode === 'cluster') { const ids = Array.from(new Set(points.map(p => p.cluster).filter(c => c != null))).sort((a,b)=>a-b); if (ids.length === 0) { legend.innerHTML = 'no cluster ids in current points'; return; } ids.forEach(id => { const li = document.createElement('span'); li.innerHTML = `cluster ${id}`; legend.appendChild(li); }); return; } PHASES.forEach(p => { const v = p === 'infected_running' ? 'phase-running' : `phase-${p}`; const li = document.createElement('span'); li.innerHTML = `${p}`; legend.appendChild(li); }); } function pointColor(p) { if (mode === 'phase' && p.phase) return phaseColor(p.phase); if (mode === 'predicted' && p.predicted) return phaseColor(p.predicted); if (mode === 'cluster' && p.cluster != null) return clusterColor(p.cluster); return cssColor('var(--fg-mute)'); } function resize() { const r = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; const w = Math.max(1, Math.floor(r.width * dpr)); const h = Math.max(1, Math.floor(r.height * dpr)); if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } } if (window.ResizeObserver) new ResizeObserver(resize).observe(canvas); // ── Auto-fit: running mean / std per axis ───────────────────── // The producer rescales PCA output to [0,1]³ by min-max of its fit // subsample, but PCA-3 is Gaussian-ish so the bulk lands in a // narrow band near the centroid. We track running mean+std as // points arrive and project around mean ± SPREAD_K·σ → [-0.5,0.5] // so the data fills the bounding cube regardless of where in // [0,1] the producer happens to put it. Outliers clamp to ±0.7 // so they're visible just outside the cube. const SPREAD_K = 2.5; const MIN_STD = 0.02; // floor so degenerate (all-equal) data doesn't blow up const stats = { n: 0, sx: 0, sx2: 0, sy: 0, sy2: 0, sz: 0, sz2: 0, mx: 0.5, my: 0.5, mz: 0.5, dx: 0.4 / SPREAD_K, dy: 0.4 / SPREAD_K, dz: 0.4 / SPREAD_K, dirty: false, }; function resetStats() { stats.n = 0; stats.sx = stats.sx2 = stats.sy = stats.sy2 = stats.sz = stats.sz2 = 0; stats.mx = stats.my = stats.mz = 0.5; stats.dx = stats.dy = stats.dz = 0.4 / SPREAD_K; stats.dirty = false; } function addStat(p) { const z = (typeof p.z === 'number') ? p.z : 0.5; stats.n++; stats.sx += p.x; stats.sx2 += p.x * p.x; stats.sy += p.y; stats.sy2 += p.y * p.y; stats.sz += z; stats.sz2 += z * z; stats.dirty = true; } function recomputeStats() { if (stats.n < 2) { stats.dirty = false; return; } const n = stats.n; stats.mx = stats.sx / n; stats.my = stats.sy / n; stats.mz = stats.sz / n; stats.dx = Math.max(MIN_STD, Math.sqrt(Math.max(0, stats.sx2 / n - stats.mx * stats.mx))); stats.dy = Math.max(MIN_STD, Math.sqrt(Math.max(0, stats.sy2 / n - stats.my * stats.my))); stats.dz = Math.max(MIN_STD, Math.sqrt(Math.max(0, stats.sz2 / n - stats.mz * stats.mz))); stats.dirty = false; } function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; } // Project already-normalized (centered, σ-scaled) coords to canvas // pixels. nx, ny, nz are in roughly [-0.5, 0.5] for the bulk of // the data; outliers go a bit beyond. function projectNorm(nx, ny, nz) { const cy_ = Math.cos(rotY), sy_ = Math.sin(rotY); const cx_ = Math.cos(rotX), sx_ = Math.sin(rotX); const x1 = nx * cy_ + nz * sy_; const z1 = -nx * sy_ + nz * cy_; const y2 = ny * cx_ - z1 * sx_; const z2 = ny * sx_ + z1 * cx_; const camZ = 2.5; const persp = camZ / (camZ - z2); const w = canvas.clientWidth, h = canvas.clientHeight; const span = Math.min(w, h) * 0.46; return { sx: w / 2 + x1 * span * persp, sy: h / 2 + y2 * span * persp, depth: z2, scale: persp, }; } // Project a raw data point: normalize via running stats, then // hand off to projectNorm. function project(p) { if (stats.dirty) recomputeStats(); const z = (typeof p.z === 'number') ? p.z : stats.mz; const nx = clamp(((p.x - stats.mx) / (SPREAD_K * stats.dx)) * 0.5, -0.7, 0.7); const ny = clamp(((p.y - stats.my) / (SPREAD_K * stats.dy)) * 0.5, -0.7, 0.7); const nz = clamp(((z - stats.mz) / (SPREAD_K * stats.dz)) * 0.5, -0.7, 0.7); return projectNorm(nx, ny, nz); } const cubeEdges = [ [0,1],[1,3],[3,2],[2,0],[4,5],[5,7],[7,6],[6,4], [0,4],[1,5],[2,6],[3,7], ]; function drawCube() { // The cube outlines mean ± k·σ — i.e. the data spread, not the // raw [0,1]³ producer-unit cube. Stays consistent with the // auto-fit projection above. const corners = []; for (let i = 0; i < 8; i++) { corners.push(projectNorm( (i & 1) ? 0.5 : -0.5, (i & 2) ? 0.5 : -0.5, (i & 4) ? 0.5 : -0.5, )); } ctx.save(); ctx.strokeStyle = cssColor('var(--line)'); ctx.globalAlpha = 0.7; ctx.lineWidth = 1; for (const [a, b] of cubeEdges) { ctx.beginPath(); ctx.moveTo(corners[a].sx, corners[a].sy); ctx.lineTo(corners[b].sx, corners[b].sy); ctx.stroke(); } ctx.restore(); } function tick() { requestAnimationFrame(tick); const sceneActive = document.querySelector('.stage-view[data-view="knn"][data-active]'); if (!sceneActive) return; resize(); ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight); drawCube(); const projected = points.map(p => ({ p, ...project(p) })); projected.sort((a, b) => a.depth - b.depth); for (const item of projected) { const r = Math.max(2, 4.5 * item.scale); ctx.fillStyle = pointColor(item.p); ctx.globalAlpha = 0.55 + 0.4 * Math.min(1, item.scale); ctx.beginPath(); ctx.arc(item.sx, item.sy, r, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; } requestAnimationFrame(tick); wrap.addEventListener('pointerdown', e => { dragging = true; didDrag = false; lastPx = e.clientX; lastPy = e.clientY; try { wrap.setPointerCapture(e.pointerId); } catch {} e.stopPropagation(); }); wrap.addEventListener('pointermove', e => { if (!dragging) return; const dx = e.clientX - lastPx, dy = e.clientY - lastPy; if (Math.hypot(dx, dy) > 2) didDrag = true; rotY += dx * 0.008; rotX += dy * 0.008; rotX = Math.max(-Math.PI / 2 + 0.05, Math.min(Math.PI / 2 - 0.05, rotX)); lastPx = e.clientX; lastPy = e.clientY; }); function endDrag(e) { dragging = false; try { wrap.releasePointerCapture(e.pointerId); } catch {} } wrap.addEventListener('pointerup', endDrag); wrap.addEventListener('pointercancel', endDrag); // Eat the click that follows a drag so click-nav doesn't advance. wrap.addEventListener('click', e => { if (didDrag) { e.stopPropagation(); didDrag = false; } }); document.querySelectorAll('.scatter3d-mode').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); mode = btn.dataset.mode; document.querySelectorAll('.scatter3d-mode').forEach(b => b.classList.toggle('active', b === btn)); rebuildLegend(); }); }); document.querySelector('.scatter3d-reset')?.addEventListener('click', e => { e.stopPropagation(); rotX = HOME_X; rotY = HOME_Y; refreshColors(); }); // Synthetic 3-D demo: 5 phase clusters, deterministic LCG. ~7% // mislabeled by the demo "predictor" so predicted-mode visibly // differs from ground truth. function loadSynthetic() { points.length = 0; resetStats(); let seed = 7; const rand = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return ((seed & 0xffff) / 0xffff) - 0.5; }; const wrand = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return (seed & 0xffff) / 0xffff; }; PHASES.forEach((p, idx) => { const [cx, cy, cz] = phaseCenters3d[p]; for (let i = 0; i < 18; i++) { const wrong = wrand() < 0.07; const predicted = wrong ? PHASES[(idx + 1 + Math.floor(wrand() * 4)) % PHASES.length] : p; const pt = { x: cx + rand() * 0.18, y: cy + rand() * 0.18, z: cz + rand() * 0.18, phase: p, predicted, cluster: idx, }; points.push(pt); addStat(pt); } }); rebuildLegend(); } // KNN scatter is real-data only: it has a working producer and // doesn't participate in demo mode at all. on('embedding', m => { if (typeof m.x !== 'number' || typeof m.y !== 'number') return; const pt = { x: m.x, y: m.y, z: typeof m.z === 'number' ? m.z : 0.5, phase: m.phase, predicted: m.predicted, cluster: typeof m.cluster === 'number' ? m.cluster : undefined, }; points.push(pt); addStat(pt); rebuildLegend(); }); rebuildLegend(); })(); // ── A100 live inference (scene: live) ───────────────────────── // Per-MODEL swim lanes of recent prediction cells. Each row is a // model the A100 is running inference with; each cell is one // ten-second-window prediction colored by predicted phase. When // the producer also sends `actual`, mismatch cells get a hatched // overlay and the running hit-rate updates. The "latest" callout // below the lanes leads with the model name + predicted phase. (function () { const lanesEl = document.getElementById('live-lanes'); const latestEl = document.getElementById('live-latest'); const statsModels = document.getElementById('live-stats-hosts'); // id kept for HTML compat const statsRate = document.getElementById('live-stats-rate'); const statsHost = document.getElementById('live-stats-model'); // id kept; meaning flipped const statsAcc = document.getElementById('live-stats-acc'); if (!lanesEl) return; const MAX_CELLS = 60; const RATE_WINDOW_MS = 5000; const lanes = new Map(); const eventTimes = []; let totalCorrect = 0, totalLabeled = 0; let lastHost = null; function ensureLane(modelName) { if (lanes.has(modelName)) return lanes.get(modelName); const row = document.createElement('div'); row.className = 'live-lane'; row.innerHTML = `
${modelName}
`; lanesEl.appendChild(row); const lane = { row, cellsEl: row.querySelector('.live-lane-cells'), cells: [] }; lanes.set(modelName, lane); return lane; } function paintCell(d) { const lane = ensureLane(d.model || 'unknown'); const cell = document.createElement('div'); cell.className = `live-cell ${d.predicted}`; if (d.actual && d.actual !== d.predicted) cell.classList.add('miss'); cell.title = `${d.model ?? '?'} · ${d.host_id ?? '?'} w${d.window_idx ?? '?'} · ${d.predicted}` + (d.actual ? ` (truth: ${d.actual})` : '') + (d.confidence != null ? ` · conf ${d.confidence.toFixed(2)}` : ''); lane.cellsEl.appendChild(cell); lane.cells.push(d); while (lane.cells.length > MAX_CELLS) { lane.cells.shift(); const first = lane.cellsEl.firstChild; if (first) lane.cellsEl.removeChild(first); } } function paintLatest(d) { const conf = d.confidence != null ? `${(d.confidence * 100).toFixed(0)}%` : '—'; const truthLine = d.actual ? (d.actual === d.predicted ? `✓ matches ground truth` : `truth: ${d.actual} (model disagrees)`) : `truth: awaiting label`; const phaseLabel = (d.predicted || '').replace('_', '
'); latestEl.innerHTML = `
${phaseLabel}
model: ${d.model ?? '—'}${ d.latency_ms != null ? ` · A100 ${d.latency_ms.toFixed(1)} ms` : '' }
window from: ${d.host_id ?? '—'}${ d.profile ? ` · profile ${d.profile}` : '' }
${truthLine}
confidence ${conf}
`; } function updateStats() { const now = Date.now(); while (eventTimes.length && now - eventTimes[0] > RATE_WINDOW_MS) eventTimes.shift(); const rate = (eventTimes.length / (RATE_WINDOW_MS / 1000)).toFixed(1); statsModels.textContent = `${lanes.size} model${lanes.size === 1 ? '' : 's'}`; statsRate.textContent = `${rate} infer / sec`; statsHost.textContent = `last window: ${lastHost ?? '—'}`; statsAcc.textContent = totalLabeled > 0 ? `hit-rate: ${(100 * totalCorrect / totalLabeled).toFixed(0)}% (${totalCorrect}/${totalLabeled})` : `hit-rate: —`; } setInterval(updateStats, 500); // Exclusive: demo on → only synthetic detections; demo off → // only real. The caps mean we hold the most recent N real // detections so toggling demo off restores them. let demoActive = false; const cachedReal = []; const REAL_CACHE_CAP = 600; function clearLanes() { lanes.forEach(l => l.row.remove()); lanes.clear(); eventTimes.length = 0; totalCorrect = 0; totalLabeled = 0; lastHost = null; latestEl.innerHTML = '
awaiting live_detection events from the A100 inference loop
'; updateStats(); } function paintDetection(d) { eventTimes.push(Date.now()); if (d.host_id) lastHost = d.host_id; if (d.actual) { totalLabeled++; if (d.actual === d.predicted) totalCorrect++; } paintCell(d); paintLatest(d); } function handleDetection(d) { if (!d.model || !d.predicted) return; paintDetection(d); } on('live_detection', m => { if (!m.model || !m.predicted) return; cachedReal.push(m); if (cachedReal.length > REAL_CACHE_CAP) cachedReal.shift(); if (!demoActive) paintDetection(m); }); // Synthetic demo: A100 cycling through 5 trained models, scoring // windows from rotating fleet hosts. Each model has its own // accuracy + latency profile (KNN faster but less accurate; // BERT slower but more accurate) so the lanes look distinct. let demoTimer = null; function demoStart() { demoActive = true; clearLanes(); if (demoTimer) clearInterval(demoTimer); const MODELS = [ { name: 'knn', acc: 0.78, latMs: 0.4, phaseIdx: 0 }, { name: 'rnn', acc: 0.86, latMs: 1.8, phaseIdx: 0 }, { name: 'gru', acc: 0.91, latMs: 2.4, phaseIdx: 1 }, { name: 'lstm', acc: 0.93, latMs: 3.2, phaseIdx: 2 }, { name: 'bert', acc: 0.95, latMs: 8.6, phaseIdx: 0 }, ]; // Only hosts that actually contributed training data — elliott-lab // produced none, so it should never be the source of an inference // window in the live scene. const HOSTS = ['elliott-thinkpad', 'k-gamingcom']; const PROFILES = ['cpu-saturate', 'ransomware-lite', 'bursty-c2', 'fork-bomb', 'crypto-miner']; const PHASES = ['clean', 'armed', 'infecting', 'infected_running', 'dormant']; let counter = 0; // ~0.4 events/sec. Real ceiling with two contributing hosts // (elliott-thinkpad, k-gamingcom) is ~1 event/sec — we stay // well under so the lane cells read as deliberate, not as a // contrived stream. demoTimer = setInterval(() => { const m = MODELS[counter % MODELS.length]; counter++; if (Math.random() < 0.18) m.phaseIdx = (m.phaseIdx + 1) % PHASES.length; const truth = PHASES[m.phaseIdx]; const right = Math.random() < m.acc; const predicted = right ? truth : PHASES[(m.phaseIdx + 1 + Math.floor(Math.random() * 4)) % PHASES.length]; handleDetection({ host_id: HOSTS[counter % HOSTS.length], profile: PROFILES[counter % PROFILES.length], predicted, actual: truth, confidence: 0.62 + Math.random() * 0.36, model: m.name, latency_ms: m.latMs + (Math.random() - 0.5) * m.latMs * 0.3, episode_id: 'demo', window_idx: counter, t_wall: Date.now() / 1000, }); }, 2500); } function demoStop() { demoActive = false; if (demoTimer) { clearInterval(demoTimer); demoTimer = null; } clearLanes(); // Replay cached real detections so the lanes don't sit empty // until the next live_detection event arrives. for (const m of cachedReal) paintDetection(m); } on('demo_start', demoStart); on('demo_stop', demoStop); updateStats(); })(); // ── References viewer (scene: references) ──────────────────── // Lists PDFs from /api/references; clicking a tab swaps the // iframe's src to /refs/. List is fetched once at // init; iframe is lazy — src isn't set until the user enters // the references scene OR clicks a tab, so the browser doesn't // download a PDF that never gets viewed. (function () { const tabsEl = document.getElementById('ref-tabs'); const viewerEl = document.getElementById('ref-viewer'); const descEl = document.getElementById('ref-description'); if (!tabsEl || !viewerEl || !descEl) return; let refs = []; let activeIdx = -1; let pendingFirst = true; // load first PDF only when scene becomes active function loadFirstIfReady() { if (!pendingFirst) return; if (refs.length === 0) return; pendingFirst = false; selectRef(0); } function rebuildTabs() { tabsEl.innerHTML = ''; if (refs.length === 0) { const empty = document.createElement('div'); empty.className = 'awaiting'; empty.textContent = 'no PDFs found in /opt/cis490/references/'; tabsEl.appendChild(empty); renderDescription(null); return; } refs.forEach((r, i) => { const btn = document.createElement('button'); btn.className = 'ref-tab' + (i === activeIdx ? ' active' : ''); btn.textContent = r.name; btn.title = r.name; btn.addEventListener('click', e => { e.stopPropagation(); // don't bubble to stage-col click-nav selectRef(i); }); tabsEl.appendChild(btn); }); } function selectRef(i) { if (i < 0 || i >= refs.length) return; activeIdx = i; rebuildTabs(); // #zoom=page-width forces the browser's PDF viewer to fit the // page horizontally to the iframe — without it, an 8.5×11 // page leaves whitespace on either side when the iframe is // wider than the page's natural width. viewerEl.src = refs[i].path + '#zoom=page-width'; renderDescription(refs[i].description); } // Tiny markdown-ish renderer: enough to display headings, // paragraphs, bold/italic, lists, inline code. Keeps this widget // dependency-free (no marked.js / showdown.js / etc). function renderDescription(md) { if (!md) { descEl.innerHTML = '

no description for this reference yet — drop a sidecar <stem>.md next to the PDF in /opt/cis490/references/

'; return; } // Escape HTML first so user content can't inject markup. let s = md.replace(/&/g, '&').replace(//g, '>'); // Inline: bold, italic, code. s = s.replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/(?$1') .replace(/`([^`\n]+)`/g, '$1'); // Block-level: split on blank lines, then handle headings + lists. const blocks = s.split(/\n{2,}/).map(block => { const stripped = block.trim(); if (!stripped) return ''; if (stripped.startsWith('# ')) return `

${stripped.slice(2)}

`; if (stripped.startsWith('## ')) return `

${stripped.slice(3)}

`; if (stripped.startsWith('### ')) return `

${stripped.slice(4)}

`; const lines = stripped.split('\n'); if (lines.every(l => /^[-*]\s/.test(l))) { return '
    ' + lines.map(l => `
  • ${l.replace(/^[-*]\s+/, '')}
  • `).join('') + '
'; } if (lines.every(l => /^\d+\.\s/.test(l))) { return '
    ' + lines.map(l => `
  1. ${l.replace(/^\d+\.\s+/, '')}
  2. `).join('') + '
'; } return `

${stripped.replace(/\n/g, '
')}

`; }); descEl.innerHTML = blocks.join(''); } fetch('/api/references') .then(r => r.json()) .then(data => { refs = (data && data.references) || []; rebuildTabs(); // If user is already on the references scene at boot, load now. if (document.querySelector('.scene[data-stage="references"][data-active]')) { loadFirstIfReady(); } }) .catch(err => { tabsEl.innerHTML = ''; const e = document.createElement('div'); e.className = 'awaiting'; e.textContent = `failed to load references list: ${err.message}`; tabsEl.appendChild(e); }); // Defer the first iframe load until the references scene actually // becomes active (no point fetching a PDF the user may never see). window.dashboard.scene('references', { onEnter: loadFirstIfReady }); })(); // ── Performance scatter (log-x, anti-overlap labels) ────────── // X-axis is log10(latency_us) so the 35× range KNN→BERT spreads // evenly. Labels are sorted by x and placed alternately above / // below their points so adjacent labels can't horizontally // overlap even when points cluster (e.g. RNN/GRU/LSTM near each // other in latency). (function () { const svg = document.getElementById('perf-scatter'); const ns = 'http://www.w3.org/2000/svg'; const W = 600, H = 360; // Log-scale x bounds: 10 μs (1e1) to 10 ms (1e4). const xLogMin = 1, xLogMax = 4; const ymin = 0.5, ymax = 1.0; const points = new Map(); // model → { g, latency, accuracy } function setupAxes() { svg.innerHTML = ''; const ax = document.createElementNS(ns, 'line'); ax.setAttribute('x1', 50); ax.setAttribute('y1', H - 40); ax.setAttribute('x2', W - 10); ax.setAttribute('y2', H - 40); ax.setAttribute('class', 'axis'); svg.appendChild(ax); const ay = document.createElementNS(ns, 'line'); ay.setAttribute('x1', 50); ay.setAttribute('y1', 10); ay.setAttribute('x2', 50); ay.setAttribute('y2', H - 40); ay.setAttribute('class', 'axis'); svg.appendChild(ay); // x-axis log ticks at 10μs, 100μs, 1ms, 10ms. [ { v: 10, lbl: '10μs' }, { v: 100, lbl: '100μs' }, { v: 1000, lbl: '1ms' }, { v: 10000, lbl: '10ms' }, ].forEach(({ v, lbl }) => { const lx = Math.log10(v); const x = 50 + ((lx - xLogMin) / (xLogMax - xLogMin)) * (W - 60); const tick = document.createElementNS(ns, 'line'); tick.setAttribute('x1', x.toFixed(1)); tick.setAttribute('x2', x.toFixed(1)); tick.setAttribute('y1', H - 40); tick.setAttribute('y2', H - 35); tick.setAttribute('class', 'axis'); svg.appendChild(tick); const tlbl = document.createElementNS(ns, 'text'); tlbl.setAttribute('x', x.toFixed(1)); tlbl.setAttribute('y', H - 22); tlbl.setAttribute('class', 'axis-label'); tlbl.setAttribute('text-anchor', 'middle'); tlbl.style.fontSize = '11px'; tlbl.textContent = lbl; svg.appendChild(tlbl); }); // y-axis ticks at 0.6, 0.8, 1.0 [0.6, 0.8, 1.0].forEach(v => { const y = (H - 40) - ((v - ymin) / (ymax - ymin)) * (H - 50); const tick = document.createElementNS(ns, 'line'); tick.setAttribute('x1', 45); tick.setAttribute('x2', 50); tick.setAttribute('y1', y.toFixed(1)); tick.setAttribute('y2', y.toFixed(1)); tick.setAttribute('class', 'axis'); svg.appendChild(tick); const tlbl = document.createElementNS(ns, 'text'); tlbl.setAttribute('x', 42); tlbl.setAttribute('y', (y + 4).toFixed(1)); tlbl.setAttribute('class', 'axis-label'); tlbl.setAttribute('text-anchor', 'end'); tlbl.style.fontSize = '11px'; tlbl.textContent = v.toFixed(1); svg.appendChild(tlbl); }); const xl = document.createElementNS(ns, 'text'); xl.setAttribute('x', W / 2); xl.setAttribute('y', H - 6); xl.setAttribute('class', 'axis-label'); xl.setAttribute('text-anchor', 'middle'); xl.textContent = 'inference latency (log scale, μs / window) →'; svg.appendChild(xl); const yl = document.createElementNS(ns, 'text'); yl.setAttribute('transform', `translate(14,${H/2}) rotate(-90)`); yl.setAttribute('class', 'axis-label'); yl.setAttribute('text-anchor', 'middle'); yl.textContent = '↑ held-out macro-F1'; svg.appendChild(yl); } function emptyState() { points.clear(); svg.innerHTML = ''; const t = document.createElementNS(ns, 'text'); t.setAttribute('x', W / 2); t.setAttribute('y', H / 2); t.setAttribute('text-anchor', 'middle'); t.setAttribute('class', 'axis-label'); t.textContent = 'awaiting model_perf events · turn demo on for examples'; svg.appendChild(t); } function project(latency, accuracy) { const lat = Math.max(1, latency); const lx = Math.log10(lat); const x = 50 + Math.min(1, Math.max(0, (lx - xLogMin) / (xLogMax - xLogMin))) * (W - 60); const y = (H - 40) - Math.max(0, Math.min(1, (accuracy - ymin) / (ymax - ymin))) * (H - 50); return [x, y]; } // Anti-overlap: sort points by x, place labels alternately // above (even index) and below (odd index) so adjacent labels // never share both x and y bands. Re-runs every render so a // late-arriving model_perf event slots into the staircase. function repaintLabels() { const entries = Array.from(points.entries()).map(([model, rec]) => ({ model, rec, x: rec.cx, y: rec.cy, })).sort((a, b) => a.x - b.x); entries.forEach((e, i) => { const t = e.rec.g.querySelector('text'); const above = (i % 2 === 0); t.setAttribute('x', e.x.toFixed(1)); t.setAttribute('y', (above ? e.y - 12 : e.y + 18).toFixed(1)); t.setAttribute('text-anchor', 'middle'); }); } function render(model, latency, accuracy) { let rec = points.get(model); if (!rec) { if (!points.size) setupAxes(); const g = document.createElementNS(ns, 'g'); const c = document.createElementNS(ns, 'circle'); c.setAttribute('r', 7); c.setAttribute('class', 'perf-point'); g.appendChild(c); const t = document.createElementNS(ns, 'text'); t.setAttribute('class', 'perf-label'); g.appendChild(t); svg.appendChild(g); rec = { g, cx: 0, cy: 0 }; points.set(model, rec); } const [px, py] = project(latency, accuracy); rec.cx = px; rec.cy = py; rec.g.querySelector('circle').setAttribute('cx', px.toFixed(1)); rec.g.querySelector('circle').setAttribute('cy', py.toFixed(1)); rec.g.querySelector('text').textContent = model; repaintLabels(); } // Exclusive: demo on → only synthetic; demo off → only real. let demoActive = false; const cachedReal = new Map(); // model → {latency, accuracy} function repaintFrom(src) { points.clear(); svg.innerHTML = ''; if (src.size === 0) { emptyState(); return; } src.forEach((p, model) => render(model, p.latency, p.accuracy)); } on('demo_start', () => { demoActive = true; const synth = new Map([ ['knn', { latency: 90, accuracy: 0.84 }], ['rnn', { latency: 380, accuracy: 0.87 }], ['gru', { latency: 520, accuracy: 0.91 }], ['lstm', { latency: 700, accuracy: 0.93 }], ['bert', { latency: 3200, accuracy: 0.95 }], ]); repaintFrom(synth); }); on('demo_stop', () => { demoActive = false; repaintFrom(cachedReal); }); on('model_perf', m => { if (!m.model || typeof m.latency_us !== 'number' || typeof m.accuracy !== 'number') return; cachedReal.set(m.model, { latency: m.latency_us, accuracy: m.accuracy }); if (!demoActive) render(m.model, m.latency_us, m.accuracy); }); emptyState(); })(); })();