From 0914cf0a75326488c63d62939fcc4c80be7a547a Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 11 Jun 2026 19:02:56 +0000 Subject: [PATCH] =?UTF-8?q?feat(portal):=20M2b-2=20=E2=80=94=20surface=20a?= =?UTF-8?q?lert=20state=20+=20breach=20history=20(internal=20+=20portal)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/routers/portal.py | 28 ++++++++++++ templates/portal/location.html | 48 +++++++++++++++++++++ templates/slm_detail.html | 79 +++++++++++++++++++++++++++++++++- 3 files changed, 154 insertions(+), 1 deletion(-) 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 %} +
Lp (Instant)
@@ -48,6 +55,12 @@
+ + +
+

Alert history

+
+
{% 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 %}