live scene: per-host swim lanes + latest-detection callout

New scene 13 (between perf and references) for fleet-wide live
predictions. Each host gets a row of recent prediction cells
(capped at 60), painted by predicted phase; mismatch with ground
truth shows a hatched overlay. A callout below the lanes holds
the most recent detection with model, profile, confidence, and
latency.

Producer contract is the new LiveDetection dataclass in events.py.
The dashboard side is producer-agnostic — the inference loop can
run locally or offload to A100 (or any GPU/host); just POST events
back. No rate-limiting needed; the swim-lane DOM does the capping.

Demo synthesizes 5 hosts walking through phases at ~92% accuracy
so the scene reads as live the moment the deck loads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Max Gorog 2026-05-08 14:03:31 -05:00
parent 9d56bcc923
commit 3783fabe86
4 changed files with 371 additions and 4 deletions

View file

@ -39,14 +39,15 @@ Scene (in deck order) Event type(s)
2. stack (static; no events)
3. collect ``snapshot``, ``episode`` (already wired by feeder)
4. hosts ``snapshot``, ``episode`` (already wired by feeder)
5. db ``snapshot`` (already wired by feeder)
6. baseline ``phase`` :class:`PhaseEvent`
5. db ``snapshot``, ``phase_mix``(already wired by feeder)
6. baseline ``phase_mix`` (feeder; see ``feeder.py``)
7. attacks ``attack_profile`` :class:`AttackProfile`
8. chunking ``prediction`` :class:`Prediction`
9. models ``model_metric`` :class:`ModelMetric`
10. training-code (static; no events)
11. knn ``embedding`` :class:`Embedding`
12. perf ``model_perf`` :class:`ModelPerf`
13. live ``live_detection`` :class:`LiveDetection`
========================== ==================================================
The reconnect gotcha
@ -234,6 +235,58 @@ class Embedding(_EventBase):
type: str = field(default="embedding", init=False, repr=False)
# ─────────────────────────────────────────────────────────────────────
# LiveDetection — scene 13 (live)
# ─────────────────────────────────────────────────────────────────────
@dataclass
class LiveDetection(_EventBase):
"""One model inference on a freshly-received window from a fleet host.
Drives the live-detections scene. Each event lights up one cell on
the host's swim lane (cells push in from the right; lane caps at
60) and overwrites the "latest detection" callout below the lanes.
When ``actual`` is also supplied, the cell renders with a hatched
overlay if the model disagrees with ground truth, and the running
hit-rate ticks up on the stats line.
The dashboard side is producer-agnostic your inference loop can
run locally or offload to an A100 (or any GPU); just POST events
back. Send them as fast as the fleet emits windows, no need to
rate-limit.
:param host_id: Source host. Each unique value gets its own lane.
:param predicted: The model's per-window phase prediction.
:param actual: Optional ground truth from labels.jsonl. Often
arrives later than the prediction that's fine,
send a follow-up event with both fields filled in
when truth is known.
:param confidence: 01 (softmax max). Optional; shown as a percent
on the latest-detection callout.
:param model: Which model produced this. Drives the "model: …"
badge on the stats line.
:param profile: Malware profile that's running on this host.
Shown on the latest-detection callout.
:param episode_id: Source episode id (used for tooltips, grouping).
:param window_idx: 0-based window index inside the episode.
:param latency_ms: Inference round-trip in ms. If you're offloading
to a remote A100, include the network leg
that's the operationally honest number.
:param t_wall: Wall-clock seconds since epoch. Optional.
"""
host_id: str
predicted: Phase
actual: Optional[Phase] = None
confidence: Optional[float] = None
model: Optional[Model] = None
profile: Optional[str] = None
episode_id: Optional[str] = None
window_idx: Optional[int] = None
latency_ms: Optional[float] = None
t_wall: Optional[float] = None
type: str = field(default="live_detection", init=False, repr=False)
# ─────────────────────────────────────────────────────────────────────
# ModelPerf — scene 12 (perf)
# ─────────────────────────────────────────────────────────────────────
@ -292,6 +345,7 @@ __all__ = [
"ModelMetric",
"Embedding",
"ModelPerf",
"LiveDetection",
"Publisher",
"publish",
"try_publish",

View file

@ -1028,6 +1028,126 @@ html, body { overflow-anchor: none; }
width: 100%; height: 100%;
}
/* ─── Live detections (scene: live) ────────────────────────────────── */
.live-stack { gap: clamp(10px, 1.6vh, 20px); }
.live-stats {
display: flex; flex-wrap: wrap; gap: 12px; align-items: center;
padding: 8px 14px;
font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--fg-dim);
background: var(--bg-elev, rgba(255, 255, 255, 0.03));
border: 1px solid var(--line);
border-radius: 4px;
}
.live-stats-eye { color: var(--accent); font-weight: 600; }
.live-stats-dot::before { content: '·'; margin-right: 12px; opacity: 0.5; }
.live-lanes {
display: flex; flex-direction: column;
gap: clamp(4px, 0.8vh, 8px);
padding: 12px;
background: var(--bg-elev, rgba(255, 255, 255, 0.03));
border: 1px solid var(--line);
border-radius: 4px;
min-height: 220px;
}
.live-lanes:empty::before {
content: 'no hosts reporting yet';
align-self: center;
margin: auto;
color: var(--fg-mute);
font: 13px ui-monospace, SFMono-Regular, Menlo, monospace;
}
.live-lane {
display: grid;
grid-template-columns: minmax(120px, 16ch) 1fr;
gap: 12px; align-items: center;
}
.live-lane-host {
font: 13px ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--fg);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.live-lane-cells {
display: flex; gap: 1px;
height: clamp(24px, 4vh, 36px);
overflow: hidden;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 2px;
}
.live-cell {
flex: 1 1 0;
position: relative;
min-width: 4px;
}
.live-cell.clean { background: var(--phase-clean); }
.live-cell.armed { background: var(--phase-armed); }
.live-cell.infecting { background: var(--phase-infecting); }
.live-cell.infected_running { background: var(--phase-running); }
.live-cell.dormant { background: var(--phase-dormant); }
.live-cell.miss::after {
content: ''; position: absolute; inset: 0;
background: repeating-linear-gradient(
-45deg, transparent 0 3px, rgba(0, 0, 0, 0.55) 3px 5px
);
}
.live-latest {
padding: 14px 16px;
background: var(--bg-elev, rgba(255, 255, 255, 0.03));
border: 1px solid var(--line);
border-radius: 4px;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 16px; align-items: center;
}
.live-latest-empty {
grid-column: 1 / -1;
text-align: center;
color: var(--fg-mute);
font: 13px ui-monospace, SFMono-Regular, Menlo, monospace;
padding: 12px 0;
}
.live-phase-block {
width: clamp(80px, 9vw, 110px);
aspect-ratio: 1;
border-radius: 4px;
display: grid; place-items: center;
font: clamp(12px, 1vw, 14px) ui-monospace, SFMono-Regular, Menlo, monospace;
font-weight: 700;
color: rgba(0, 0, 0, 0.85);
text-align: center; padding: 8px;
line-height: 1.15;
}
.live-phase-block.clean { background: var(--phase-clean); }
.live-phase-block.armed { background: var(--phase-armed); }
.live-phase-block.infecting { background: var(--phase-infecting); }
.live-phase-block.infected_running { background: var(--phase-running); }
.live-phase-block.dormant { background: var(--phase-dormant); }
.live-meta {
display: flex; flex-direction: column; gap: 4px;
font: 13px ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--fg-dim);
min-width: 0;
}
.live-meta-host { color: var(--fg); font-weight: 600; }
.live-meta-line code { color: var(--fg); }
.live-conf {
text-align: right;
font: clamp(20px, 2vw, 28px) ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--fg);
white-space: nowrap;
}
.live-conf-label {
display: block;
font-size: 11px; color: var(--fg-mute);
font-weight: normal;
margin-bottom: 2px;
}
/* ─── Scatter plots (KNN, perf) ────────────────────────────────────── */
.scatter {
width: 100%;

View file

@ -1900,6 +1900,162 @@ for epoch in range(20):
rebuildLegend();
})();
// ── Live detections (scene: live) ─────────────────────────────
// Per-host swim lanes of recent prediction cells, plus a "latest
// detection" callout. New cells push in from the right; lane caps
// at MAX_CELLS so memory stays bounded across long sessions. When
// the producer also sends `actual`, mismatch cells get a hatched
// overlay and the running hit-rate updates.
(function () {
const lanesEl = document.getElementById('live-lanes');
const latestEl = document.getElementById('live-latest');
const statsHosts = document.getElementById('live-stats-hosts');
const statsRate = document.getElementById('live-stats-rate');
const statsModel = document.getElementById('live-stats-model');
const statsAcc = document.getElementById('live-stats-acc');
if (!lanesEl) return;
const MAX_CELLS = 60;
const RATE_WINDOW_MS = 5000;
const lanes = new Map();
const eventTimes = [];
let totalCorrect = 0, totalLabeled = 0;
let lastModel = null;
function ensureLane(hostId) {
if (lanes.has(hostId)) return lanes.get(hostId);
const row = document.createElement('div');
row.className = 'live-lane';
row.innerHTML = `
<div class="live-lane-host" title="${hostId}">${hostId}</div>
<div class="live-lane-cells"></div>`;
lanesEl.appendChild(row);
const lane = { row, cellsEl: row.querySelector('.live-lane-cells'), cells: [] };
lanes.set(hostId, lane);
return lane;
}
function paintCell(d) {
const lane = ensureLane(d.host_id);
const cell = document.createElement('div');
cell.className = `live-cell ${d.predicted}`;
if (d.actual && d.actual !== d.predicted) cell.classList.add('miss');
cell.title = `${d.host_id} · w${d.window_idx ?? '?'} · ${d.predicted}` +
(d.actual ? ` (truth: ${d.actual})` : '') +
(d.confidence != null ? ` · conf ${d.confidence.toFixed(2)}` : '');
lane.cellsEl.appendChild(cell);
lane.cells.push(d);
while (lane.cells.length > MAX_CELLS) {
lane.cells.shift();
const first = lane.cellsEl.firstChild;
if (first) lane.cellsEl.removeChild(first);
}
}
function paintLatest(d) {
const conf = d.confidence != null
? `${(d.confidence * 100).toFixed(0)}%`
: '—';
const truthLine = d.actual
? (d.actual === d.predicted
? `<span style="color: var(--phase-clean)">✓ matches ground truth</span>`
: `truth: <code>${d.actual}</code> (model disagrees)`)
: `truth: <span style="color: var(--fg-mute)">awaiting label</span>`;
const phaseLabel = (d.predicted || '').replace('_', '<br>');
latestEl.innerHTML = `
<div class="live-phase-block ${d.predicted}">${phaseLabel}</div>
<div class="live-meta">
<div class="live-meta-host">${d.host_id}</div>
<div class="live-meta-line">profile: <code>${d.profile ?? '—'}</code></div>
<div class="live-meta-line">model: <code>${d.model ?? '—'}</code>${
d.latency_ms != null ? ` · ${d.latency_ms.toFixed(0)} ms` : ''
}</div>
<div class="live-meta-line">${truthLine}</div>
</div>
<div class="live-conf">
<span class="live-conf-label">confidence</span>
${conf}
</div>`;
}
function updateStats() {
const now = Date.now();
while (eventTimes.length && now - eventTimes[0] > RATE_WINDOW_MS) eventTimes.shift();
const rate = (eventTimes.length / (RATE_WINDOW_MS / 1000)).toFixed(1);
statsHosts.textContent = `${lanes.size} host${lanes.size === 1 ? '' : 's'}`;
statsRate.textContent = `${rate} / sec`;
statsModel.textContent = `model: ${lastModel ?? '—'}`;
statsAcc.textContent = totalLabeled > 0
? `hit-rate: ${(100 * totalCorrect / totalLabeled).toFixed(0)}% (${totalCorrect}/${totalLabeled})`
: `hit-rate: —`;
}
setInterval(updateStats, 500);
function handleDetection(d) {
if (!d.host_id || !d.predicted) return;
eventTimes.push(Date.now());
if (d.model) lastModel = d.model;
if (d.actual) {
totalLabeled++;
if (d.actual === d.predicted) totalCorrect++;
}
paintCell(d);
paintLatest(d);
}
on('live_detection', handleDetection);
// Synthetic demo: 5 hosts, walk through phases, ~92% accuracy.
let demoTimer = null;
function demoStart() {
if (demoTimer) clearInterval(demoTimer);
const HOSTS = [
{ id: 'elliott-lab', profile: 'cpu-saturate', phaseIdx: 0 },
{ id: 'elliott-thinkpad', profile: 'ransomware-lite', phaseIdx: 0 },
{ id: 'k-gamingcom', profile: 'bursty-c2', phaseIdx: 1 },
{ id: 'smelliott', profile: 'fork-bomb', phaseIdx: 0 },
{ id: 'gosling', profile: 'crypto-miner', phaseIdx: 2 },
];
const PHASES = ['clean', 'armed', 'infecting', 'infected_running', 'dormant'];
let counter = 0;
demoTimer = setInterval(() => {
const host = HOSTS[counter % HOSTS.length];
counter++;
if (Math.random() < 0.18) host.phaseIdx = (host.phaseIdx + 1) % PHASES.length;
const truth = PHASES[host.phaseIdx];
const right = Math.random() < 0.92;
const predicted = right
? truth
: PHASES[(host.phaseIdx + 1 + Math.floor(Math.random() * 4)) % PHASES.length];
handleDetection({
host_id: host.id, profile: host.profile,
predicted, actual: truth,
confidence: 0.62 + Math.random() * 0.36,
model: 'lstm',
latency_ms: 3 + Math.random() * 4,
episode_id: 'demo',
window_idx: counter,
t_wall: Date.now() / 1000,
});
}, 280);
}
function demoStop() {
if (demoTimer) { clearInterval(demoTimer); demoTimer = null; }
lanes.forEach(l => l.row.remove());
lanes.clear();
eventTimes.length = 0;
totalCorrect = 0; totalLabeled = 0;
lastModel = null;
latestEl.innerHTML = '<div class="live-latest-empty">awaiting <code>live_detection</code> events from the inference loop</div>';
updateStats();
}
on('demo_start', demoStart);
on('demo_stop', demoStop);
updateStats();
})();
// ── References viewer (scene: references) ────────────────────
// Lists PDFs from /api/references; clicking a tab swaps the
// iframe's src to /refs/<filename>. List is fetched once at

