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:
parent
9d56bcc923
commit
3783fabe86
4 changed files with 371 additions and 4 deletions
|
|
@ -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: 0–1 (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",
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue