CIS490/training/dashboard/static/dashboard.js
Max Gorog 9e7d9999a3 demo mode: omit attack envelopes too
Same scope-narrowing as collect / hosts / db / knn — attack profiles
are real data from the orchestrator's catalog, so the deck should
display whatever the producer publishes via attack_profile events
and not overwrite that with synthetic curves on demo_start.

Removed both demo_start (synthesize) and demo_stop (clearAll)
handlers; the syntheticProfiles helper is left in place for
reference but is no longer wired to anything.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:29:38 -05:00

2469 lines
105 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
}
});
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: it does NOT synthesize
// `episode` events (those would clobber the real ingest counter,
// host bars, and database explorer — all of which work fine in or
// out of demo mode), and it does NOT synthesize `phase` events
// (the baseline scene reads dataset-derived `phase_mix` instead).
// The only periodic side-effect is occasional model_metric jitter
// so the bars don't sit frozen during a long talk.
function demoTick() {
if (Math.random() < 0.05) {
const m = ['knn', 'rnn', 'gru', 'lstm', 'bert'][Math.floor(Math.random() * 5)];
const base = { knn: 0.736, rnn: 0.872, gru: 0.911, lstm: 0.928, bert: 0.954 }[m];
dispatch({ type: 'model_metric', model: m, accuracy: base + (Math.random() - 0.5) * 0.012 });
}
}
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.<name>. 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 <details>) 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} · <span id="${id}-val">${fmt}</span>` +
`<input type="range" id="${id}" min="${p.min}" max="${p.max}" ` +
`value="${v}" step="${p.step}">`;
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// 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 += `<span class="str">${s.slice(i, j + 1)}</span>`;
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 += `<span class="kw">${word}</span>`;
else out += word;
i = j;
} else if (/\d/.test(ch)) {
let j = i;
while (j < s.length && /[\d.]/.test(s[j])) j++;
out += `<span class="num">${s.slice(i, j)}</span>`;
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 ? `<span class="com">${comment}</span>` : '');
}).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, '<span class="ty">$1</span>')
.replace(/^([A-Za-z_][\w-]*)(\s*=)/, '<span class="fn">$1</span>$2')
.replace(/("[^"\n]*"|'[^'\n]*')/g, '<span class="str">$1</span>')
.replace(/\b(\d+(?:\.\d+)?)\b/g, '<span class="num">$1</span>');
return coded +
(comment ? `<span class="com">${comment}</span>` : '');
}).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 = `<span class="swatch" style="background:var(--phase-${swatchVar})"></span>${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 <strong>${fmtInt(mix.sampled_episodes)}</strong> ` +
`randomly-sampled episodes ` +
`(<strong>${fmtInt(mix.total_labels)}</strong> phase records` +
(hours != null ? `, ~<strong>${fmtInt(hours)}</strong> 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 = `
<td><span class="db-host">${rec.host_id || '—'}</span></td>
<td><span class="db-id">${shortId(rec.episode_id)}</span></td>
<td>${fmtTime(rec.received_at)}</td>
<td class="db-size">${fmtBytes(rec.size_bytes)}</td>`;
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 = `
<span class="db-host">${rec.host_id || '—'}</span>
<span class="db-id">${rec.episode_id || '—'}</span>
<span>${fmtBytes(rec.size_bytes)}</span>
<span>${rec.received_at || ''}</span>`;
detailChart.innerHTML =
'<text x="50%" y="50%" text-anchor="middle" class="placeholder">loading…</text>';
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 =
`<text x="50%" y="50%" text-anchor="middle" class="placeholder">${err.message}</text>`;
}
}
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 =
'<text x="50%" y="50%" text-anchor="middle" class="placeholder">no telemetry samples</text>';
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(
`<rect class="phase-band ${phase}" x="${x.toFixed(1)}" y="${pad.t}" ` +
`width="${w.toFixed(1)}" height="${innerH}" />`);
}
}
const axisY = pad.t + innerH;
const durSec = (tRange / 1e9).toFixed(1);
detailChart.innerHTML = `
${phaseBands.join('')}
<line class="axis" x1="${pad.l}" y1="${pad.t}" x2="${pad.l}" y2="${axisY}" />
<line class="axis" x1="${pad.l}" y1="${axisY}" x2="${W - pad.r}" y2="${axisY}" />
<text class="tick" x="${pad.l - 4}" y="${pad.t + 8}" text-anchor="end">peak</text>
<text class="tick" x="${pad.l - 4}" y="${axisY}" text-anchor="end">0</text>
<text class="tick" x="${pad.l}" y="${axisY + 14}" text-anchor="start">0 s</text>
<text class="tick" x="${W - pad.r}" y="${axisY + 14}" text-anchor="end">${durSec} s</text>
<path class="metric-line" d="${cpuPath}" stroke="var(--c1)" />
<path class="metric-line" d="${ioPath}" stroke="var(--c2)" />
`;
const phaseList = Array.from(phasesUsed).map(p => {
const v = p === 'infected_running' ? 'phase-running' : `phase-${p}`;
return `<span><span class="swatch" style="background:var(--${v})"></span>${p}</span>`;
}).join('');
detailLegend.innerHTML = `
<span><span class="swatch" style="background:var(--c1)"></span>cpu jiffies / interval</span>
<span><span class="swatch" style="background:var(--c2)"></span>io bytes / interval</span>
${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 = `
<div class="profile-name"></div>
<div class="profile-shape"></div>
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"><path d=""></path></svg>`;
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'));
}
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 = `
<div class="model-name">${model}</div>
<div class="model-track"><div class="model-fill ${model}" style="width:0%"></div></div>
<div class="model-acc">0.000</div>`;
root.appendChild(row);
const entry = { row, fill: row.querySelector('.model-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 - 0.5) / 0.5));
r.fill.style.width = (visible * 100).toFixed(1) + '%';
r.acc.textContent = accuracy.toFixed(3);
}
on('demo_start', () => {
// KNN sits below the recurrent/transformer family; it memorizes
// the train host's feature space and generalizes worse than a
// model that learned temporal structure. Bar visible-scale starts
// at 0.5 so the real cross-host F1 (~0.43) reads as 0% — that's
// honest, just visually flat. Demo value here is the in-distribution
// ballpark for a healthier display.
[ ['knn', 0.736], ['rnn', 0.872], ['gru', 0.911], ['lstm', 0.928], ['bert', 0.954] ]
.forEach(([m, a]) => render(m, a));
});
on('demo_stop', () => { rows.clear(); emptyState(); });
on('model_metric', m => {
if (!m.model || typeof m.accuracy !== 'number') return;
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 = '<span style="opacity:.6">no cluster ids in current points</span>';
return;
}
ids.forEach(id => {
const li = document.createElement('span');
li.innerHTML = `<span class="swatch" style="background:${clusterColor(id)}"></span>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 = `<span class="swatch" style="background:var(--${v})"></span>${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();
}
on('demo_start', loadSynthetic);
on('demo_stop', () => { points.length = 0; resetStats(); rebuildLegend(); });
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();
})();
// ── Live detections (scene: live) ─────────────────────────────
// Per-host swim lanes of recent prediction cells, plus a "latest
// detection" callout. New cells push in from the right; lane caps
// at MAX_CELLS so memory stays bounded across long sessions. When
// the producer also sends `actual`, mismatch cells get a hatched
// overlay and the running hit-rate updates.
(function () {
const lanesEl = document.getElementById('live-lanes');
const latestEl = document.getElementById('live-latest');
const statsHosts = document.getElementById('live-stats-hosts');
const statsRate = document.getElementById('live-stats-rate');
const statsModel = document.getElementById('live-stats-model');
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 lastModel = null;
function ensureLane(hostId) {
if (lanes.has(hostId)) return lanes.get(hostId);
const row = document.createElement('div');
row.className = 'live-lane';
row.innerHTML = `
<div class="live-lane-host" title="${hostId}">${hostId}</div>
<div class="live-lane-cells"></div>`;
lanesEl.appendChild(row);
const lane = { row, cellsEl: row.querySelector('.live-lane-cells'), cells: [] };
lanes.set(hostId, lane);
return lane;
}
function paintCell(d) {
const lane = ensureLane(d.host_id);
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.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
? `<span style="color: var(--phase-clean)">✓ matches ground truth</span>`
: `truth: <code>${d.actual}</code> (model disagrees)`)
: `truth: <span style="color: var(--fg-mute)">awaiting label</span>`;
const phaseLabel = (d.predicted || '').replace('_', '<br>');
latestEl.innerHTML = `
<div class="live-phase-block ${d.predicted}">${phaseLabel}</div>
<div class="live-meta">
<div class="live-meta-host">${d.host_id}</div>
<div class="live-meta-line">profile: <code>${d.profile ?? '—'}</code></div>
<div class="live-meta-line">model: <code>${d.model ?? '—'}</code>${
d.latency_ms != null ? ` · ${d.latency_ms.toFixed(0)} ms` : ''
}</div>
<div class="live-meta-line">${truthLine}</div>
</div>
<div class="live-conf">
<span class="live-conf-label">confidence</span>
${conf}
</div>`;
}
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);
statsHosts.textContent = `${lanes.size} host${lanes.size === 1 ? '' : 's'}`;
statsRate.textContent = `${rate} / sec`;
statsModel.textContent = `model: ${lastModel ?? '—'}`;
statsAcc.textContent = totalLabeled > 0
? `hit-rate: ${(100 * totalCorrect / totalLabeled).toFixed(0)}% (${totalCorrect}/${totalLabeled})`
: `hit-rate: —`;
}
setInterval(updateStats, 500);
function handleDetection(d) {
if (!d.host_id || !d.predicted) return;
eventTimes.push(Date.now());
if (d.model) lastModel = d.model;
if (d.actual) {
totalLabeled++;
if (d.actual === d.predicted) totalCorrect++;
}
paintCell(d);
paintLatest(d);
}
on('live_detection', handleDetection);
// Synthetic demo: 5 hosts, walk through phases, ~92% accuracy.
let demoTimer = null;
function demoStart() {
if (demoTimer) clearInterval(demoTimer);
const HOSTS = [
{ id: 'elliott-lab', profile: 'cpu-saturate', phaseIdx: 0 },
{ id: 'elliott-thinkpad', profile: 'ransomware-lite', phaseIdx: 0 },
{ id: 'k-gamingcom', profile: 'bursty-c2', phaseIdx: 1 },
{ id: 'smelliott', profile: 'fork-bomb', phaseIdx: 0 },
{ id: 'gosling', profile: 'crypto-miner', phaseIdx: 2 },
];
const PHASES = ['clean', 'armed', 'infecting', 'infected_running', 'dormant'];
let counter = 0;
demoTimer = setInterval(() => {
const host = HOSTS[counter % HOSTS.length];
counter++;
if (Math.random() < 0.18) host.phaseIdx = (host.phaseIdx + 1) % PHASES.length;
const truth = PHASES[host.phaseIdx];
const right = Math.random() < 0.92;
const predicted = right
? truth
: PHASES[(host.phaseIdx + 1 + Math.floor(Math.random() * 4)) % PHASES.length];
handleDetection({
host_id: host.id, profile: host.profile,
predicted, actual: truth,
confidence: 0.62 + Math.random() * 0.36,
model: 'lstm',
latency_ms: 3 + Math.random() * 4,
episode_id: 'demo',
window_idx: counter,
t_wall: Date.now() / 1000,
});
}, 280);
}
function demoStop() {
if (demoTimer) { clearInterval(demoTimer); demoTimer = null; }
lanes.forEach(l => l.row.remove());
lanes.clear();
eventTimes.length = 0;
totalCorrect = 0; totalLabeled = 0;
lastModel = null;
latestEl.innerHTML = '<div class="live-latest-empty">awaiting <code>live_detection</code> events from the inference loop</div>';
updateStats();
}
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/<filename>. 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 =
'<p class="awaiting">no description for this reference yet — drop a sidecar &lt;stem&gt;.md next to the PDF in /opt/cis490/references/</p>';
return;
}
// Escape HTML first so user content can't inject markup.
let s = md.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Inline: bold, italic, code.
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>')
.replace(/`([^`\n]+)`/g, '<code>$1</code>');
// 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 `<h2>${stripped.slice(2)}</h2>`;
if (stripped.startsWith('## ')) return `<h2>${stripped.slice(3)}</h2>`;
if (stripped.startsWith('### ')) return `<h3>${stripped.slice(4)}</h3>`;
const lines = stripped.split('\n');
if (lines.every(l => /^[-*]\s/.test(l))) {
return '<ul>' + lines.map(l => `<li>${l.replace(/^[-*]\s+/, '')}</li>`).join('') + '</ul>';
}
if (lines.every(l => /^\d+\.\s/.test(l))) {
return '<ol>' + lines.map(l => `<li>${l.replace(/^\d+\.\s+/, '')}</li>`).join('') + '</ol>';
}
return `<p>${stripped.replace(/\n/g, '<br>')}</p>`;
});
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 — DEMO ONLY ───────────────────────────
(function () {
const svg = document.getElementById('perf-scatter');
const ns = 'http://www.w3.org/2000/svg';
const W = 600, H = 360;
const xmin = 0, xmax = 4000, ymin = 0.7, ymax = 1.0;
const points = new Map();
function setupAxes() {
svg.innerHTML = '';
const ax = document.createElementNS(ns, 'line');
ax.setAttribute('x1', 50); ax.setAttribute('y1', H - 30);
ax.setAttribute('x2', W - 10); ax.setAttribute('y2', H - 30);
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 - 30);
ay.setAttribute('class', 'axis'); svg.appendChild(ay);
const xl = document.createElementNS(ns, 'text');
xl.setAttribute('x', W / 2); xl.setAttribute('y', H - 8);
xl.setAttribute('class', 'axis-label'); xl.setAttribute('text-anchor', 'middle');
xl.textContent = 'inference latency (μ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 accuracy'; 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 x = 50 + Math.min(1, (latency - xmin) / (xmax - xmin)) * (W - 60);
const y = (H - 30) - Math.max(0, Math.min(1, (accuracy - ymin) / (ymax - ymin))) * (H - 40);
return [x, y];
}
function render(model, latency, accuracy) {
let g = points.get(model);
if (!g) {
if (!points.size) setupAxes();
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);
points.set(model, g);
}
const [px, py] = project(latency, accuracy);
g.querySelector('circle').setAttribute('cx', px.toFixed(1));
g.querySelector('circle').setAttribute('cy', py.toFixed(1));
const t = g.querySelector('text');
t.setAttribute('x', (px + 12).toFixed(1));
t.setAttribute('y', (py + 4).toFixed(1));
t.textContent = model;
}
on('demo_start', () => {
[
{ model: 'knn', latency_us: 90, accuracy: 0.84 },
{ model: 'rnn', latency_us: 380, accuracy: 0.87 },
{ model: 'gru', latency_us: 520, accuracy: 0.91 },
{ model: 'lstm', latency_us: 700, accuracy: 0.93 },
{ model: 'bert', latency_us: 3200, accuracy: 0.95 },
].forEach(p => render(p.model, p.latency_us, p.accuracy));
});
on('demo_stop', emptyState);
on('model_perf', m => {
if (!m.model || typeof m.latency_us !== 'number' || typeof m.accuracy !== 'number') return;
render(m.model, m.latency_us, m.accuracy);
});
emptyState();
})();
})();