911 / live_dashboard.html
garvitsachdeva's picture
Submission polish: compliance hardening, baseline matrix, dashboard UX, tests, and docs
775befb
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>911 Dispatch Live Dashboard</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', monospace; background: #0a0e1a; color: #e2e8f0; min-height: 100vh; }
.header { background: linear-gradient(135deg, #1e293b, #0f172a); border-bottom: 1px solid #334155; padding: 14px 20px; display: flex; align-items: center; gap: 14px; }
.title { font-size: 16px; font-weight: 700; color: #f1f5f9; }
.meta { margin-left: auto; display: flex; gap: 14px; flex-wrap: wrap; justify-content: flex-end; }
.pill { background: #0f172a; border: 1px solid #334155; border-radius: 999px; padding: 6px 10px; font-size: 11px; color: #cbd5e1; }
.pill strong { color: #f1f5f9; font-weight: 700; }
.layout { display: grid; grid-template-rows: 1fr 180px; height: calc(100vh - 52px); }
.main { display: grid; grid-template-columns: 340px 1fr 340px; min-height: 0; }
.col { padding: 16px; overflow: auto; }
.col + .col { border-left: 1px solid #1e293b; }
.panel-title { font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; color: #475569; margin-bottom: 10px; }
.card { background: #0f172a; border: 1px solid #334155; border-radius: 10px; padding: 12px; margin-bottom: 10px; }
.row { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; }
.muted { color: #64748b; }
.map-wrap { height: 100%; display: flex; flex-direction: column; }
.map { flex: 1; min-height: 320px; border: 1px solid #334155; border-radius: 10px; background: #0f172a; overflow: hidden; }
.legend { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; font-size: 11px; color: #94a3b8; }
.dot { width: 10px; height: 10px; border-radius: 999px; display: inline-block; margin-right: 6px; border: 1px solid #334155; }
.bottom { border-top: 1px solid #1e293b; padding: 14px 20px; }
.breakdown { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; }
.bar { height: 10px; background: #0f172a; border: 1px solid #334155; border-radius: 999px; overflow: hidden; }
.fill { height: 100%; width: 0%; background: #38bdf8; }
.kpi { display: flex; justify-content: space-between; font-size: 11px; color: #cbd5e1; margin-bottom: 6px; }
.status { margin-top: 10px; font-size: 11px; color: #64748b; }
.history-item { border-bottom: 1px solid #1e293b; padding: 8px 0; font-size: 11px; color: #cbd5e1; }
.history-item:last-child { border-bottom: 0; }
.history-step { color: #94a3b8; margin-right: 8px; }
.history-issues { color: #fbbf24; display: block; margin-top: 4px; }
@media (max-width: 1200px) {
.layout { grid-template-rows: auto auto; height: auto; min-height: calc(100vh - 52px); }
.main { grid-template-columns: 1fr; }
.col + .col { border-left: 0; border-top: 1px solid #1e293b; }
.map { min-height: 260px; }
.breakdown { grid-template-columns: repeat(2, 1fr); }
}
</style>
</head>
<body>
<div class="header">
<div class="title">911 Dispatch Live Dashboard</div>
<div class="meta">
<div class="pill">Schema: <strong id="hdr-schema"></strong></div>
<div class="pill">Task: <strong id="hdr-task"></strong></div>
<div class="pill">Episode: <strong id="hdr-episode"></strong></div>
<div class="pill">Step: <strong id="hdr-step"></strong></div>
<div class="pill">Episode Score: <strong id="hdr-episode-score"></strong></div>
<div class="pill">Cumulative Reward: <strong id="hdr-cum-reward"></strong></div>
</div>
</div>
<div class="layout">
<div class="main">
<div class="col">
<div class="panel-title">Units</div>
<div id="units"></div>
</div>
<div class="col">
<div class="panel-title">City Map</div>
<div class="map-wrap">
<div class="map">
<svg id="map" width="100%" height="100%" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
</div>
<div class="legend">
<span><span class="dot" style="background:#10b981"></span>AVAILABLE</span>
<span><span class="dot" style="background:#38bdf8"></span>DISPATCHED</span>
<span><span class="dot" style="background:#fbbf24"></span>ON_SCENE</span>
<span><span class="dot" style="background:#ef4444"></span>INCIDENT</span>
</div>
<div class="status" id="status">Waiting for server…</div>
</div>
</div>
<div class="col">
<div class="panel-title">Incidents</div>
<div id="incidents"></div>
<div class="panel-title" style="margin-top:14px;">Recent Events</div>
<div id="history"></div>
</div>
</div>
<div class="bottom">
<div class="panel-title">Step Reward Breakdown (latest observation)</div>
<div class="breakdown">
<div>
<div class="kpi"><span>response_time</span><span id="v-response_time">0.00</span></div>
<div class="bar"><div class="fill" id="b-response_time"></div></div>
</div>
<div>
<div class="kpi"><span>triage</span><span id="v-triage">0.00</span></div>
<div class="bar"><div class="fill" id="b-triage"></div></div>
</div>
<div>
<div class="kpi"><span>survival</span><span id="v-survival">0.00</span></div>
<div class="bar"><div class="fill" id="b-survival"></div></div>
</div>
<div>
<div class="kpi"><span>coverage</span><span id="v-coverage">0.00</span></div>
<div class="bar"><div class="fill" id="b-coverage"></div></div>
</div>
<div>
<div class="kpi"><span>protocol</span><span id="v-protocol">0.00</span></div>
<div class="bar"><div class="fill" id="b-protocol"></div></div>
</div>
</div>
</div>
</div>
<script>
const API = 'http://localhost:8000';
const DASHBOARD_STATE = `${API}/dashboard/state`;
const REFRESH_MS = 500;
const HISTORY_LIMIT = 12;
let lastHistoryEpisode = null;
let lastHistoryStep = -1;
let eventHistory = [];
const STATUS_COLORS = {
AVAILABLE: '#10b981',
DISPATCHED: '#38bdf8',
ON_SCENE: '#fbbf24',
TRANSPORTING: '#7c3aed',
OUT_OF_SERVICE: '#64748b'
};
function clamp01(v) {
const x = Number(v);
if (!Number.isFinite(x)) return 0;
return Math.max(0, Math.min(1, x));
}
function fmt(v, digits=2) {
const x = Number(v);
if (!Number.isFinite(x)) return '—';
return x.toFixed(digits);
}
function setText(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function renderUnits(state) {
const root = document.getElementById('units');
root.innerHTML = '';
const units = Object.values(state.units || {}).sort((a,b) => String(a.unit_id).localeCompare(String(b.unit_id)));
if (units.length === 0) {
root.innerHTML = '<div class="muted">No units</div>';
return;
}
for (const u of units) {
const color = STATUS_COLORS[u.status] || '#94a3b8';
const assigned = u.assigned_incident_id ? u.assigned_incident_id : '—';
const eta = Number(u.eta_seconds || 0);
const etaStr = eta > 0 ? `${eta.toFixed(0)}s` : '—';
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `
<div class="row"><div><strong style="color:${color}">${u.unit_id}</strong> <span class="muted">(${u.unit_type})</span></div><div class="muted">${u.status}</div></div>
<div class="row" style="margin-top:8px"><div class="muted">assigned</div><div>${assigned}</div></div>
<div class="row"><div class="muted">eta</div><div>${etaStr}</div></div>
<div class="row"><div class="muted">pos</div><div>${fmt(u.location_x,0)}, ${fmt(u.location_y,0)}</div></div>
`;
root.appendChild(card);
}
}
function renderIncidents(state) {
const root = document.getElementById('incidents');
root.innerHTML = '';
const incs = Object.values(state.incidents || {}).sort((a,b) => String(a.incident_id).localeCompare(String(b.incident_id)));
if (incs.length === 0) {
root.innerHTML = '<div class="muted">No incidents</div>';
return;
}
for (const i of incs) {
const sev = String(i.severity || '');
const sevColor = sev === 'PRIORITY_1' ? '#ef4444' : (sev === 'PRIORITY_2' ? '#fbbf24' : '#38bdf8');
const units = (i.units_assigned || []).join(', ') || '—';
const survival = Number(i.survival_clock);
const survivalStr = Number.isFinite(survival) ? `${survival.toFixed(0)}s` : '—';
const p1ClockRow = sev === 'PRIORITY_1'
? `<div class="row"><div class="muted">p1 clock</div><div>${survivalStr}</div></div>`
: '';
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `
<div class="row"><div><strong style="color:${sevColor}">${i.incident_id}</strong> <span class="muted">(${i.incident_type})</span></div><div class="muted">${i.status}</div></div>
<div class="row" style="margin-top:8px"><div class="muted">severity</div><div>${i.severity}</div></div>
${p1ClockRow}
<div class="row"><div class="muted">assigned</div><div>${units}</div></div>
<div class="row"><div class="muted">pos</div><div>${fmt(i.location_x,0)}, ${fmt(i.location_y,0)}</div></div>
`;
root.appendChild(card);
}
}
function inferBounds(state) {
const metaGrid = state.metadata && state.metadata.grid_size;
if (Array.isArray(metaGrid) && metaGrid.length === 2) {
const w = Math.max(1, Number(metaGrid[0] || 1));
const h = Math.max(1, Number(metaGrid[1] || 1));
return { w, h };
}
let maxX = 1, maxY = 1;
for (const u of Object.values(state.units || {})) {
maxX = Math.max(maxX, Number(u.location_x || 0));
maxY = Math.max(maxY, Number(u.location_y || 0));
}
for (const i of Object.values(state.incidents || {})) {
maxX = Math.max(maxX, Number(i.location_x || 0));
maxY = Math.max(maxY, Number(i.location_y || 0));
}
return { w: Math.max(1, maxX), h: Math.max(1, maxY) };
}
function svgEl(tag, attrs) {
const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
for (const [k,v] of Object.entries(attrs || {})) {
el.setAttribute(k, String(v));
}
return el;
}
function renderMap(state) {
const svg = document.getElementById('map');
while (svg.firstChild) svg.removeChild(svg.firstChild);
const { w, h } = inferBounds(state);
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
svg.appendChild(svgEl('rect', { x: 0, y: 0, width: w, height: h, fill: '#0f172a' }));
// light grid
const gridStep = Math.max(1, Math.floor(Math.min(w,h) / 10));
for (let x = 0; x <= w; x += gridStep) {
svg.appendChild(svgEl('line', { x1: x, y1: 0, x2: x, y2: h, stroke: '#1e293b', 'stroke-width': 0.2 }));
}
for (let y = 0; y <= h; y += gridStep) {
svg.appendChild(svgEl('line', { x1: 0, y1: y, x2: w, y2: y, stroke: '#1e293b', 'stroke-width': 0.2 }));
}
// incidents
for (const i of Object.values(state.incidents || {})) {
const sev = String(i.severity || '');
const color = sev === 'PRIORITY_1' ? '#ef4444' : (sev === 'PRIORITY_2' ? '#fbbf24' : '#38bdf8');
const x = Number(i.location_x || 0);
const y = Number(i.location_y || 0);
const p1Clock = Number(i.survival_clock);
const p1Suffix = (sev === 'PRIORITY_1' && Number.isFinite(p1Clock)) ? ` (${p1Clock.toFixed(0)}s)` : '';
svg.appendChild(svgEl('circle', { cx: x, cy: y, r: Math.max(0.7, Math.min(w,h) * 0.012), fill: color, stroke: '#0f172a', 'stroke-width': 0.3 }));
svg.appendChild(svgEl('text', { x: x + 1, y: y - 1, fill: '#e2e8f0', 'font-size': 2.8, 'font-family': 'monospace' })).textContent = `${i.incident_id}${p1Suffix}`;
}
// units
for (const u of Object.values(state.units || {})) {
const color = STATUS_COLORS[u.status] || '#94a3b8';
const x = Number(u.location_x || 0);
const y = Number(u.location_y || 0);
const size = Math.max(0.8, Math.min(w,h) * 0.012);
svg.appendChild(svgEl('rect', { x: x - size/2, y: y - size/2, width: size, height: size, fill: color, stroke: '#0f172a', 'stroke-width': 0.3 }));
svg.appendChild(svgEl('text', { x: x + 1, y: y + 3, fill: '#cbd5e1', 'font-size': 2.6, 'font-family': 'monospace' })).textContent = u.unit_id;
}
}
function renderBreakdown(state) {
const obs = state.observation || null;
const breakdown = (obs && obs.reward_breakdown) ? obs.reward_breakdown : null;
const keys = ['response_time','triage','survival','coverage','protocol'];
for (const k of keys) {
const v = breakdown ? clamp01(breakdown[k]) : 0;
setText(`v-${k}`, fmt(v, 2));
const fill = document.getElementById(`b-${k}`);
if (fill) fill.style.width = `${(v * 100).toFixed(0)}%`;
}
}
function updateHeader(state) {
setText('hdr-schema', (state.metadata && state.metadata.schema) ? String(state.metadata.schema) : '—');
setText('hdr-task', state.task_id || '—');
setText('hdr-episode', state.episode_id ? String(state.episode_id).slice(0, 8) : '—');
setText('hdr-step', (state.step_count !== undefined) ? String(state.step_count) : '—');
const episodeScore = state.metadata && state.metadata.episode_score;
const cum = state.metadata && state.metadata.cumulative_reward;
setText('hdr-episode-score', (episodeScore !== undefined) ? fmt(episodeScore, 3) : '—');
setText('hdr-cum-reward', (cum !== undefined) ? fmt(cum, 3) : '—');
}
function updateHistory(state) {
const obs = state.observation;
if (!obs) return;
if (lastHistoryEpisode !== state.episode_id) {
lastHistoryEpisode = state.episode_id;
lastHistoryStep = -1;
eventHistory = [];
}
if (state.step_count === lastHistoryStep) return;
const issues = Array.isArray(state.issues) ? state.issues : [];
eventHistory.unshift({
step: state.step_count,
result: obs.result || 'state updated',
issues,
});
eventHistory = eventHistory.slice(0, HISTORY_LIMIT);
lastHistoryStep = state.step_count;
}
function renderHistory() {
const root = document.getElementById('history');
if (!root) return;
if (eventHistory.length === 0) {
root.innerHTML = '<div class="muted">No events yet</div>';
return;
}
root.innerHTML = eventHistory.map((item) => {
const issueText = item.issues.length > 0
? `<span class="history-issues">issues: ${escapeHtml(item.issues.join(', '))}</span>`
: '';
return `<div class="history-item"><span class="history-step">step ${item.step}</span>${escapeHtml(item.result)}${issueText}</div>`;
}).join('');
}
async function tick() {
const status = document.getElementById('status');
try {
const res = await fetch(DASHBOARD_STATE, { cache: 'no-store' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const state = await res.json();
if (!state || !state.units || !state.incidents) {
status.textContent = 'Waiting for /reset…';
return;
}
updateHeader(state);
renderUnits(state);
renderMap(state);
renderIncidents(state);
renderBreakdown(state);
updateHistory(state);
renderHistory();
const issueList = Array.isArray(state.issues) ? state.issues : [];
const issuePreview = issueList.length > 0 ? issueList.slice(0, 2).join(', ') : 'none';
status.textContent = `Connected · issues=${issueList.length} (${issuePreview}) · refresh=${REFRESH_MS}ms`;
} catch (e) {
status.textContent = `Disconnected · start server on :8000 (${String(e.message || e)})`;
}
}
setInterval(tick, REFRESH_MS);
tick();
</script>
</body>
</html>