feat(portal): M2b-2 — surface alert state + breach history (internal + portal)

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>
This commit is contained in:
2026-06-11 19:02:56 +00:00
parent 29b974a1f7
commit 0914cf0a75
3 changed files with 154 additions and 1 deletions
+48
View File
@@ -16,6 +16,13 @@
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>
@@ -48,6 +55,12 @@
</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 %}
@@ -203,10 +216,45 @@ document.addEventListener('visibilitychange', () => {
});
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 %}