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