diff --git a/backend/routers/portal.py b/backend/routers/portal.py
index 6af574e..6f86108 100644
--- a/backend/routers/portal.py
+++ b/backend/routers/portal.py
@@ -214,6 +214,34 @@ async def portal_location_history(location_id: str, hours: float = 2.0,
return {"status": "ok", "readings": (r.json() or {}).get("readings", [])}
+# Whitelist of alert-event fields exposed to a client (no internal ids/ack-by).
+_PORTAL_EVENT_FIELDS = ("rule_name", "metric", "threshold_db", "onset_at",
+ "onset_value", "peak_value", "clear_at", "status")
+
+
+@router.get("/api/location/{location_id}/events")
+async def portal_location_events(location_id: str, limit: int = 20,
+ client: Client = Depends(get_current_client),
+ db: Session = Depends(get_db)):
+ """Scrubbed breach history for a location the client owns (read-only)."""
+ resolve_client_location(client, location_id, db)
+ unit_id = active_unit_for_location(location_id, db)
+ if not unit_id:
+ return {"status": "ok", "events": []}
+ limit = max(1, min(limit, 100))
+ try:
+ async with httpx.AsyncClient(timeout=5.0) as hc:
+ r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/alerts/events",
+ params={"limit": limit})
+ except Exception:
+ return {"status": "ok", "events": []}
+ if r.status_code != 200:
+ return {"status": "ok", "events": []}
+ raw = (r.json() or {}).get("events", [])
+ events = [{k: e.get(k) for k in _PORTAL_EVENT_FIELDS} for e in raw]
+ return {"status": "ok", "events": events, "active": sum(1 for e in events if e.get("status") == "active")}
+
+
# -- live stream (fan-out feed, scoped + scrubbed) ---------------------------
def _scrub_frame(raw: str) -> str:
diff --git a/templates/portal/location.html b/templates/portal/location.html
index 1a3148c..7ebe04e 100644
--- a/templates/portal/location.html
+++ b/templates/portal/location.html
@@ -16,6 +16,13 @@
No device is currently assigned to this location.
{% else %}
+
+
+
Currently above threshold.
+
Lp (Instant)
@@ -48,6 +55,12 @@
+
+
+
{% 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 = 'No alerts have fired.
'; 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 = `${e.rule_name || 'Alert'} · ${m} ${e.threshold_db} dB
+ ${when}${peak}
`;
+ 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);
{% endif %}
{% endblock %}
diff --git a/templates/slm_detail.html b/templates/slm_detail.html
index def5ed4..abeccbe 100644
--- a/templates/slm_detail.html
+++ b/templates/slm_detail.html
@@ -117,7 +117,9 @@
-
Alerts
+
Alerts
+
+
Threshold rules evaluated on this device's live feed.
+
+
+
+
+
History
+
+
+
+
{% endblock %}