CIS490/training/dashboard/static/dashboard.js
Max Gorog a04bba6281 training/dashboard: click a db row → render the episode envelope
New endpoint GET /api/episode/<host_id>/<episode_id> in app.py.
Stream-decompresses the tarball (zstd -dc piped into tarfile),
extracts telemetry-proc.jsonl, labels.jsonl, and meta.json,
returns the parsed contents. Synchronous extract runs in
asyncio.to_thread so the event loop isn't blocked.

Frontend: clicking a row in the database explorer now fetches
the episode and draws an SVG chart matching the README's Real
Alpine VM envelope shape:
  - per-interval CPU jiffies delta (user + sys)
  - per-interval IO bytes delta (read + write)
  - colored phase bands (clean/armed/infecting/infected_running/
    dormant) overlaid by labels.jsonl
  - axis ticks for 0-peak on Y, 0-totalDuration in seconds on X
  - legend below the chart with palette-driven swatches

The detail panel that previously showed the row JSON now shows
metadata + the chart + the legend. Validated end-to-end against
a real episode (863 samples, 8 labels) extracted from
/var/lib/cis490/episodes/elliott-thinkpad/.
2026-05-08 01:16:54 -05:00

1709 lines
72 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}%`);
root.setProperty('--theme-c', state.C);
root.setProperty('--theme-h', 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 (rolling 5 min) ─────────────────────────────────
// Real-data widget. Will be empty until phase events flow.
(function () {
const stack = document.getElementById('phase-stack');
const legend = document.getElementById('phase-legend');
const PHASES = ['clean', 'armed', 'infecting', 'infected_running', 'dormant'];
const WINDOW_MS = 5 * 60 * 1000;
const samples = [];
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 render() {
const now = Date.now();
while (samples.length && now - samples[0].t > WINDOW_MS) samples.shift();
const counts = Object.fromEntries(PHASES.map(p => [p, 0]));
samples.forEach(s => { if (counts[s.phase] !== undefined) counts[s.phase]++; });
const total = Math.max(1, samples.length);
PHASES.forEach(p => { segs.get(p).style.flexGrow = (counts[p] / total).toFixed(4); });
}
on('phase', m => {
if (!m.phase) return;
samples.push({ phase: m.phase, t: Date.now() }); render();
});
on('demo_stop', () => { samples.length = 0; render(); });
setInterval(render, 1000);
})();
// ── 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', () => {
[ ['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 scatter — DEMO ONLY ───────────────────────────────────
(function () {
const svg = document.getElementById('knn-scatter');
const legend = document.getElementById('knn-legend');
const PHASES = ['clean', 'armed', 'infecting', 'infected_running', 'dormant'];
const phaseCenters = {
clean: [0.18, 0.75],
armed: [0.42, 0.58],
infecting: [0.72, 0.40],
infected_running: [0.85, 0.18],
dormant: [0.30, 0.30],
};
const ns = 'http://www.w3.org/2000/svg';
let W = 600, H = 360;
legend.innerHTML = '';
PHASES.forEach(p => {
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 setupAxes() {
svg.innerHTML = '';
const ax = document.createElementNS(ns, 'line');
ax.setAttribute('x1', 40); 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', 40); ay.setAttribute('y1', 10);
ay.setAttribute('x2', 40); 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 = 'feature 1'; svg.appendChild(xl);
const yl = document.createElementNS(ns, 'text');
yl.setAttribute('transform', `translate(12,${H/2}) rotate(-90)`);
yl.setAttribute('class', 'axis-label'); yl.setAttribute('text-anchor', 'middle');
yl.textContent = 'feature 2'; svg.appendChild(yl);
}
function emptyState() {
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 embedding events · turn demo on for examples';
svg.appendChild(t);
}
function project(x, y) {
const px = 40 + x * (W - 50);
const py = (H - 30) - y * (H - 40);
return [px, py];
}
function addPoint(x, y, phase) {
const c = document.createElementNS(ns, 'circle');
const [px, py] = project(x, y);
c.setAttribute('cx', px.toFixed(1)); c.setAttribute('cy', py.toFixed(1));
c.setAttribute('r', 4);
c.setAttribute('class', `point ${phase}`);
svg.appendChild(c);
}
function syntheticPoints() {
let seed = 7;
const rand = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return ((seed & 0xffff) / 0xffff) - 0.5; };
setupAxes();
PHASES.forEach(p => {
const [cx, cy] = phaseCenters[p];
for (let i = 0; i < 14; i++) addPoint(cx + rand() * 0.16, cy + rand() * 0.16, p);
});
}
on('demo_start', syntheticPoints);
on('demo_stop', emptyState);
on('embedding', m => {
if (typeof m.x !== 'number' || typeof m.y !== 'number' || !m.phase) return;
// First real embedding wipes the awaiting note and sets up axes.
if (svg.querySelector('.axis-label') && !svg.querySelector('.point')) setupAxes();
addPoint(m.x, m.y, m.phase);
});
emptyState();
})();
// ── 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();
})();
})();