0914cf0a75
Internal (SLM detail page): live alarm-state badge in the Alerts header
(● N active / ✓ all clear), a History list of fired events (onset → clear, peak
dB, ack status) with an Ack button, refreshed every 20s. Reads the existing SLMM
/alerts/events + /ack via the proxy.
Portal (client, read-only, scoped): new GET /portal/api/location/{id}/events —
ownership-gated, returns a scrubbed projection (rule_name/metric/threshold/onset/
peak/clear/status only; no internal ids or ack-by) plus an `active` count. The
location page shows a red "Currently above threshold" banner when active and a
read-only breach history, polled every 20s. No ack on the client side.
Verified: portal.py compiles; both scripts balance; both templates parse.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
261 lines
12 KiB
HTML
261 lines
12 KiB
HTML
{% extends "portal/base.html" %}
|
|
{% block title %}{{ location.name }}{% endblock %}
|
|
{% block head %}
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
|
{% endblock %}
|
|
{% block content %}
|
|
<a href="/portal" class="text-sm text-gray-400 hover:text-gray-200">← All locations</a>
|
|
<h1 class="text-2xl font-semibold mt-1">{{ location.name }}</h1>
|
|
<div class="flex items-center gap-2 text-sm mt-1 mb-6">
|
|
<span id="p-badge" class="hidden px-2 py-0.5 text-xs font-medium rounded-full"></span>
|
|
<span id="p-fresh" class="text-gray-400"></span>
|
|
</div>
|
|
|
|
{% if not has_device %}
|
|
<div class="rounded-xl border border-slate-700 bg-slate-800/50 p-8 text-center text-gray-400">
|
|
No device is currently assigned to this location.
|
|
</div>
|
|
{% else %}
|
|
<div id="p-alarm-banner" class="hidden mb-4 px-4 py-3 rounded-lg bg-red-900/30 border border-red-700/50 text-red-200 text-sm flex items-center gap-2">
|
|
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M12 9v2m0 4h.01M5.07 19h13.86c1.54 0 2.5-1.67 1.73-3L13.73 4a2 2 0 00-3.46 0L3.34 16c-.77 1.33.19 3 1.73 3z"/>
|
|
</svg>
|
|
<span id="p-alarm-text">Currently above threshold.</span>
|
|
</div>
|
|
<div class="grid grid-cols-2 sm:grid-cols-5 gap-3 mb-6">
|
|
<div class="rounded-lg bg-slate-800/60 border border-slate-700 p-3">
|
|
<div class="text-xs text-gray-400">Lp (Instant)</div>
|
|
<div id="p-lp" class="text-2xl font-bold text-blue-400">--</div><div class="text-xs text-gray-500">dB</div>
|
|
</div>
|
|
<div class="rounded-lg bg-slate-800/60 border border-slate-700 p-3">
|
|
<div class="text-xs text-gray-400">Leq (Average)</div>
|
|
<div id="p-leq" class="text-2xl font-bold text-green-400">--</div><div class="text-xs text-gray-500">dB</div>
|
|
</div>
|
|
<div class="rounded-lg bg-slate-800/60 border border-slate-700 p-3">
|
|
<div class="text-xs text-gray-400">Lmax (Max)</div>
|
|
<div id="p-lmax" class="text-2xl font-bold text-red-400">--</div><div class="text-xs text-gray-500">dB</div>
|
|
</div>
|
|
<div class="rounded-lg bg-slate-800/60 border border-slate-700 p-3">
|
|
<div class="text-xs text-gray-400">L1</div>
|
|
<div id="p-ln1" class="text-2xl font-bold text-purple-400">--</div><div class="text-xs text-gray-500">dB</div>
|
|
</div>
|
|
<div class="rounded-lg bg-slate-800/60 border border-slate-700 p-3">
|
|
<div class="text-xs text-gray-400">L10</div>
|
|
<div id="p-ln2" class="text-2xl font-bold text-orange-400">--</div><div class="text-xs text-gray-500">dB</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="relative rounded-xl border border-slate-700 bg-slate-800/50 p-4" style="min-height: 360px;">
|
|
<canvas id="p-chart"></canvas>
|
|
<div id="p-paused" class="hidden absolute inset-0 flex items-center justify-center bg-slate-900/70 rounded-xl">
|
|
<button onclick="resumeStream()"
|
|
class="px-4 py-2 rounded-lg bg-seismo-orange/20 text-seismo-orange border border-seismo-orange/40 hover:bg-seismo-orange/30 text-sm font-medium">
|
|
⏸ Live paused — click to resume
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Alert history (read-only) -->
|
|
<div class="mt-6">
|
|
<h2 class="text-sm font-medium text-gray-400 mb-2">Alert history</h2>
|
|
<div id="p-events" class="space-y-2"></div>
|
|
</div>
|
|
{% endif %}
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
{% if has_device %}
|
|
<script>
|
|
const LOC_ID = "{{ location.id }}";
|
|
const cd = { t: [], lp: [], leq: [], ln1: [], ln2: [] };
|
|
let chart;
|
|
const numOrNull = v => { const f = parseFloat(v); return isNaN(f) ? null : f; };
|
|
|
|
function ds(label, color) {
|
|
return { label, data: [], borderColor: color, backgroundColor: color,
|
|
borderWidth: 2, pointRadius: 0, tension: 0.3, spanGaps: true };
|
|
}
|
|
function initChart() {
|
|
const ctx = document.getElementById('p-chart').getContext('2d');
|
|
chart = new Chart(ctx, {
|
|
type: 'line',
|
|
data: { labels: [], datasets: [
|
|
ds('Lp', 'rgb(96,165,250)'), ds('Leq', 'rgb(74,222,128)'),
|
|
ds('L1', 'rgb(192,132,252)'), ds('L10', 'rgb(251,146,60)') ] },
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false, animation: false,
|
|
interaction: { intersect: false, mode: 'index' },
|
|
scales: {
|
|
y: { min: 30, max: 130, title: { display: true, text: 'dB', color: '#94a3b8' },
|
|
ticks: { color: '#94a3b8' }, grid: { color: 'rgba(148,163,184,0.12)' } },
|
|
x: { ticks: { color: '#94a3b8', maxTicksLimit: 8 }, grid: { color: 'rgba(148,163,184,0.12)' } }
|
|
},
|
|
plugins: { legend: { labels: { color: '#cbd5e1' } } }
|
|
}
|
|
});
|
|
}
|
|
|
|
function setCard(id, v) { document.getElementById(id).textContent = (v == null || v === '') ? '--' : v; }
|
|
function setBadge(measuring, lastSeen) {
|
|
const b = document.getElementById('p-badge'), f = document.getElementById('p-fresh');
|
|
if (measuring === null) { b.className = 'hidden px-2 py-0.5 text-xs font-medium rounded-full'; b.textContent = ''; }
|
|
else if (measuring) { b.className = 'px-2 py-0.5 text-xs font-medium rounded-full bg-green-900/40 text-green-300'; b.textContent = '● Live'; }
|
|
else { b.className = 'px-2 py-0.5 text-xs font-medium rounded-full bg-slate-700 text-gray-300'; b.textContent = '■ Stopped'; }
|
|
f.innerHTML = fmtFreshness(lastSeen);
|
|
}
|
|
function fmtFreshness(iso) {
|
|
if (!iso) return '<span class="text-gray-500">no recent reading</span>';
|
|
const t = new Date(iso.endsWith('Z') ? iso : iso + 'Z');
|
|
const s = Math.max(0, Math.round((Date.now() - t.getTime()) / 1000));
|
|
let ago, stale = false;
|
|
if (s < 10) ago = 'just now';
|
|
else if (s < 60) ago = s + 's ago';
|
|
else if (s < 3600) { ago = Math.round(s / 60) + 'm ago'; stale = s >= 300; }
|
|
else { ago = Math.round(s / 3600) + 'h ago'; stale = true; }
|
|
const cls = stale ? 'text-amber-400' : 'text-gray-400';
|
|
return `as of ${t.toLocaleTimeString()} <span class="${cls}">(${ago}${stale ? ' · cached' : ''})</span>`;
|
|
}
|
|
|
|
async function prefill() {
|
|
try {
|
|
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/live`)).json();
|
|
const d = j.data;
|
|
if (!d) {
|
|
setBadge(null, null);
|
|
document.getElementById('p-fresh').textContent =
|
|
j.reason === 'no_device' ? 'No device assigned' : 'Currently unreachable';
|
|
return;
|
|
}
|
|
setCard('p-lp', d.lp); setCard('p-leq', d.leq); setCard('p-lmax', d.lmax);
|
|
setCard('p-ln1', d.ln1); setCard('p-ln2', d.ln2);
|
|
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
|
|
setBadge(measuring, d.last_seen);
|
|
} catch (e) { /* keep last values */ }
|
|
}
|
|
async function backfill() {
|
|
try {
|
|
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/history?hours=2`)).json();
|
|
for (const row of (j.readings || [])) {
|
|
cd.t.push(row.timestamp ? new Date(row.timestamp + 'Z').toLocaleTimeString() : '');
|
|
cd.lp.push(numOrNull(row.lp)); cd.leq.push(numOrNull(row.leq));
|
|
cd.ln1.push(numOrNull(row.ln1)); cd.ln2.push(numOrNull(row.ln2));
|
|
}
|
|
chart.data.labels = cd.t;
|
|
chart.data.datasets[0].data = cd.lp; chart.data.datasets[1].data = cd.leq;
|
|
chart.data.datasets[2].data = cd.ln1; chart.data.datasets[3].data = cd.ln2;
|
|
chart.update('none');
|
|
} catch (e) { /* leave chart empty */ }
|
|
}
|
|
|
|
// ---- live stream (upgrades the cache prefill to a real ~1Hz feed) --------
|
|
let ws = null, hardCap = null, paused = false;
|
|
const IDLE_CAP_MS = 15 * 60 * 1000; // auto-close after 15 min so an abandoned
|
|
// tab doesn't pin the device at 1Hz polling
|
|
|
|
function pushPoint(d) {
|
|
cd.t.push(new Date().toLocaleTimeString());
|
|
cd.lp.push(numOrNull(d.lp)); cd.leq.push(numOrNull(d.leq));
|
|
cd.ln1.push(numOrNull(d.ln1)); cd.ln2.push(numOrNull(d.ln2));
|
|
if (cd.t.length > 600) { cd.t.shift(); cd.lp.shift(); cd.leq.shift(); cd.ln1.shift(); cd.ln2.shift(); }
|
|
chart.data.labels = cd.t;
|
|
chart.data.datasets[0].data = cd.lp; chart.data.datasets[1].data = cd.leq;
|
|
chart.data.datasets[2].data = cd.ln1; chart.data.datasets[3].data = cd.ln2;
|
|
chart.update('none');
|
|
}
|
|
|
|
function openStream() {
|
|
if (paused || ws) return;
|
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
ws = new WebSocket(`${proto}//${location.host}/portal/api/location/${encodeURIComponent(LOC_ID)}/stream`);
|
|
ws.onmessage = (e) => {
|
|
let d; try { d = JSON.parse(e.data); } catch (_) { return; }
|
|
if (d.feed_status === 'no_device') {
|
|
setBadge(null, null);
|
|
document.getElementById('p-fresh').textContent = 'No device assigned';
|
|
return;
|
|
}
|
|
if (d.heartbeat) return;
|
|
if (d.feed_status === 'unreachable') {
|
|
document.getElementById('p-fresh').innerHTML = '<span class="text-amber-400">device unreachable</span>';
|
|
return;
|
|
}
|
|
setCard('p-lp', d.lp); setCard('p-leq', d.leq); setCard('p-lmax', d.lmax);
|
|
setCard('p-ln1', d.ln1); setCard('p-ln2', d.ln2);
|
|
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
|
|
setBadge(measuring, d.timestamp || new Date().toISOString());
|
|
pushPoint(d);
|
|
};
|
|
ws.onclose = () => { ws = null; };
|
|
ws.onerror = () => {};
|
|
clearTimeout(hardCap);
|
|
hardCap = setTimeout(() => { paused = true; closeStream(); showPaused(true); }, IDLE_CAP_MS);
|
|
}
|
|
|
|
function closeStream() {
|
|
clearTimeout(hardCap);
|
|
if (ws) { try { ws.close(); } catch (_) {} ws = null; }
|
|
}
|
|
|
|
function showPaused(on) {
|
|
const el = document.getElementById('p-paused');
|
|
if (el) el.classList.toggle('hidden', !on);
|
|
}
|
|
function resumeStream() {
|
|
paused = false; showPaused(false);
|
|
prefill(); // refresh cards instantly on resume
|
|
openStream();
|
|
}
|
|
|
|
// Stop streaming when the tab is hidden (client switched away / locked phone) and
|
|
// resume when it's visible again — the main cost guard, so the device relaxes back
|
|
// to its idle poll rate the moment nobody is actually looking.
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.hidden) closeStream();
|
|
else if (!paused) openStream();
|
|
});
|
|
window.addEventListener('beforeunload', closeStream);
|
|
|
|
// ---- alert history + current-alarm banner (read-only) --------------------
|
|
const EV_METRIC = { leq: 'Leq', lp: 'Lp', lmax: 'Lmax', lpeak: 'Lpeak', ln1: 'L1', ln2: 'L10' };
|
|
function fmtAlertTime(iso) { return iso ? new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString() : ''; }
|
|
|
|
async function loadEvents() {
|
|
try {
|
|
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/events?limit=20`)).json();
|
|
const events = j.events || [];
|
|
const banner = document.getElementById('p-alarm-banner');
|
|
if (j.active) {
|
|
banner.classList.remove('hidden');
|
|
document.getElementById('p-alarm-text').textContent =
|
|
j.active > 1 ? `${j.active} alerts currently active` : 'Currently above threshold.';
|
|
} else banner.classList.add('hidden');
|
|
const list = document.getElementById('p-events');
|
|
if (!events.length) { list.innerHTML = '<div class="text-sm text-gray-500">No alerts have fired.</div>'; return; }
|
|
list.innerHTML = '';
|
|
for (const e of events) {
|
|
const m = EV_METRIC[e.metric] || e.metric;
|
|
const active = e.status === 'active';
|
|
const when = active ? `since ${fmtAlertTime(e.onset_at)}`
|
|
: `${fmtAlertTime(e.onset_at)} → ${fmtAlertTime(e.clear_at)}`;
|
|
const peak = (e.peak_value != null) ? ` · peak ${e.peak_value} dB` : '';
|
|
const row = document.createElement('div');
|
|
row.className = 'px-3 py-2 rounded-lg border text-sm ' +
|
|
(active ? 'border-red-700/60 bg-red-900/20 text-red-200' : 'border-slate-700 bg-slate-800/40 text-gray-300');
|
|
row.innerHTML = `<div>${e.rule_name || 'Alert'} <span class="text-xs text-gray-400">· ${m} ${e.threshold_db} dB</span></div>
|
|
<div class="text-xs text-gray-400">${when}${peak}</div>`;
|
|
list.appendChild(row);
|
|
}
|
|
} catch (e) { /* leave history as-is */ }
|
|
}
|
|
|
|
initChart();
|
|
prefill(); // instant first paint from cache
|
|
backfill(); // seed the chart trail
|
|
openStream(); // then upgrade to the live feed
|
|
loadEvents();
|
|
setInterval(loadEvents, 20000);
|
|
</script>
|
|
{% endif %}
|
|
{% endblock %}
|