New endpoint GET /api/episode/<host_id>/<episode_id> in app.py.
Stream-decompresses the tarball (zstd -dc piped into tarfile),
extracts telemetry-proc.jsonl, labels.jsonl, and meta.json,
returns the parsed contents. Synchronous extract runs in
asyncio.to_thread so the event loop isn't blocked.
Frontend: clicking a row in the database explorer now fetches
the episode and draws an SVG chart matching the README's Real
Alpine VM envelope shape:
- per-interval CPU jiffies delta (user + sys)
- per-interval IO bytes delta (read + write)
- colored phase bands (clean/armed/infecting/infected_running/
dormant) overlaid by labels.jsonl
- axis ticks for 0-peak on Y, 0-totalDuration in seconds on X
- legend below the chart with palette-driven swatches
The detail panel that previously showed the row JSON now shows
metadata + the chart + the legend. Validated end-to-end against
a real episode (863 samples, 8 labels) extracted from
/var/lib/cis490/episodes/elliott-thinkpad/.
1709 lines
72 KiB
JavaScript
1709 lines
72 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);
|
||
|
||
let activeIdx = -1;
|
||
function setActiveIdx(idx) {
|
||
if (idx === activeIdx) return;
|
||
if (activeIdx >= 0) {
|
||
const prevName = scenes[activeIdx].dataset.stage;
|
||
const view = stageViews.get(prevName);
|
||
if (view) view.removeAttribute('data-active');
|
||
const fn = sceneExitHandlers.get(prevName);
|
||
if (fn) try { fn(); } catch (e) { console.error(e); }
|
||
scenes[activeIdx].removeAttribute('data-active');
|
||
}
|
||
activeIdx = idx;
|
||
sceneIdxEl.textContent = String(idx + 1);
|
||
const name = scenes[idx].dataset.stage;
|
||
const view = stageViews.get(name);
|
||
if (view) view.setAttribute('data-active', '');
|
||
scenes[idx].setAttribute('data-active', '');
|
||
const fn = sceneEnterHandlers.get(name);
|
||
if (fn) try { fn(); } catch (e) { console.error(e); }
|
||
prevBtn.disabled = idx === 0;
|
||
nextBtn.disabled = idx === scenes.length - 1;
|
||
if (fab) fab.disabled = idx === scenes.length - 1;
|
||
}
|
||
|
||
const sceneRatios = new Map();
|
||
const io = new IntersectionObserver(entries => {
|
||
entries.forEach(e => sceneRatios.set(e.target, e.intersectionRatio));
|
||
let bestIdx = activeIdx >= 0 ? activeIdx : 0;
|
||
let bestRatio = -1;
|
||
scenes.forEach((s, i) => {
|
||
const r = sceneRatios.get(s) || 0;
|
||
if (r > bestRatio) { bestRatio = r; bestIdx = i; }
|
||
});
|
||
setActiveIdx(bestIdx);
|
||
}, {
|
||
threshold: [0, 0.25, 0.5, 0.75, 1],
|
||
rootMargin: '-30% 0px -30% 0px',
|
||
});
|
||
scenes.forEach(s => io.observe(s));
|
||
setActiveIdx(0);
|
||
|
||
function scrollToScene(idx) {
|
||
idx = Math.max(0, Math.min(scenes.length - 1, idx));
|
||
if (idx === activeIdx) return;
|
||
scenes[idx].scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
function next() { scrollToScene(activeIdx + 1); }
|
||
function prev() { scrollToScene(activeIdx - 1); }
|
||
|
||
window.addEventListener('keydown', e => {
|
||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||
const tag = (e.target && e.target.tagName) || '';
|
||
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target && e.target.isContentEditable)) return;
|
||
switch (e.key) {
|
||
case 'ArrowDown': case 'ArrowRight': case 'PageDown': case 'j': case ' ':
|
||
e.preventDefault(); next(); break;
|
||
case 'ArrowUp': case 'ArrowLeft': case 'PageUp': case 'k':
|
||
e.preventDefault(); prev(); break;
|
||
case 'Home': e.preventDefault(); scrollToScene(0); break;
|
||
case 'End': e.preventDefault(); scrollToScene(scenes.length - 1); break;
|
||
case 'c': case 'C': e.preventDefault(); setClickNav(!clickNavOn); break;
|
||
}
|
||
});
|
||
prevBtn.addEventListener('click', prev);
|
||
nextBtn.addEventListener('click', next);
|
||
if (fab) fab.addEventListener('click', next);
|
||
|
||
// Click-on-stage to advance — gated by the click-nav toggle so
|
||
// interactive widgets (db table rows, search boxes) don't compete
|
||
// with the next-slide gesture by default. Topbar arrows / FAB /
|
||
// hotkeys always work regardless.
|
||
const clickNavBtn = document.getElementById('click-nav-btn');
|
||
let clickNavOn = false;
|
||
function setClickNav(on) {
|
||
clickNavOn = on;
|
||
clickNavBtn.textContent = `click-nav: ${on ? 'on' : 'off'}`;
|
||
clickNavBtn.classList.toggle('active', on);
|
||
}
|
||
clickNavBtn.addEventListener('click', e => { e.stopPropagation(); setClickNav(!clickNavOn); });
|
||
setClickNav(false);
|
||
|
||
const stageCol = document.getElementById('stage-col');
|
||
stageCol.addEventListener('click', e => {
|
||
if (!clickNavOn) return;
|
||
if (e.target.closest('[data-no-advance]')) return;
|
||
if (e.target.closest('button, a, input, select, textarea')) return;
|
||
next();
|
||
});
|
||
|
||
// ────────────────────────────────────────────────────────────────
|
||
// 3. Public API + demo machinery
|
||
// ────────────────────────────────────────────────────────────────
|
||
window.dashboard = { on, send, scene: onScene, dispatch, next, prev, scrollToScene };
|
||
|
||
const demoBtn = document.getElementById('demo-btn');
|
||
let demoTimer = null;
|
||
let demoActive = false;
|
||
const HOSTS = ['elliott-lab', 'elliott-thinkpad', 'k-gamingcom'];
|
||
const PHASES = ['clean', 'armed', 'infecting', 'infected_running', 'dormant'];
|
||
function demoTick() {
|
||
if (Math.random() < 0.7) {
|
||
const host = HOSTS[Math.floor(Math.random() * HOSTS.length)];
|
||
dispatch({ type: 'episode',
|
||
episode_id: 'demo-' + Math.random().toString(36).slice(2, 12),
|
||
host_id: host, size_bytes: 30_000 + Math.random() * 20_000 });
|
||
}
|
||
if (Math.random() < 0.5) {
|
||
dispatch({ type: 'phase', phase: PHASES[Math.floor(Math.random() * PHASES.length)] });
|
||
}
|
||
// Occasionally tweak a model metric so the bars aren't static.
|
||
if (Math.random() < 0.05) {
|
||
const m = ['rnn', 'gru', 'lstm', 'bert'][Math.floor(Math.random() * 4)];
|
||
const base = { rnn: 0.872, gru: 0.911, lstm: 0.928, bert: 0.954 }[m];
|
||
dispatch({ type: 'model_metric', model: m, accuracy: base + (Math.random() - 0.5) * 0.012 });
|
||
}
|
||
}
|
||
function setDemo(active) {
|
||
if (demoActive === active) return;
|
||
demoActive = active;
|
||
if (active) {
|
||
dispatch({ type: 'demo_start' });
|
||
demoTimer = setInterval(demoTick, 350);
|
||
demoBtn.textContent = 'demo: on'; demoBtn.classList.add('active');
|
||
} else {
|
||
if (demoTimer) clearInterval(demoTimer); demoTimer = null;
|
||
dispatch({ type: 'demo_stop' });
|
||
demoBtn.textContent = 'demo: off'; demoBtn.classList.remove('active');
|
||
}
|
||
}
|
||
demoBtn.addEventListener('click', e => { e.stopPropagation(); setDemo(!demoActive); });
|
||
|
||
// Helper for empty-state rows.
|
||
function awaitingNote(text) {
|
||
const d = document.createElement('div');
|
||
d.className = 'awaiting'; d.textContent = text;
|
||
return d;
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────
|
||
// 3.5. Theme machinery (OKLCH palette · continuous harmony · 5 BGs)
|
||
// ────────────────────────────────────────────────────────────────
|
||
// Harmony is parameterized continuously by `count` (1..6 colors)
|
||
// and `spread` (0..300° angular range). Together they cover every
|
||
// named scheme: mono = count 1; complementary = count 2 spread 180;
|
||
// analogous = count 3-5 with low spread; triadic = count 3 spread
|
||
// 240; tetradic = count 4 spread 270; etc. Markers fan symmetrically
|
||
// around the primary so a 60° spread with 3 colors is [0, +30, -30].
|
||
// Any marker can be dragged individually to break the symmetry.
|
||
//
|
||
// Animations across the bg-canvas read --anim-speed; the bg blur
|
||
// reads --bg-blur. State persists in localStorage.
|
||
(function () {
|
||
// Per-theme settings spec. Each entry becomes a slider in the
|
||
// panel under the active-theme section. `step` rounds the value
|
||
// and decides how many decimals to show.
|
||
const THEMES = {
|
||
drift: {
|
||
label: 'drift settings',
|
||
params: {
|
||
count: { default: 6, min: 3, max: 12, step: 1, label: 'blobs', rebuild: true },
|
||
sizeVw: { default: 36, min: 12, max: 60, step: 1, label: 'size (vw)', rebuild: true },
|
||
blur: { default: 70, min: 20, max: 150, step: 1, label: 'blur (px)' },
|
||
opacity: { default: 0.55, min: 0.1, max: 1, step: 0.05, label: 'opacity' },
|
||
},
|
||
},
|
||
lava: {
|
||
label: 'lava settings',
|
||
params: {
|
||
count: { default: 8, min: 4, max: 16, step: 1, label: 'bubbles', rebuild: true },
|
||
sizeMean: { default: 9, min: 3, max: 18, step: 0.5, label: 'mean size (vw)', rebuild: true },
|
||
sizeSpread: { default: 4, min: 0, max: 8, step: 0.5, label: 'size σ (spread)', rebuild: true },
|
||
gooStr: { default: 26, min: 8, max: 50, step: 1, label: 'merge strength' },
|
||
gooBlur: { default: 22, min: 8, max: 40, step: 1, label: 'merge blur (σ)' },
|
||
},
|
||
},
|
||
vaporwave: {
|
||
label: 'vaporwave settings',
|
||
params: {
|
||
gridSize: { default: 80, min: 30, max: 200, step: 5, label: 'grid cell (px)' },
|
||
perspective: { default: 62, min: 30, max: 80, step: 1, label: 'horizon angle (°)' },
|
||
sunSize: { default: 50, min: 20, max: 80, step: 1, label: 'sun size (vmin)' },
|
||
horizonPct: { default: 55, min: 35, max: 70, step: 1, label: 'horizon position (%)' },
|
||
blindWidth: { default: 11, min: 6, max: 30, step: 1, label: 'blind width (px)' },
|
||
},
|
||
},
|
||
laser: {
|
||
label: 'laser settings',
|
||
params: {
|
||
count: { default: 5, min: 2, max: 12, step: 1, label: 'beams', rebuild: true },
|
||
thickness: { default: 3, min: 1, max: 12, step: 1, label: 'thickness (px)' },
|
||
blur: { default: 2, min: 0, max: 10, step: 1, label: 'blur (px)' },
|
||
opacity: { default: 0.55, min: 0.1, max: 1, step: 0.05, label: 'opacity' },
|
||
},
|
||
},
|
||
};
|
||
|
||
function defaultThemeSettings() {
|
||
const out = {};
|
||
for (const t in THEMES) {
|
||
out[t] = {};
|
||
for (const p in THEMES[t].params) {
|
||
out[t][p] = THEMES[t].params[p].default;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const DEFAULTS = {
|
||
background: 'black',
|
||
L: 70, C: 0.15, H: 250,
|
||
count: 3,
|
||
spread: 60,
|
||
offsets: null,
|
||
lVar: 0,
|
||
cVar: 0,
|
||
animSpeed: 1.0,
|
||
bgBlur: 0,
|
||
tint: 0.10,
|
||
contentBackdrop: 0.30, // 0 = fully transparent foreground; 1 = fully opaque
|
||
themes: defaultThemeSettings(),
|
||
};
|
||
const state = Object.assign({}, DEFAULTS);
|
||
|
||
// Even, symmetric distribution: primary at 0, siblings fan
|
||
// alternately to +/- around it. e.g. n=4 spread=270 → [0, 90,
|
||
// -90, 180] (which is tetradic, just stated symmetrically).
|
||
function evenOffsets(n, spread) {
|
||
const r = [0];
|
||
if (n <= 1) return r;
|
||
const step = spread / (n - 1);
|
||
for (let i = 1; i < n; i++) {
|
||
const sign = i % 2 === 1 ? 1 : -1;
|
||
const k = Math.ceil(i / 2);
|
||
r.push(sign * k * step);
|
||
}
|
||
return r;
|
||
}
|
||
|
||
try {
|
||
const stored = JSON.parse(localStorage.getItem('cis490-theme') || '{}');
|
||
delete stored.harmony; // discrete-harmony field (gone)
|
||
Object.assign(state, stored);
|
||
if (!Array.isArray(state.offsets) || state.offsets.length !== state.count) {
|
||
state.offsets = evenOffsets(state.count, state.spread);
|
||
}
|
||
if (typeof state.spread === 'number' && state.spread <= 5) {
|
||
state.spread = 60;
|
||
state.offsets = evenOffsets(state.count, state.spread);
|
||
}
|
||
// Merge in theme defaults for any missing per-theme params
|
||
// (state shape grows over time as new sliders ship).
|
||
const fresh = defaultThemeSettings();
|
||
state.themes = state.themes || {};
|
||
for (const t in fresh) {
|
||
state.themes[t] = Object.assign({}, fresh[t], state.themes[t] || {});
|
||
}
|
||
} catch {}
|
||
if (!state.offsets) state.offsets = evenOffsets(state.count, state.spread);
|
||
|
||
const $ = id => document.getElementById(id);
|
||
const els = {
|
||
bg: $('theme-bg'),
|
||
L: $('theme-l'), Lv: $('theme-l-val'),
|
||
C: $('theme-c'), Cv: $('theme-c-val'),
|
||
H: $('theme-h'), Hv: $('theme-h-val'),
|
||
count: $('theme-count'), countV: $('theme-count-val'),
|
||
spread: $('theme-spread'), spreadV: $('theme-spread-val'),
|
||
hint: $('theme-harmony-hint'),
|
||
lvar: $('theme-lvar'), lvarV: $('theme-lvar-val'),
|
||
cvar: $('theme-cvar'), cvarV: $('theme-cvar-val'),
|
||
speed: $('theme-speed'), speedV: $('theme-speed-val'),
|
||
blur: $('theme-blur'), blurV: $('theme-blur-val'),
|
||
tint: $('theme-tint'), tintV: $('theme-tint-val'),
|
||
backdrop: $('theme-backdrop'), backdropV: $('theme-backdrop-val'),
|
||
swatches: $('theme-swatches'), markers: $('wheel-markers'),
|
||
wheel: $('theme-wheel'), meta: $('theme-meta'),
|
||
panel: $('theme-panel'), btn: $('theme-btn'),
|
||
close: $('theme-close'), reset: $('theme-reset'),
|
||
};
|
||
|
||
// Best-effort name for the current (count, spread) combination —
|
||
// labels purely informational, not used for state.
|
||
function harmonyLabel() {
|
||
const n = state.count, s = state.spread;
|
||
if (n === 1) return 'mono';
|
||
if (n === 2) {
|
||
if (s >= 170 && s <= 190) return 'complementary';
|
||
if (s < 60) return 'split-mono';
|
||
return null;
|
||
}
|
||
if (n === 3) {
|
||
if (s <= 60) return 'analogous';
|
||
if (s >= 230 && s <= 250) return 'triadic';
|
||
if (s >= 170 && s <= 210) return 'split-complementary';
|
||
}
|
||
if (n === 4 && s >= 260 && s <= 280) return 'tetradic';
|
||
return null;
|
||
}
|
||
|
||
// Compute one palette color (OKLCH triple) for index i.
|
||
function colorAt(i) {
|
||
const off = state.offsets[i] || 0;
|
||
const h = (state.H + off + 360) % 360;
|
||
const sign = i === 0 ? 0 : (i % 2 === 1 ? 1 : -1);
|
||
const L = Math.max(5, Math.min(98, state.L + sign * state.lVar));
|
||
const C = Math.max(0, state.C + sign * state.cVar);
|
||
return { L, C, H: h };
|
||
}
|
||
function ok(c) { return `oklch(${c.L}% ${c.C.toFixed(3)} ${c.H.toFixed(1)})`; }
|
||
|
||
function applyCSSVars() {
|
||
const root = document.documentElement.style;
|
||
for (let i = 0; i < 5; i++) {
|
||
const c = colorAt(i % state.offsets.length);
|
||
root.setProperty(`--c${i + 1}`, ok(c));
|
||
}
|
||
const primary = colorAt(0);
|
||
root.setProperty('--accent', ok(primary));
|
||
root.setProperty('--accent-soft',
|
||
`oklch(${primary.L}% ${primary.C.toFixed(3)} ${primary.H.toFixed(1)} / 0.15)`);
|
||
root.setProperty('--theme-l', `${state.L}%`);
|
||
root.setProperty('--theme-c', state.C);
|
||
root.setProperty('--theme-h', state.H);
|
||
root.setProperty('--anim-speed', state.animSpeed);
|
||
// Only apply the filter when there's something to blur — at
|
||
// zero, applying `filter: blur(0px)` still creates a stacking
|
||
// context and forces a compositor layer that can flatten 3D
|
||
// children and produce artifacts on neighboring elements.
|
||
if (state.bgBlur > 0) {
|
||
root.setProperty('--bg-filter', `blur(${state.bgBlur}px)`);
|
||
} else {
|
||
root.removeProperty('--bg-filter');
|
||
}
|
||
root.setProperty('--tint-strength', state.tint);
|
||
root.setProperty('--content-backdrop', state.contentBackdrop);
|
||
document.body.dataset.theme = state.background;
|
||
applyThemeSpecificVars();
|
||
}
|
||
|
||
// Per-theme CSS variables derived from state.themes.<name>. Only
|
||
// the variables for the currently-active theme need to be set,
|
||
// but it's cheap to set them all so the dropdown switch is
|
||
// instant.
|
||
function applyThemeSpecificVars() {
|
||
const root = document.documentElement.style;
|
||
const t = state.themes;
|
||
// drift
|
||
root.setProperty('--drift-blur', `${t.drift.blur}px`);
|
||
root.setProperty('--drift-opacity', t.drift.opacity);
|
||
// vaporwave
|
||
root.setProperty('--vw-grid-size', `${t.vaporwave.gridSize}px`);
|
||
root.setProperty('--vw-perspective', `${t.vaporwave.perspective}deg`);
|
||
root.setProperty('--vw-sun-size', `${t.vaporwave.sunSize}vmin`);
|
||
root.setProperty('--vw-horizon', `${t.vaporwave.horizonPct}%`);
|
||
root.setProperty('--vw-blind', `${t.vaporwave.blindWidth}px`);
|
||
// laser
|
||
root.setProperty('--laser-thickness', `${t.laser.thickness}px`);
|
||
root.setProperty('--laser-blur', `${t.laser.blur}px`);
|
||
root.setProperty('--laser-opacity', t.laser.opacity);
|
||
// lava goo filter — has to be set on the SVG element directly
|
||
const fb = document.querySelector('#goo feGaussianBlur');
|
||
const fc = document.querySelector('#goo feColorMatrix');
|
||
if (fb) fb.setAttribute('stdDeviation', String(t.lava.gooBlur));
|
||
if (fc) {
|
||
const a = t.lava.gooStr;
|
||
const b = Math.round(a / 2 + 1);
|
||
fc.setAttribute('values',
|
||
`1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ${a} -${b}`);
|
||
}
|
||
}
|
||
|
||
function applyForm() {
|
||
els.bg.value = state.background;
|
||
els.L.value = state.L; els.Lv.textContent = state.L;
|
||
els.C.value = state.C; els.Cv.textContent = state.C.toFixed(3);
|
||
els.H.value = state.H; els.Hv.textContent = Math.round(state.H);
|
||
els.count.value = state.count; els.countV.textContent = state.count;
|
||
els.spread.value = state.spread; els.spreadV.textContent = Math.round(state.spread);
|
||
els.lvar.value = state.lVar; els.lvarV.textContent = state.lVar;
|
||
els.cvar.value = state.cVar; els.cvarV.textContent = state.cVar.toFixed(3);
|
||
els.speed.value = state.animSpeed; els.speedV.textContent = state.animSpeed.toFixed(2);
|
||
els.blur.value = state.bgBlur; els.blurV.textContent = state.bgBlur;
|
||
els.tint.value = state.tint; els.tintV.textContent = state.tint.toFixed(2);
|
||
els.backdrop.value = state.contentBackdrop; els.backdropV.textContent = state.contentBackdrop.toFixed(2);
|
||
|
||
const label = harmonyLabel();
|
||
els.hint.textContent = label ? `≈ ${label}` : '· custom';
|
||
|
||
const primary = colorAt(0);
|
||
els.meta.textContent = ok(primary);
|
||
|
||
els.swatches.innerHTML = '';
|
||
for (let i = 0; i < state.offsets.length; i++) {
|
||
const c = colorAt(i);
|
||
const s = document.createElement('div');
|
||
s.className = 'theme-swatch';
|
||
s.style.background = ok(c);
|
||
s.title = ok(c);
|
||
els.swatches.appendChild(s);
|
||
}
|
||
}
|
||
|
||
// Marker rendering: full rebuild (called when offsets array
|
||
// length changes, e.g. on harmony switch) vs. in-place update
|
||
// (called every other time, including during drag — does NOT
|
||
// tear down DOM, so pointer capture stays alive).
|
||
const RADIUS = 84;
|
||
function positionMarker(m, i) {
|
||
const c = colorAt(i);
|
||
const angleRad = (c.H - 90) * Math.PI / 180;
|
||
const x = 100 + RADIUS * Math.cos(angleRad);
|
||
const y = 100 + RADIUS * Math.sin(angleRad);
|
||
m.style.left = x.toFixed(1) + 'px';
|
||
m.style.top = y.toFixed(1) + 'px';
|
||
m.style.background = ok(c);
|
||
}
|
||
function renderMarkersFull() {
|
||
els.markers.innerHTML = '';
|
||
state.offsets.forEach((_, i) => {
|
||
const m = document.createElement('div');
|
||
m.className = 'wheel-marker' + (i === 0 ? ' primary' : '');
|
||
m.dataset.idx = String(i);
|
||
m.title = i === 0
|
||
? 'primary · drag to rotate the whole palette'
|
||
: 'drag to move just this color';
|
||
positionMarker(m, i);
|
||
m.addEventListener('pointerdown', startDrag);
|
||
els.markers.appendChild(m);
|
||
});
|
||
}
|
||
function updateMarkers() {
|
||
const ms = els.markers.querySelectorAll('.wheel-marker');
|
||
ms.forEach((m, i) => positionMarker(m, i));
|
||
}
|
||
|
||
function apply({ rebuildMarkers = false, rebuildBg = false } = {}) {
|
||
applyCSSVars();
|
||
applyForm();
|
||
if (rebuildMarkers) renderMarkersFull();
|
||
else updateMarkers();
|
||
if (rebuildBg) rebuildBackgroundElements();
|
||
showActiveBgSection();
|
||
try { localStorage.setItem('cis490-theme', JSON.stringify(state)); } catch {}
|
||
}
|
||
|
||
// ── Per-theme background element generation ───────────────────
|
||
// drift / lava / laser each have collections of small DOM nodes
|
||
// whose count and individual properties depend on per-theme
|
||
// settings. Rebuild only when count or statistical-spread params
|
||
// change; styling-only updates (blur, opacity, thickness) flow
|
||
// through CSS variables and don't need a rebuild.
|
||
|
||
// Box-Muller for size variance.
|
||
function gauss(mean, std) {
|
||
let u = 0, v = 0;
|
||
while (u === 0) u = Math.random();
|
||
while (v === 0) v = Math.random();
|
||
return mean + std * Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
|
||
}
|
||
|
||
// Graceful removal helpers. retireOnIteration waits for the
|
||
// element's animation to complete its current cycle, then
|
||
// removes it — the "back to translateY(0)" frame of the rise
|
||
// animations is offscreen, so removal is invisible. fadeOut
|
||
// transitions opacity to 0 over 800ms then removes; used for
|
||
// lasers (no offscreen moment in their continuous rotation).
|
||
function retireOnIteration(el) {
|
||
let removed = false;
|
||
const kill = () => { if (!removed) { removed = true; el.remove(); } };
|
||
el.addEventListener('animationiteration', kill, { once: true });
|
||
// Safety net: enforce removal after a generous max so a
|
||
// setting-spam doesn't leak old nodes forever.
|
||
setTimeout(() => { if (el.isConnected) kill(); }, 60_000);
|
||
}
|
||
function fadeOutAndRemove(el) {
|
||
el.style.transition = 'opacity 800ms ease-out';
|
||
requestAnimationFrame(() => { el.style.opacity = '0'; });
|
||
setTimeout(() => { if (el.isConnected) el.remove(); }, 900);
|
||
}
|
||
|
||
function makeDriftBlob(i) {
|
||
const t = state.themes.drift;
|
||
const b = document.createElement('span');
|
||
b.className = 'drift-blob';
|
||
b.style.width = b.style.height = `${t.sizeVw}vw`;
|
||
b.style.left = `${(Math.random() * 90).toFixed(1)}%`;
|
||
b.style.bottom = `-${(15 + Math.random() * 25).toFixed(0)}%`;
|
||
b.style.background = `var(--c${(i % 5) + 1})`;
|
||
const dur = (14 + Math.random() * 16).toFixed(1);
|
||
const delay = (-Math.random() * parseFloat(dur)).toFixed(1);
|
||
b.style.animation =
|
||
`drift-rise calc(${dur}s / var(--anim-speed, 1)) ease-in-out ${delay}s infinite`;
|
||
return b;
|
||
}
|
||
function makeLavaBubble(i) {
|
||
const t = state.themes.lava;
|
||
const b = document.createElement('span');
|
||
b.className = 'goo-bubble';
|
||
const sz = Math.max(2, Math.min(28, gauss(t.sizeMean, t.sizeSpread)));
|
||
b.style.width = b.style.height = `${sz.toFixed(1)}vw`;
|
||
b.style.left = `${(Math.random() * 92).toFixed(1)}%`;
|
||
b.style.bottom = `-${(sz + 4).toFixed(1)}vw`;
|
||
b.style.background = `var(--c${(i % 5) + 1})`;
|
||
const dur = (14 + Math.random() * 14).toFixed(1);
|
||
const delay = (-Math.random() * parseFloat(dur)).toFixed(1);
|
||
b.style.animation =
|
||
`goo-rise calc(${dur}s / var(--anim-speed, 1)) ease-in-out ${delay}s infinite`;
|
||
return b;
|
||
}
|
||
function makeLaserBeam(i) {
|
||
const b = document.createElement('span');
|
||
b.className = 'laser-beam';
|
||
b.style.background =
|
||
`linear-gradient(90deg, transparent, var(--c${(i % 5) + 1}), transparent)`;
|
||
const dur = (8 + Math.random() * 12).toFixed(1);
|
||
const delay = (-Math.random() * parseFloat(dur)).toFixed(1);
|
||
const dir = i % 2 === 0 ? 'normal' : 'reverse';
|
||
b.style.animation =
|
||
`laser-sweep calc(${dur}s / var(--anim-speed, 1)) linear ${delay}s infinite ${dir}`;
|
||
return b;
|
||
}
|
||
|
||
function rebuildDrift() {
|
||
const root = document.getElementById('bg-drift');
|
||
if (!root) return;
|
||
// Phase out existing blobs at end of their current cycle —
|
||
// they finish their visible rise/fall before vanishing instead
|
||
// of all popping out of existence at once.
|
||
Array.from(root.children).forEach(retireOnIteration);
|
||
const t = state.themes.drift;
|
||
for (let i = 0; i < t.count; i++) root.appendChild(makeDriftBlob(i));
|
||
}
|
||
function rebuildLava() {
|
||
const root = document.getElementById('bg-lava-bubbles');
|
||
if (!root) return;
|
||
Array.from(root.children).forEach(retireOnIteration);
|
||
const t = state.themes.lava;
|
||
for (let i = 0; i < t.count; i++) root.appendChild(makeLavaBubble(i));
|
||
}
|
||
function rebuildLaser() {
|
||
const root = document.getElementById('bg-laser-beams');
|
||
if (!root) return;
|
||
// Lasers have no offscreen moment in their rotation, so fade.
|
||
Array.from(root.children).forEach(fadeOutAndRemove);
|
||
const t = state.themes.laser;
|
||
for (let i = 0; i < t.count; i++) root.appendChild(makeLaserBeam(i));
|
||
}
|
||
|
||
function rebuildBackgroundElements() {
|
||
rebuildDrift();
|
||
rebuildLava();
|
||
rebuildLaser();
|
||
}
|
||
|
||
// (Vaporwave floor is now CSS-only again — see the .vw-floor*
|
||
// rules in dashboard.css. Heavy compositor-layer hints
|
||
// prevent the scroll-induced re-rasterization.)
|
||
|
||
// ── Drag handling ──────────────────────────────────────────────
|
||
// Primary marker (idx 0): drag rotates the entire palette
|
||
// (changes state.H). Other markers: drag moves just that
|
||
// marker (changes state.offsets[i]). pointermove is on the
|
||
// captured marker itself, NOT the document, so we keep capture
|
||
// even if the cursor leaves the wheel area.
|
||
let dragMarker = null;
|
||
let dragIdx = -1;
|
||
let dragIsPrimary = false;
|
||
function startDrag(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
dragMarker = e.currentTarget;
|
||
dragIdx = parseInt(dragMarker.dataset.idx, 10);
|
||
dragIsPrimary = (dragIdx === 0);
|
||
dragMarker.classList.add('dragging');
|
||
try { dragMarker.setPointerCapture(e.pointerId); } catch {}
|
||
dragMarker.addEventListener('pointermove', onDrag);
|
||
dragMarker.addEventListener('pointerup', endDrag, { once: true });
|
||
dragMarker.addEventListener('pointercancel', endDrag, { once: true });
|
||
}
|
||
function angleFromCenter(clientX, clientY) {
|
||
const r = els.wheel.getBoundingClientRect();
|
||
const cx = r.left + r.width / 2;
|
||
const cy = r.top + r.height / 2;
|
||
let a = Math.atan2(clientY - cy, clientX - cx) * 180 / Math.PI + 90;
|
||
if (a < 0) a += 360;
|
||
return a;
|
||
}
|
||
function onDrag(e) {
|
||
const angle = angleFromCenter(e.clientX, e.clientY);
|
||
if (dragIsPrimary) {
|
||
state.H = (angle + 360) % 360;
|
||
} else {
|
||
// Marker's absolute hue should equal `angle`; its hue is
|
||
// (H + offset). So offset = angle - H, normalized to -180..180.
|
||
const target = (angle - state.H + 540) % 360 - 180;
|
||
state.offsets[dragIdx] = target;
|
||
}
|
||
apply();
|
||
}
|
||
function endDrag(e) {
|
||
if (!dragMarker) return;
|
||
dragMarker.classList.remove('dragging');
|
||
dragMarker.removeEventListener('pointermove', onDrag);
|
||
try { dragMarker.releasePointerCapture(e.pointerId); } catch {}
|
||
dragMarker = null; dragIdx = -1;
|
||
}
|
||
|
||
// ── Panel show/hide + hotkey ───────────────────────────────────
|
||
// Toggle via the `is-open` class (not the hidden attribute) so
|
||
// the slide-in transform transition is preserved in both
|
||
// directions.
|
||
function togglePanel(force) {
|
||
const willShow = typeof force === 'boolean'
|
||
? force
|
||
: !els.panel.classList.contains('is-open');
|
||
els.panel.classList.toggle('is-open', willShow);
|
||
els.btn.classList.toggle('active', willShow);
|
||
}
|
||
window.addEventListener('keydown', e => {
|
||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||
const tag = (e.target && e.target.tagName) || '';
|
||
if (tag === 'INPUT' || tag === 'TEXTAREA' ||
|
||
(e.target && e.target.isContentEditable)) return;
|
||
if (e.key === 't' || e.key === 'T') {
|
||
e.preventDefault(); togglePanel();
|
||
}
|
||
});
|
||
|
||
els.btn.addEventListener('click', e => { e.stopPropagation(); togglePanel(); });
|
||
els.close.addEventListener('click', e => { e.stopPropagation(); togglePanel(false); });
|
||
els.reset.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
Object.assign(state, DEFAULTS);
|
||
state.offsets = evenOffsets(state.count, state.spread);
|
||
apply({ rebuildMarkers: true });
|
||
});
|
||
|
||
els.bg.addEventListener('change', e => { state.background = e.target.value; apply(); });
|
||
|
||
// ── Per-theme settings panel ──────────────────────────────────
|
||
// Build one section per theme (each a <details>) inside
|
||
// #theme-bg-settings. CSS hides all but the active one (via the
|
||
// `.is-active` class managed by showActiveBgSection).
|
||
const $bgSettings = document.getElementById('theme-bg-settings');
|
||
for (const themeName in THEMES) {
|
||
const spec = THEMES[themeName];
|
||
const section = document.createElement('details');
|
||
section.className = 'theme-advanced theme-bg-section';
|
||
section.dataset.theme = themeName;
|
||
section.open = true;
|
||
|
||
const summary = document.createElement('summary');
|
||
summary.textContent = spec.label;
|
||
section.appendChild(summary);
|
||
|
||
const sliders = document.createElement('div');
|
||
sliders.className = 'theme-sliders';
|
||
for (const key in spec.params) {
|
||
const p = spec.params[key];
|
||
const id = `theme-${themeName}-${key}`;
|
||
const v = state.themes[themeName][key];
|
||
const fmt = p.step >= 1 ? Math.round(v) : v.toFixed(p.step >= 0.1 ? 2 : 3);
|
||
const label = document.createElement('label');
|
||
label.innerHTML =
|
||
`${p.label} · <span id="${id}-val">${fmt}</span>` +
|
||
`<input type="range" id="${id}" min="${p.min}" max="${p.max}" ` +
|
||
`value="${v}" step="${p.step}">`;
|
||
sliders.appendChild(label);
|
||
}
|
||
section.appendChild(sliders);
|
||
$bgSettings.appendChild(section);
|
||
|
||
// Bind inputs after they're in the DOM.
|
||
for (const key in spec.params) {
|
||
const p = spec.params[key];
|
||
const id = `theme-${themeName}-${key}`;
|
||
const input = document.getElementById(id);
|
||
const valSpan = document.getElementById(`${id}-val`);
|
||
if (!input || !valSpan) continue;
|
||
input.addEventListener('input', e => {
|
||
const v = parseFloat(e.target.value);
|
||
state.themes[themeName][key] = v;
|
||
valSpan.textContent = p.step >= 1 ? Math.round(v) :
|
||
v.toFixed(p.step >= 0.1 ? 2 : 3);
|
||
apply({ rebuildBg: !!p.rebuild });
|
||
});
|
||
}
|
||
}
|
||
function showActiveBgSection() {
|
||
document.querySelectorAll('.theme-bg-section').forEach(s => {
|
||
s.classList.toggle('is-active', s.dataset.theme === state.background);
|
||
});
|
||
}
|
||
|
||
// count + spread together regenerate offsets to an evenly-fanned
|
||
// distribution. Any prior per-marker drags are overwritten —
|
||
// the user is asking for a clean palette by touching these.
|
||
function regenAndApply() {
|
||
state.offsets = evenOffsets(state.count, state.spread);
|
||
apply({ rebuildMarkers: true });
|
||
}
|
||
els.count.addEventListener('input', e => {
|
||
state.count = Math.max(1, Math.min(6, parseInt(e.target.value, 10)));
|
||
regenAndApply();
|
||
});
|
||
els.spread.addEventListener('input', e => {
|
||
state.spread = Math.max(0, Math.min(300, parseFloat(e.target.value)));
|
||
regenAndApply();
|
||
});
|
||
|
||
const bind = (input, setter) => input.addEventListener('input',
|
||
e => { setter(parseFloat(e.target.value)); apply(); });
|
||
bind(els.L, v => state.L = v);
|
||
bind(els.C, v => state.C = v);
|
||
bind(els.H, v => state.H = v);
|
||
bind(els.lvar, v => state.lVar = v);
|
||
bind(els.cvar, v => state.cVar = v);
|
||
bind(els.speed, v => state.animSpeed = v);
|
||
bind(els.blur, v => state.bgBlur = v);
|
||
bind(els.tint, v => state.tint = v);
|
||
bind(els.backdrop, v => state.contentBackdrop = v);
|
||
|
||
// Stop panel interactions from bubbling to stage-col click-to-advance.
|
||
els.panel.addEventListener('click', e => e.stopPropagation());
|
||
els.panel.addEventListener('pointerdown', e => e.stopPropagation());
|
||
|
||
apply({ rebuildMarkers: true, rebuildBg: true });
|
||
})();
|
||
|
||
// ────────────────────────────────────────────────────────────────
|
||
// 4. Widgets
|
||
// ────────────────────────────────────────────────────────────────
|
||
|
||
// ── Stack scene · pyproject.toml + receiver/app.py header ─────
|
||
// Static content. The point is to surface the "stdlib-first,
|
||
// every dep annotated" stance to the audience without making them
|
||
// open a terminal.
|
||
(function () {
|
||
const PYPROJECT = `[project]
|
||
name = "cis490"
|
||
description = "CIS490 behavioral malware detection — dataset, transport, training"
|
||
requires-python = ">=3.11"
|
||
dependencies = [
|
||
"starlette>=0.36",
|
||
"uvicorn[standard]>=0.27",
|
||
"msgpack>=1.0", # MSF RPC wire format for the Tier-3 exploit driver
|
||
"pycdlib>=1.14", # build NoCloud cidata ISOs in pure Python
|
||
]
|
||
|
||
[dependency-groups]
|
||
dev = [
|
||
"pytest>=8",
|
||
"pytest-asyncio>=0.23",
|
||
"httpx>=0.27",
|
||
"paramiko>=3", # SSH client for in-guest control on images that support it
|
||
]
|
||
`;
|
||
const RECEIVER = `from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import secrets
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Awaitable, Callable
|
||
|
||
from starlette.applications import Starlette
|
||
from starlette.requests import Request
|
||
from starlette.responses import JSONResponse, Response
|
||
from starlette.routing import Route
|
||
|
||
from .store import EpisodeStore, is_valid_id
|
||
from .version_gate import VersionGate
|
||
|
||
log = logging.getLogger("cis490.receiver")
|
||
`;
|
||
|
||
const PY_KEYWORDS = new Set([
|
||
'from', 'import', 'def', 'class', 'return', 'async', 'await',
|
||
'if', 'else', 'elif', 'for', 'while', 'in', 'as', 'with',
|
||
'lambda', 'None', 'True', 'False', 'raise', 'try', 'except',
|
||
'finally', 'yield', 'global', 'nonlocal', 'pass', 'break',
|
||
'continue', 'not', 'and', 'or', 'is', 'self',
|
||
]);
|
||
|
||
function escapeHtml(s) {
|
||
return s.replace(/&/g, '&').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 = `"""Train PhaseLSTM on the windowed dataset.
|
||
|
||
Each window is 10 s of /proc telemetry (100 samples × 12 channels)
|
||
labeled with the phase that occupies its center. The LSTM reads the
|
||
window timestep-by-timestep and predicts a single phase.
|
||
|
||
Held-out *samples* — not held-out time slices — are the bar that
|
||
matters. Generalization to malware the model has never seen is the
|
||
whole reason this dataset exists.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import torch
|
||
import torch.nn as nn
|
||
from torch.utils.data import DataLoader
|
||
|
||
from training.data.windows import WindowedEpisodes
|
||
from training.models.lstm import PhaseLSTM
|
||
|
||
ds = WindowedEpisodes("train", window_s=10, hz=10)
|
||
loader = DataLoader(ds, batch_size=128, shuffle=True)
|
||
model = PhaseLSTM(channels=12, hidden=64, num_phases=5).cuda()
|
||
optim = torch.optim.AdamW(model.parameters(), lr=3e-4)
|
||
loss_fn = nn.CrossEntropyLoss()
|
||
|
||
for epoch in range(20):
|
||
for x, y in loader:
|
||
loss = loss_fn(model(x.cuda()), y.cuda())
|
||
optim.zero_grad()
|
||
loss.backward()
|
||
optim.step()
|
||
`;
|
||
|
||
document.getElementById('code-pyproject').innerHTML = highlightToml(PYPROJECT);
|
||
document.getElementById('code-receiver').innerHTML = highlightPython(RECEIVER);
|
||
document.getElementById('code-train-lstm').innerHTML = highlightPython(TRAINER);
|
||
})();
|
||
|
||
// ── Ingest counter + 60-second sparkline ──────────────────────
|
||
// Real-data widget: populated by snapshot + episode events. No
|
||
// demo gating — when demo is off, it just shows real activity.
|
||
(function () {
|
||
const totalEl = document.getElementById('ingest-total');
|
||
const rateEl = document.getElementById('ingest-rate');
|
||
const bytesEl = document.getElementById('ingest-bytes');
|
||
const pathEl = document.getElementById('ingest-spark-path');
|
||
const fillEl = document.getElementById('ingest-spark-fill');
|
||
const W = 600, H = 120, BUCKETS = 60;
|
||
const buckets = new Array(BUCKETS).fill(0);
|
||
let total = 0, totalBytes = 0;
|
||
|
||
function bucketIndex() { return Math.floor(Date.now() / 1000) % BUCKETS; }
|
||
let lastBucket = bucketIndex();
|
||
|
||
function rotateIfNeeded() {
|
||
const cur = bucketIndex();
|
||
while (lastBucket !== cur) {
|
||
lastBucket = (lastBucket + 1) % BUCKETS;
|
||
buckets[lastBucket] = 0;
|
||
}
|
||
}
|
||
|
||
function fmtBytes(n) {
|
||
if (!n) return '0 B';
|
||
const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0;
|
||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||
return n.toFixed(n >= 100 || i === 0 ? 0 : 1) + ' ' + u[i];
|
||
}
|
||
|
||
function render() {
|
||
rotateIfNeeded();
|
||
totalEl.textContent = total.toLocaleString();
|
||
const sum = buckets.reduce((a, b) => a + b, 0);
|
||
rateEl.textContent = (sum / BUCKETS).toFixed(1);
|
||
if (bytesEl) bytesEl.textContent = fmtBytes(totalBytes);
|
||
const max = Math.max(1, ...buckets);
|
||
const pts = [];
|
||
for (let i = 0; i < BUCKETS; i++) {
|
||
const idx = (lastBucket + 1 + i) % BUCKETS;
|
||
const x = (i / (BUCKETS - 1)) * W;
|
||
const y = H - (buckets[idx] / max) * (H - 8) - 4;
|
||
pts.push([x, y]);
|
||
}
|
||
const d = pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`).join(' ');
|
||
pathEl.setAttribute('d', d);
|
||
fillEl.setAttribute('d', `${d} L${W},${H} L0,${H} Z`);
|
||
}
|
||
|
||
on('snapshot', m => {
|
||
if (typeof m.total_episodes === 'number') total = m.total_episodes;
|
||
if (typeof m.total_bytes === 'number') totalBytes = m.total_bytes;
|
||
render();
|
||
});
|
||
on('episode', m => {
|
||
rotateIfNeeded();
|
||
buckets[lastBucket] += 1;
|
||
total += 1;
|
||
if (typeof m.size_bytes === 'number') totalBytes += m.size_bytes;
|
||
render();
|
||
});
|
||
setInterval(render, 1000);
|
||
render();
|
||
})();
|
||
|
||
// ── Per-host bars ─────────────────────────────────────────────
|
||
// Real-data widget. Snapshot seeds absolute counts; episode events
|
||
// increment.
|
||
(function () {
|
||
const root = document.getElementById('host-bars');
|
||
const counts = new Map();
|
||
const rows = new Map();
|
||
|
||
function clearEmpty() {
|
||
const e = root.querySelector('.bars-empty, .awaiting');
|
||
if (e) e.remove();
|
||
}
|
||
function ensureRow(host) {
|
||
if (rows.has(host)) return rows.get(host);
|
||
clearEmpty();
|
||
const row = document.createElement('div'); row.className = 'bar-row';
|
||
const name = document.createElement('div'); name.className = 'bar-host'; name.textContent = host;
|
||
const track = document.createElement('div'); track.className = 'bar-track';
|
||
const fill = document.createElement('div'); fill.className = 'bar-fill'; fill.style.width = '0%';
|
||
track.appendChild(fill);
|
||
const label = document.createElement('div'); label.className = 'bar-count'; label.textContent = '0';
|
||
row.append(name, track, label);
|
||
root.appendChild(row);
|
||
const entry = { row, fill, label };
|
||
rows.set(host, entry); return entry;
|
||
}
|
||
function render() {
|
||
const max = Math.max(1, ...counts.values());
|
||
Array.from(counts.keys()).forEach(h => {
|
||
const r = ensureRow(h);
|
||
const c = counts.get(h);
|
||
r.fill.style.width = ((c / max) * 100).toFixed(1) + '%';
|
||
r.label.textContent = c.toLocaleString();
|
||
});
|
||
Array.from(counts.keys()).sort((a, b) => counts.get(b) - counts.get(a))
|
||
.forEach(h => root.appendChild(rows.get(h).row));
|
||
}
|
||
|
||
on('snapshot', m => {
|
||
if (m.host_counts && typeof m.host_counts === 'object') {
|
||
Object.entries(m.host_counts).forEach(([h, c]) => counts.set(h, c));
|
||
render();
|
||
}
|
||
});
|
||
on('episode', m => {
|
||
if (!m.host_id) return;
|
||
counts.set(m.host_id, (counts.get(m.host_id) || 0) + 1); render();
|
||
});
|
||
})();
|
||
|
||
// ── Phase mix (rolling 5 min) ─────────────────────────────────
|
||
// Real-data widget. Will be empty until phase events flow.
|
||
(function () {
|
||
const stack = document.getElementById('phase-stack');
|
||
const legend = document.getElementById('phase-legend');
|
||
const PHASES = ['clean', 'armed', 'infecting', 'infected_running', 'dormant'];
|
||
const WINDOW_MS = 5 * 60 * 1000;
|
||
const samples = [];
|
||
const segs = new Map();
|
||
|
||
PHASES.forEach(p => {
|
||
const seg = document.createElement('div');
|
||
seg.className = `phase-seg ${p}`; stack.appendChild(seg); segs.set(p, seg);
|
||
const li = document.createElement('span');
|
||
const swatchVar = p === 'infected_running' ? 'running' : p;
|
||
li.innerHTML = `<span class="swatch" style="background:var(--phase-${swatchVar})"></span>${p}`;
|
||
legend.appendChild(li);
|
||
});
|
||
|
||
function render() {
|
||
const now = Date.now();
|
||
while (samples.length && now - samples[0].t > WINDOW_MS) samples.shift();
|
||
const counts = Object.fromEntries(PHASES.map(p => [p, 0]));
|
||
samples.forEach(s => { if (counts[s.phase] !== undefined) counts[s.phase]++; });
|
||
const total = Math.max(1, samples.length);
|
||
PHASES.forEach(p => { segs.get(p).style.flexGrow = (counts[p] / total).toFixed(4); });
|
||
}
|
||
|
||
on('phase', m => {
|
||
if (!m.phase) return;
|
||
samples.push({ phase: m.phase, t: Date.now() }); render();
|
||
});
|
||
on('demo_stop', () => { samples.length = 0; render(); });
|
||
setInterval(render, 1000);
|
||
})();
|
||
|
||
// ── Database explorer ─────────────────────────────────────────
|
||
// Real-data widget. Initial population from snapshot.recent_episodes
|
||
// (last 200 lines of index.jsonl). New episodes prepend live.
|
||
(function () {
|
||
const tabsEl = document.getElementById('db-tabs');
|
||
const searchEl = document.getElementById('db-search');
|
||
const tbodyEl = document.getElementById('db-tbody');
|
||
const detailEl = document.getElementById('db-detail');
|
||
const detailMeta = document.getElementById('db-detail-meta');
|
||
const detailChart = document.getElementById('db-detail-chart');
|
||
const detailLegend = document.getElementById('db-detail-legend');
|
||
const countEl = document.getElementById('db-count');
|
||
|
||
let records = []; // newest first
|
||
let activeHost = null; // null = all
|
||
let query = '';
|
||
|
||
function fmtBytes(n) {
|
||
if (!n) return '—';
|
||
const u = ['B', 'KB', 'MB', 'GB']; let i = 0;
|
||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||
return n.toFixed(n >= 100 || i === 0 ? 0 : 1) + ' ' + u[i];
|
||
}
|
||
function fmtTime(iso) {
|
||
if (!iso) return '—';
|
||
try { return new Date(iso).toLocaleTimeString('en-US', { hour12: false }); }
|
||
catch { return iso; }
|
||
}
|
||
function shortId(id) {
|
||
if (!id) return '—';
|
||
return id.length > 24 ? id.slice(0, 16) + '…' + id.slice(-6) : id;
|
||
}
|
||
|
||
function rebuildTabs() {
|
||
const hosts = Array.from(new Set(records.map(r => r.host_id).filter(Boolean))).sort();
|
||
tabsEl.innerHTML = '';
|
||
const all = document.createElement('button');
|
||
all.className = 'db-tab' + (activeHost === null ? ' active' : '');
|
||
all.textContent = `all · ${records.length}`;
|
||
all.addEventListener('click', e => { e.stopPropagation(); activeHost = null; rebuildTabs(); rebuildTable(); });
|
||
tabsEl.appendChild(all);
|
||
hosts.forEach(h => {
|
||
const b = document.createElement('button');
|
||
b.className = 'db-tab' + (activeHost === h ? ' active' : '');
|
||
const c = records.filter(r => r.host_id === h).length;
|
||
b.textContent = `${h} · ${c}`;
|
||
b.addEventListener('click', e => { e.stopPropagation(); activeHost = h; rebuildTabs(); rebuildTable(); });
|
||
tabsEl.appendChild(b);
|
||
});
|
||
}
|
||
|
||
function matches(rec) {
|
||
if (activeHost && rec.host_id !== activeHost) return false;
|
||
if (!query) return true;
|
||
const q = query.toLowerCase();
|
||
return (rec.host_id || '').toLowerCase().includes(q)
|
||
|| (rec.episode_id || '').toLowerCase().includes(q)
|
||
|| (rec.sha256 || '').toLowerCase().includes(q);
|
||
}
|
||
|
||
function rebuildTable() {
|
||
const filtered = records.filter(matches);
|
||
countEl.textContent = `${filtered.length} of ${records.length}`;
|
||
tbodyEl.innerHTML = '';
|
||
if (!filtered.length) {
|
||
const tr = document.createElement('tr');
|
||
const td = document.createElement('td');
|
||
td.colSpan = 4; td.className = 'awaiting';
|
||
td.textContent = records.length === 0
|
||
? 'awaiting snapshot…'
|
||
: 'no rows match the current filter';
|
||
tr.appendChild(td); tbodyEl.appendChild(tr);
|
||
return;
|
||
}
|
||
const frag = document.createDocumentFragment();
|
||
filtered.slice(0, 200).forEach(rec => {
|
||
const tr = document.createElement('tr');
|
||
tr.className = 'db-row'; tr.dataset.id = rec.episode_id || '';
|
||
tr.innerHTML = `
|
||
<td><span class="db-host">${rec.host_id || '—'}</span></td>
|
||
<td><span class="db-id">${shortId(rec.episode_id)}</span></td>
|
||
<td>${fmtTime(rec.received_at)}</td>
|
||
<td class="db-size">${fmtBytes(rec.size_bytes)}</td>`;
|
||
tr.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
detailEl.hidden = false;
|
||
tbodyEl.querySelectorAll('.db-row').forEach(r => r.classList.remove('selected'));
|
||
tr.classList.add('selected');
|
||
showEpisode(rec);
|
||
});
|
||
frag.appendChild(tr);
|
||
});
|
||
tbodyEl.appendChild(frag);
|
||
}
|
||
|
||
// Fetch + render the per-episode telemetry chart. Decompresses
|
||
// and parses the .tar.zst on the server (see /api/episode in
|
||
// app.py); here we compute deltas on the cumulative counters
|
||
// and draw lines + phase bands.
|
||
async function showEpisode(rec) {
|
||
detailMeta.innerHTML = `
|
||
<span class="db-host">${rec.host_id || '—'}</span>
|
||
<span class="db-id">${rec.episode_id || '—'}</span>
|
||
<span>${fmtBytes(rec.size_bytes)}</span>
|
||
<span>${rec.received_at || ''}</span>`;
|
||
detailChart.innerHTML =
|
||
'<text x="50%" y="50%" text-anchor="middle" class="placeholder">loading…</text>';
|
||
detailLegend.innerHTML = '';
|
||
try {
|
||
const url = `/api/episode/${encodeURIComponent(rec.host_id)}/${encodeURIComponent(rec.episode_id)}`;
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) {
|
||
if (resp.status === 404) throw new Error('episode tarball not on disk');
|
||
throw new Error(`HTTP ${resp.status}`);
|
||
}
|
||
const data = await resp.json();
|
||
renderEpisodeChart(data);
|
||
} catch (err) {
|
||
detailChart.innerHTML =
|
||
`<text x="50%" y="50%" text-anchor="middle" class="placeholder">${err.message}</text>`;
|
||
}
|
||
}
|
||
|
||
function renderEpisodeChart(data) {
|
||
const W = 1000, H = 360;
|
||
const pad = { t: 16, r: 18, b: 32, l: 56 };
|
||
const innerW = W - pad.l - pad.r;
|
||
const innerH = H - pad.t - pad.b;
|
||
|
||
const samples = data.samples || [];
|
||
const labels = (data.labels || [])
|
||
.filter(l => typeof l.t_mono_ns === 'number')
|
||
.sort((a, b) => a.t_mono_ns - b.t_mono_ns);
|
||
|
||
if (samples.length < 2) {
|
||
detailChart.innerHTML =
|
||
'<text x="50%" y="50%" text-anchor="middle" class="placeholder">no telemetry samples</text>';
|
||
return;
|
||
}
|
||
|
||
const tMin = samples[0].t_mono_ns;
|
||
const tMax = samples[samples.length - 1].t_mono_ns;
|
||
const tRange = Math.max(1, tMax - tMin);
|
||
|
||
// Per-interval deltas of the cumulative counters. The README
|
||
// envelope uses CPU jiffies (user + sys) and IO bytes (read +
|
||
// write); both are running totals in /proc, so subtracting
|
||
// adjacent samples gives instantaneous-ish rates.
|
||
const cpu = [], io = [];
|
||
for (let i = 1; i < samples.length; i++) {
|
||
const a = samples[i - 1], b = samples[i];
|
||
if (b.t_mono_ns - a.t_mono_ns <= 0) continue;
|
||
const cv = ((b.cpu_user_jiffies || 0) - (a.cpu_user_jiffies || 0))
|
||
+ ((b.cpu_sys_jiffies || 0) - (a.cpu_sys_jiffies || 0));
|
||
const iv = ((b.io_read_bytes || 0) - (a.io_read_bytes || 0))
|
||
+ ((b.io_write_bytes || 0) - (a.io_write_bytes || 0));
|
||
cpu.push({ t: b.t_mono_ns, v: Math.max(0, cv) });
|
||
io.push({ t: b.t_mono_ns, v: Math.max(0, iv) });
|
||
}
|
||
const cpuMax = Math.max(1, ...cpu.map(p => p.v));
|
||
const ioMax = Math.max(1, ...io.map(p => p.v));
|
||
|
||
const tToX = t => pad.l + ((t - tMin) / tRange) * innerW;
|
||
const cpuToY = v => pad.t + innerH - (v / cpuMax) * innerH;
|
||
const ioToY = v => pad.t + innerH - (v / ioMax) * innerH;
|
||
|
||
const cpuPath = cpu.map((p, i) =>
|
||
`${i === 0 ? 'M' : 'L'}${tToX(p.t).toFixed(1)},${cpuToY(p.v).toFixed(1)}`).join(' ');
|
||
const ioPath = io.map((p, i) =>
|
||
`${i === 0 ? 'M' : 'L'}${tToX(p.t).toFixed(1)},${ioToY(p.v).toFixed(1)}`).join(' ');
|
||
|
||
// Colored band per labeled-phase span.
|
||
const phaseBands = [];
|
||
const phasesUsed = new Set();
|
||
for (let i = 0; i < labels.length; i++) {
|
||
const start = labels[i].t_mono_ns;
|
||
const end = i + 1 < labels.length ? labels[i + 1].t_mono_ns : tMax;
|
||
const phase = labels[i].phase;
|
||
if (!phase) continue;
|
||
phasesUsed.add(phase);
|
||
const x = tToX(Math.max(start, tMin));
|
||
const w = tToX(Math.min(end, tMax)) - x;
|
||
if (w > 0.5) {
|
||
phaseBands.push(
|
||
`<rect class="phase-band ${phase}" x="${x.toFixed(1)}" y="${pad.t}" ` +
|
||
`width="${w.toFixed(1)}" height="${innerH}" />`);
|
||
}
|
||
}
|
||
|
||
const axisY = pad.t + innerH;
|
||
const durSec = (tRange / 1e9).toFixed(1);
|
||
|
||
detailChart.innerHTML = `
|
||
${phaseBands.join('')}
|
||
<line class="axis" x1="${pad.l}" y1="${pad.t}" x2="${pad.l}" y2="${axisY}" />
|
||
<line class="axis" x1="${pad.l}" y1="${axisY}" x2="${W - pad.r}" y2="${axisY}" />
|
||
<text class="tick" x="${pad.l - 4}" y="${pad.t + 8}" text-anchor="end">peak</text>
|
||
<text class="tick" x="${pad.l - 4}" y="${axisY}" text-anchor="end">0</text>
|
||
<text class="tick" x="${pad.l}" y="${axisY + 14}" text-anchor="start">0 s</text>
|
||
<text class="tick" x="${W - pad.r}" y="${axisY + 14}" text-anchor="end">${durSec} s</text>
|
||
<path class="metric-line" d="${cpuPath}" stroke="var(--c1)" />
|
||
<path class="metric-line" d="${ioPath}" stroke="var(--c2)" />
|
||
`;
|
||
|
||
const phaseList = Array.from(phasesUsed).map(p => {
|
||
const v = p === 'infected_running' ? 'phase-running' : `phase-${p}`;
|
||
return `<span><span class="swatch" style="background:var(--${v})"></span>${p}</span>`;
|
||
}).join('');
|
||
detailLegend.innerHTML = `
|
||
<span><span class="swatch" style="background:var(--c1)"></span>cpu jiffies / interval</span>
|
||
<span><span class="swatch" style="background:var(--c2)"></span>io bytes / interval</span>
|
||
${phaseList}`;
|
||
}
|
||
|
||
on('snapshot', m => {
|
||
if (Array.isArray(m.recent_episodes)) {
|
||
records = m.recent_episodes.slice();
|
||
rebuildTabs(); rebuildTable();
|
||
}
|
||
});
|
||
on('episode', m => {
|
||
// Prepend; cap.
|
||
records.unshift({
|
||
episode_id: m.episode_id, host_id: m.host_id,
|
||
sha256: m.sha256, size_bytes: m.size_bytes, received_at: m.received_at,
|
||
});
|
||
if (records.length > 200) records.length = 200;
|
||
// Cheap update: only rebuild if scene visible.
|
||
rebuildTabs(); rebuildTable();
|
||
});
|
||
on('demo_stop', () => {
|
||
// Drop demo-injected records (their ids start with "demo-").
|
||
const before = records.length;
|
||
records = records.filter(r => !(r.episode_id && r.episode_id.startsWith('demo-')));
|
||
if (records.length !== before) { rebuildTabs(); rebuildTable(); }
|
||
});
|
||
searchEl.addEventListener('input', e => { query = e.target.value; rebuildTable(); });
|
||
searchEl.addEventListener('click', e => e.stopPropagation());
|
||
rebuildTabs(); rebuildTable();
|
||
})();
|
||
|
||
// ── Attack envelope thumbnails — DEMO ONLY ───────────────────
|
||
(function () {
|
||
const root = document.getElementById('profile-grid');
|
||
const W = 200, H = 56;
|
||
function emptyState() {
|
||
root.innerHTML = '';
|
||
root.appendChild(awaitingNote('awaiting attack_profile events · turn demo on for examples'));
|
||
}
|
||
function curveToPath(values) {
|
||
const max = Math.max(1, ...values);
|
||
return values.map((v, i) => {
|
||
const x = (i / (values.length - 1)) * W;
|
||
const y = H - (v / max) * (H - 6) - 3;
|
||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||
}).join(' ');
|
||
}
|
||
function gen(seed, fn, n = 80) {
|
||
let s = seed;
|
||
const rand = () => { s = (s * 1664525 + 1013904223) >>> 0; return (s & 0xffff) / 0xffff; };
|
||
return Array.from({ length: n }, (_, i) => fn(i / n, rand));
|
||
}
|
||
const cards = new Map();
|
||
function render(profile) {
|
||
let card = cards.get(profile.name);
|
||
if (!card) {
|
||
if (root.querySelector('.awaiting')) root.innerHTML = '';
|
||
card = document.createElement('div'); card.className = 'profile-card';
|
||
card.innerHTML = `
|
||
<div class="profile-name"></div>
|
||
<div class="profile-shape"></div>
|
||
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"><path d=""></path></svg>`;
|
||
root.appendChild(card); cards.set(profile.name, card);
|
||
}
|
||
card.querySelector('.profile-name').textContent = profile.name;
|
||
card.querySelector('.profile-shape').textContent = profile.shape || '';
|
||
card.querySelector('path').setAttribute('d', curveToPath(profile.curve));
|
||
}
|
||
function clearAll() { cards.clear(); emptyState(); }
|
||
function syntheticProfiles() {
|
||
return [
|
||
{ name: 'cpu-saturate', shape: 'sustained 1-vCPU peg (XMRig)',
|
||
curve: gen(1, (t, r) => 0.1 + (t > 0.15 ? 0.85 : 0) + 0.05 * r()) },
|
||
{ name: 'scan-and-dial', shape: 'SYN-style probes + dial-home',
|
||
curve: gen(2, (t, r) => t < 0.2 ? 0.05 : 0.15 + 0.7 * Math.exp(-Math.pow((t-0.5)*4, 2)) + 0.05 * r()) },
|
||
{ name: 'io-walk', shape: 'fs traversal + 4 KiB urandom writes',
|
||
curve: gen(3, (t, r) => 0.2 + 0.3 * Math.sin(t * 14) + 0.4 * (t > 0.3 && t < 0.85 ? 1 : 0) + 0.05 * r()) },
|
||
{ name: 'bursty-c2', shape: 'long idle + 3-packet egress bursts',
|
||
curve: gen(4, (t, r) => 0.05 + (Math.sin(t * 30) > 0.95 ? 0.9 : 0) + 0.02 * r()) },
|
||
{ name: 'low-and-slow', shape: 'minimal CPU + periodic memory churn',
|
||
curve: gen(5, (t, r) => 0.12 + 0.08 * Math.sin(t * 6) + 0.05 * r()) },
|
||
{ name: 'shell-resident', shape: 'one long TCP socket + command ticks',
|
||
curve: gen(6, (t, r) => 0.08 + (Math.sin(t * 22) > 0.7 ? 0.5 : 0) + 0.03 * r()) },
|
||
];
|
||
}
|
||
on('demo_start', () => syntheticProfiles().forEach(render));
|
||
on('demo_stop', () => clearAll());
|
||
on('attack_profile', m => {
|
||
if (!m.name || !Array.isArray(m.curve)) return;
|
||
render({ name: m.name, shape: m.shape || '', curve: m.curve });
|
||
});
|
||
emptyState();
|
||
})();
|
||
|
||
// ── Chunking timeline — DEMO ONLY ────────────────────────────
|
||
(function () {
|
||
const ruleEl = document.getElementById('chunk-rule');
|
||
const rowEl = document.getElementById('chunk-row');
|
||
const axisEl = document.getElementById('chunk-axis');
|
||
const N = 6;
|
||
function clearAll() {
|
||
ruleEl.innerHTML = ''; rowEl.innerHTML = ''; axisEl.innerHTML = '';
|
||
rowEl.appendChild(awaitingNote('awaiting prediction events · turn demo on for examples'));
|
||
}
|
||
function buildExample() {
|
||
const labels = ['clean', 'clean', 'armed', 'infecting', 'infected_running', 'dormant'];
|
||
ruleEl.innerHTML = ''; rowEl.innerHTML = ''; axisEl.innerHTML = '';
|
||
for (let i = 0; i < N; i++) ruleEl.appendChild(Object.assign(document.createElement('div'), { className: 'tick' }));
|
||
for (let i = 0; i < N; i++) {
|
||
const c = document.createElement('div');
|
||
c.className = `chunk-cell ${labels[i]}`;
|
||
c.textContent = labels[i].replace('_', ' ');
|
||
rowEl.appendChild(c);
|
||
}
|
||
for (let i = 0; i < N; i++) {
|
||
const t = document.createElement('span');
|
||
t.textContent = `${i * 10}s`;
|
||
axisEl.appendChild(t);
|
||
}
|
||
}
|
||
on('demo_start', buildExample);
|
||
on('demo_stop', clearAll);
|
||
on('prediction', m => {
|
||
// Real predictions can update individual cells.
|
||
if (typeof m.window_idx !== 'number') return;
|
||
const cells = rowEl.querySelectorAll('.chunk-cell');
|
||
const cell = cells[m.window_idx];
|
||
if (!cell) return;
|
||
const phase = m.predicted || m.actual;
|
||
if (!phase) return;
|
||
cell.className = `chunk-cell ${phase}`;
|
||
cell.textContent = phase.replace('_', ' ');
|
||
});
|
||
clearAll();
|
||
})();
|
||
|
||
// ── Model comparison bars — DEMO ONLY (until model_metric arrives) ─
|
||
(function () {
|
||
const root = document.getElementById('model-bars');
|
||
const rows = new Map();
|
||
function emptyState() {
|
||
root.innerHTML = '';
|
||
root.appendChild(awaitingNote('awaiting model_metric events · turn demo on for examples'));
|
||
}
|
||
function ensureRow(model) {
|
||
if (rows.has(model)) return rows.get(model);
|
||
if (root.querySelector('.awaiting')) root.innerHTML = '';
|
||
const row = document.createElement('div'); row.className = 'model-row';
|
||
row.innerHTML = `
|
||
<div class="model-name">${model}</div>
|
||
<div class="model-track"><div class="model-fill ${model}" style="width:0%"></div></div>
|
||
<div class="model-acc">0.000</div>`;
|
||
root.appendChild(row);
|
||
const entry = { row, fill: row.querySelector('.model-fill'), acc: row.querySelector('.model-acc') };
|
||
rows.set(model, entry); return entry;
|
||
}
|
||
function render(model, accuracy) {
|
||
const r = ensureRow(model);
|
||
const visible = Math.max(0, Math.min(1, (accuracy - 0.5) / 0.5));
|
||
r.fill.style.width = (visible * 100).toFixed(1) + '%';
|
||
r.acc.textContent = accuracy.toFixed(3);
|
||
}
|
||
on('demo_start', () => {
|
||
[ ['rnn', 0.872], ['gru', 0.911], ['lstm', 0.928], ['bert', 0.954] ]
|
||
.forEach(([m, a]) => render(m, a));
|
||
});
|
||
on('demo_stop', () => { rows.clear(); emptyState(); });
|
||
on('model_metric', m => {
|
||
if (!m.model || typeof m.accuracy !== 'number') return;
|
||
render(m.model, m.accuracy);
|
||
});
|
||
emptyState();
|
||
})();
|
||
|
||
// ── KNN scatter — DEMO ONLY ───────────────────────────────────
|
||
(function () {
|
||
const svg = document.getElementById('knn-scatter');
|
||
const legend = document.getElementById('knn-legend');
|
||
const PHASES = ['clean', 'armed', 'infecting', 'infected_running', 'dormant'];
|
||
const phaseCenters = {
|
||
clean: [0.18, 0.75],
|
||
armed: [0.42, 0.58],
|
||
infecting: [0.72, 0.40],
|
||
infected_running: [0.85, 0.18],
|
||
dormant: [0.30, 0.30],
|
||
};
|
||
const ns = 'http://www.w3.org/2000/svg';
|
||
let W = 600, H = 360;
|
||
|
||
legend.innerHTML = '';
|
||
PHASES.forEach(p => {
|
||
const li = document.createElement('span');
|
||
const swatchVar = p === 'infected_running' ? 'running' : p;
|
||
li.innerHTML = `<span class="swatch" style="background:var(--phase-${swatchVar})"></span>${p}`;
|
||
legend.appendChild(li);
|
||
});
|
||
|
||
function setupAxes() {
|
||
svg.innerHTML = '';
|
||
const ax = document.createElementNS(ns, 'line');
|
||
ax.setAttribute('x1', 40); ax.setAttribute('y1', H - 30);
|
||
ax.setAttribute('x2', W - 10); ax.setAttribute('y2', H - 30);
|
||
ax.setAttribute('class', 'axis'); svg.appendChild(ax);
|
||
const ay = document.createElementNS(ns, 'line');
|
||
ay.setAttribute('x1', 40); ay.setAttribute('y1', 10);
|
||
ay.setAttribute('x2', 40); ay.setAttribute('y2', H - 30);
|
||
ay.setAttribute('class', 'axis'); svg.appendChild(ay);
|
||
const xl = document.createElementNS(ns, 'text');
|
||
xl.setAttribute('x', W / 2); xl.setAttribute('y', H - 8);
|
||
xl.setAttribute('class', 'axis-label'); xl.setAttribute('text-anchor', 'middle');
|
||
xl.textContent = 'feature 1'; svg.appendChild(xl);
|
||
const yl = document.createElementNS(ns, 'text');
|
||
yl.setAttribute('transform', `translate(12,${H/2}) rotate(-90)`);
|
||
yl.setAttribute('class', 'axis-label'); yl.setAttribute('text-anchor', 'middle');
|
||
yl.textContent = 'feature 2'; svg.appendChild(yl);
|
||
}
|
||
function emptyState() {
|
||
svg.innerHTML = '';
|
||
const t = document.createElementNS(ns, 'text');
|
||
t.setAttribute('x', W / 2); t.setAttribute('y', H / 2);
|
||
t.setAttribute('text-anchor', 'middle');
|
||
t.setAttribute('class', 'axis-label');
|
||
t.textContent = 'awaiting embedding events · turn demo on for examples';
|
||
svg.appendChild(t);
|
||
}
|
||
function project(x, y) {
|
||
const px = 40 + x * (W - 50);
|
||
const py = (H - 30) - y * (H - 40);
|
||
return [px, py];
|
||
}
|
||
function addPoint(x, y, phase) {
|
||
const c = document.createElementNS(ns, 'circle');
|
||
const [px, py] = project(x, y);
|
||
c.setAttribute('cx', px.toFixed(1)); c.setAttribute('cy', py.toFixed(1));
|
||
c.setAttribute('r', 4);
|
||
c.setAttribute('class', `point ${phase}`);
|
||
svg.appendChild(c);
|
||
}
|
||
function syntheticPoints() {
|
||
let seed = 7;
|
||
const rand = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return ((seed & 0xffff) / 0xffff) - 0.5; };
|
||
setupAxes();
|
||
PHASES.forEach(p => {
|
||
const [cx, cy] = phaseCenters[p];
|
||
for (let i = 0; i < 14; i++) addPoint(cx + rand() * 0.16, cy + rand() * 0.16, p);
|
||
});
|
||
}
|
||
on('demo_start', syntheticPoints);
|
||
on('demo_stop', emptyState);
|
||
on('embedding', m => {
|
||
if (typeof m.x !== 'number' || typeof m.y !== 'number' || !m.phase) return;
|
||
// First real embedding wipes the awaiting note and sets up axes.
|
||
if (svg.querySelector('.axis-label') && !svg.querySelector('.point')) setupAxes();
|
||
addPoint(m.x, m.y, m.phase);
|
||
});
|
||
emptyState();
|
||
})();
|
||
|
||
// ── Performance scatter — DEMO ONLY ───────────────────────────
|
||
(function () {
|
||
const svg = document.getElementById('perf-scatter');
|
||
const ns = 'http://www.w3.org/2000/svg';
|
||
const W = 600, H = 360;
|
||
const xmin = 0, xmax = 4000, ymin = 0.7, ymax = 1.0;
|
||
const points = new Map();
|
||
|
||
function setupAxes() {
|
||
svg.innerHTML = '';
|
||
const ax = document.createElementNS(ns, 'line');
|
||
ax.setAttribute('x1', 50); ax.setAttribute('y1', H - 30);
|
||
ax.setAttribute('x2', W - 10); ax.setAttribute('y2', H - 30);
|
||
ax.setAttribute('class', 'axis'); svg.appendChild(ax);
|
||
const ay = document.createElementNS(ns, 'line');
|
||
ay.setAttribute('x1', 50); ay.setAttribute('y1', 10);
|
||
ay.setAttribute('x2', 50); ay.setAttribute('y2', H - 30);
|
||
ay.setAttribute('class', 'axis'); svg.appendChild(ay);
|
||
const xl = document.createElementNS(ns, 'text');
|
||
xl.setAttribute('x', W / 2); xl.setAttribute('y', H - 8);
|
||
xl.setAttribute('class', 'axis-label'); xl.setAttribute('text-anchor', 'middle');
|
||
xl.textContent = 'inference latency (μs / window) →'; svg.appendChild(xl);
|
||
const yl = document.createElementNS(ns, 'text');
|
||
yl.setAttribute('transform', `translate(14,${H/2}) rotate(-90)`);
|
||
yl.setAttribute('class', 'axis-label'); yl.setAttribute('text-anchor', 'middle');
|
||
yl.textContent = '↑ held-out accuracy'; svg.appendChild(yl);
|
||
}
|
||
function emptyState() {
|
||
points.clear(); svg.innerHTML = '';
|
||
const t = document.createElementNS(ns, 'text');
|
||
t.setAttribute('x', W / 2); t.setAttribute('y', H / 2);
|
||
t.setAttribute('text-anchor', 'middle'); t.setAttribute('class', 'axis-label');
|
||
t.textContent = 'awaiting model_perf events · turn demo on for examples';
|
||
svg.appendChild(t);
|
||
}
|
||
function project(latency, accuracy) {
|
||
const x = 50 + Math.min(1, (latency - xmin) / (xmax - xmin)) * (W - 60);
|
||
const y = (H - 30) - Math.max(0, Math.min(1, (accuracy - ymin) / (ymax - ymin))) * (H - 40);
|
||
return [x, y];
|
||
}
|
||
function render(model, latency, accuracy) {
|
||
let g = points.get(model);
|
||
if (!g) {
|
||
if (!points.size) setupAxes();
|
||
g = document.createElementNS(ns, 'g');
|
||
const c = document.createElementNS(ns, 'circle');
|
||
c.setAttribute('r', 7); c.setAttribute('class', 'perf-point');
|
||
g.appendChild(c);
|
||
const t = document.createElementNS(ns, 'text');
|
||
t.setAttribute('class', 'perf-label');
|
||
g.appendChild(t);
|
||
svg.appendChild(g);
|
||
points.set(model, g);
|
||
}
|
||
const [px, py] = project(latency, accuracy);
|
||
g.querySelector('circle').setAttribute('cx', px.toFixed(1));
|
||
g.querySelector('circle').setAttribute('cy', py.toFixed(1));
|
||
const t = g.querySelector('text');
|
||
t.setAttribute('x', (px + 12).toFixed(1));
|
||
t.setAttribute('y', (py + 4).toFixed(1));
|
||
t.textContent = model;
|
||
}
|
||
on('demo_start', () => {
|
||
[
|
||
{ model: 'knn', latency_us: 90, accuracy: 0.84 },
|
||
{ model: 'rnn', latency_us: 380, accuracy: 0.87 },
|
||
{ model: 'gru', latency_us: 520, accuracy: 0.91 },
|
||
{ model: 'lstm', latency_us: 700, accuracy: 0.93 },
|
||
{ model: 'bert', latency_us: 3200, accuracy: 0.95 },
|
||
].forEach(p => render(p.model, p.latency_us, p.accuracy));
|
||
});
|
||
on('demo_stop', emptyState);
|
||
on('model_perf', m => {
|
||
if (!m.model || typeof m.latency_us !== 'number' || typeof m.accuracy !== 'number') return;
|
||
render(m.model, m.latency_us, m.accuracy);
|
||
});
|
||
emptyState();
|
||
})();
|
||
|
||
})();
|