Spaces:
Sleeping
Sleeping
| <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('&', '&') | |
| .replaceAll('<', '<') | |
| .replaceAll('>', '>') | |
| .replaceAll('"', '"') | |
| .replaceAll("'", '''); | |
| } | |
| 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> | |