View file

@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CIS490 — live</title>
<link rel="stylesheet" href="/static/dashboard.css?v=edba1d35">
<link rel="stylesheet" href="/static/dashboard.css?v=1fc0424d">
</head>
<body>
<!-- SVG filter defs for the lava-lamp goo effect. Width/height 0
@ -338,6 +338,23 @@
</div>
</div>
<!-- 13. live — fleet-wide live detections feed -->
<div class="stage-view" data-view="live">
<div class="metric-stack metric-stack-wide live-stack">
<div class="live-stats">
<span class="live-stats-eye">live detections</span>
<span class="live-stats-dot" id="live-stats-hosts">0 hosts</span>
<span class="live-stats-dot" id="live-stats-rate">0 / sec</span>
<span class="live-stats-dot" id="live-stats-model">model: —</span>
<span class="live-stats-dot" id="live-stats-acc">hit-rate: —</span>
</div>
<div class="live-lanes" id="live-lanes"></div>
<div class="live-latest" id="live-latest">
<div class="live-latest-empty">awaiting <code>live_detection</code> events from the inference loop</div>
</div>
</div>
</div>
</div>
<button id="next-fab" class="fab" data-no-advance title="Next (→)"></button>
</div>
@ -518,6 +535,26 @@
</div>
</section>
<section class="scene" data-stage="live">
<div class="prose">
<h2>Catching attacks live</h2>
<p>Real episodes arrive from the fleet, get chunked into ten-second
windows, and a deployed model labels each window in flight. The
heavy models can offload inference to an <strong>A100</strong>
so the receiver never blocks on a forward pass — predictions
stream back as they finish.</p>
<p>Each row on the stage is a host; each cell is one ten-second
window painted by the model's predicted phase. A clean run
cruises blue; an attack profile pushes the lane through
<code>armed</code><code>infecting</code>
<code>infected_running</code>. When ground truth catches up,
mismatched cells get a hatched overlay so you can spot where
the model disagrees with the orchestrator. The callout below
holds the most recent prediction with model name,
confidence, and round-trip latency.</p>
</div>
</section>
<section class="scene" data-stage="references">
<div class="prose">
<h2>References</h2>
@ -533,6 +570,6 @@
</article>
</div>
<script src="/static/dashboard.js?v=fbac7a5c"></script>
<script src="/static/dashboard.js?v=061aec1c"></script>
</body>
</html>