CIS490/training/dashboard/static/dashboard.js
Max Gorog f537ab8686 models scene: paint the knn bar (CSS color + demo entry)
The model-bar widget rendered .model-fill.knn with no gradient when
a model_metric{model:"knn"} arrived, leaving an empty track. Add a
green gradient and include knn in the demo-mode set so the row is
visible without waiting on the producer.

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

2034 lines
85 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);
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;
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;
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;
const HOSTS = ['elliott-lab', 'elliott-thinkpad', 'k-gamingcom'];
const PHASES = ['clean', 'armed', 'infecting', 'infected_running', 'dormant'];
function demoTick() {
if (Math.random() < 0.7) {
const host = HOSTS[Math.floor(Math.random() * HOSTS.length)];
dispatch({ type: 'episode',
episode_id: 'demo-' + Math.random().toString(36).slice(2, 12),
host_id: host, size_bytes: 30_000 + Math.random() * 20_000 });
}
if (Math.random() < 0.5) {
dispatch({ type: 'phase', phase: PHASES[Math.floor(Math.random() * PHASES.length)] });
}
// Occasionally tweak a model metric so the bars aren't static.
if (Math.random() < 0.05) {
const m = ['rnn', 'gru', 'lstm', 'bert'][Math.floor(Math.random() * 4)];
const base = { 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 = `[project]
name = "cis490"
description = "CIS490 behavioral malware detection — dataset, transport, training"
requires-python = ">=3.11"
dependencies = [
"starlette>=0.36",
"uvicorn[standard]>=0.27",
"msgpack>=1.0", # MSF RPC wire format for the Tier-3 exploit driver
"pycdlib>=1.14", # build NoCloud cidata ISOs in pure Python
]
[dependency-groups]
dev = [
"pytest>=8",
"pytest-asyncio>=0.23",
"httpx>=0.27",
"paramiko>=3", # SSH client for in-guest control on images that support it
]
`;
const RECEIVER = `from __future__ import annotations
import json
import logging
import secrets
import time
from pathlib import Path
from typing import Awaitable, Callable
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.routing import Route
from .store import EpisodeStore, is_valid_id
from .version_gate import VersionGate
log = logging.getLogger("cis490.receiver")
`;
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 = `"""Train PhaseLSTM on the windowed dataset.
Each window is 10 s of /proc telemetry (100 samples × 12 channels)
labeled with the phase that occupies its center. The LSTM reads the
window timestep-by-timestep and predicts a single phase.
Held-out *samples* — not held-out time slices — are the bar that
matters. Generalization to malware the model has never seen is the
whole reason this dataset exists.
"""
from __future__ import annotations
import torch
import torch.nn as nn
from torch.utils.data import DataLoader
from training.data.windows import WindowedEpisodes
from training.models.lstm import PhaseLSTM
ds = WindowedEpisodes("train", window_s=10, hz=10)
loader = DataLoader(ds, batch_size=128, shuffle=True)
model = PhaseLSTM(channels=12, hidden=64, num_phases=5).cuda()
optim = torch.optim.AdamW(model.parameters(), lr=3e-4)
loss_fn = nn.CrossEntropyLoss()
for epoch in range(20):
for x, y in loader:
loss = loss_fn(model(x.cuda()), y.cuda())
optim.zero_grad()
loss.backward()
optim.step()
`;
document.getElementById('code-pyproject').innerHTML = highlightToml(PYPROJECT);
document.getElementById('code-receiver').innerHTML = highlightPython(RECEIVER);
document.getElementById('code-train-lstm').innerHTML = highlightPython(TRAINER);
})();
// ── 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() : '—'; }
function applyMix(mix) {
if (!mix) return;
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_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()) },
];
}
on('demo_start', () => syntheticProfiles().forEach(render));
on('demo_stop', () => clearAll());
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);
// (x,y,z) ∈ [0,1]³ → canvas pixels: rotateY then rotateX,
// perspective from a fixed camera distance.
function project(p) {
const x = (p.x ?? 0.5) - 0.5;
const y = (p.y ?? 0.5) - 0.5;
const z = (p.z ?? 0.5) - 0.5;
const cy_ = Math.cos(rotY), sy_ = Math.sin(rotY);
const cx_ = Math.cos(rotX), sx_ = Math.sin(rotX);
const x1 = x * cy_ + z * sy_;
const z1 = -x * sy_ + z * cy_;
const y2 = y * cx_ - z1 * sx_;
const z2 = y * 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.4;
return {
sx: w / 2 + x1 * span * persp,
sy: h / 2 + y2 * span * persp,
depth: z2,
scale: persp,
};
}
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() {
const corners = [];
for (let i = 0; i < 8; i++) {
corners.push(project({
x: (i & 1) ? 1 : 0,
y: (i & 2) ? 1 : 0,
z: (i & 4) ? 1 : 0,
}));
}
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;
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;
points.push({
x: cx + rand() * 0.18,
y: cy + rand() * 0.18,
z: cz + rand() * 0.18,
phase: p,
predicted,
cluster: idx,
});
}
});
rebuildLegend();
}
on('demo_start', loadSynthetic);
on('demo_stop', () => { points.length = 0; rebuildLegend(); });
on('embedding', m => {
if (typeof m.x !== 'number' || typeof m.y !== 'number') return;
points.push({
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,
});
rebuildLegend();
});
rebuildLegend();
})();
// ── 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();
})();
})();