From c1bc391ba265b97142f47df29fc3e5eef426cd7b Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 11 Jun 2026 23:28:57 +0000 Subject: [PATCH] feat(portal): show the client their active alert limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New scoped GET /portal/api/location/{id}/thresholds returns the enabled alert rules (scrubbed: name/metric/comparison/threshold/duration/schedule — no cooldown or hysteresis internals). Location page renders an "Alert limits" panel above the history, e.g. "Night noise · Leq above 65 dB for 60s · 22:00–07:00", hidden when no limits are set. Gives the breach history context. Verified: portal.py compiles; location script balances; template parses. Co-Authored-By: Claude Opus 4.8 --- backend/routers/portal.py | 28 +++++++++++++++++++++++++ templates/portal/location.html | 38 +++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/backend/routers/portal.py b/backend/routers/portal.py index 6f86108..412a7f5 100644 --- a/backend/routers/portal.py +++ b/backend/routers/portal.py @@ -242,6 +242,34 @@ async def portal_location_events(location_id: str, limit: int = 20, return {"status": "ok", "events": events, "active": sum(1 for e in events if e.get("status") == "active")} +# Whitelist of alert-rule fields shown to a client (the active limits, no cooldown/ +# hysteresis internals). +_PORTAL_RULE_FIELDS = ("name", "metric", "comparison", "threshold_db", "duration_s", + "schedule_start", "schedule_end", "schedule_days") + + +@router.get("/api/location/{location_id}/thresholds") +async def portal_location_thresholds(location_id: str, + client: Client = Depends(get_current_client), + db: Session = Depends(get_db)): + """The active alert limits for a location the client owns (enabled rules only), + so the client can see what they're being alerted on. Read-only, scrubbed.""" + resolve_client_location(client, location_id, db) + unit_id = active_unit_for_location(location_id, db) + if not unit_id: + return {"status": "ok", "rules": []} + try: + async with httpx.AsyncClient(timeout=5.0) as hc: + r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/alerts/rules") + except Exception: + return {"status": "ok", "rules": []} + if r.status_code != 200: + return {"status": "ok", "rules": []} + raw = (r.json() or {}).get("rules", []) + rules = [{k: x.get(k) for k in _PORTAL_RULE_FIELDS} for x in raw if x.get("enabled")] + return {"status": "ok", "rules": rules} + + # -- 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 88a4f1f..97d5e8b 100644 --- a/templates/portal/location.html +++ b/templates/portal/location.html @@ -69,8 +69,14 @@ + + + -
+
Alert history
@@ -260,6 +266,35 @@ window.addEventListener('beforeunload', closeStream); 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() : ''; } +// ---- alert limits (the active thresholds, read-only) --------------------- +function fmtThreshold(r) { + const m = EV_METRIC[r.metric] || r.metric; + const cmp = r.comparison === 'below' ? 'below' : 'above'; + let s = `${m} ${cmp} ${r.threshold_db} dB`; + if (r.duration_s) s += ` for ${r.duration_s}s`; + if (r.schedule_start && r.schedule_end) s += ` · ${r.schedule_start}–${r.schedule_end}`; + return s; +} +async function loadThresholds() { + const sec = document.getElementById('p-limits-section'); + try { + const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/thresholds`)).json(); + const rules = j.rules || []; + if (!rules.length) { sec.classList.add('hidden'); return; } + const list = document.getElementById('p-thresholds'); + list.innerHTML = ''; + for (const r of rules) { + const row = document.createElement('div'); + row.className = 'panel px-3.5 py-2.5 text-sm flex items-center gap-2.5'; + row.innerHTML = ` + ${r.name || 'Alert'} + ${fmtThreshold(r)}`; + list.appendChild(row); + } + sec.classList.remove('hidden'); + } catch (e) { sec.classList.add('hidden'); } +} + async function loadEvents() { try { const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/events?limit=20`)).json(); @@ -293,6 +328,7 @@ prefill(); // instant first paint from cache backfill(); // seed the chart trail openStream(); // then upgrade to the live feed loadEvents(); +loadThresholds(); setInterval(loadEvents, 20000); {% endif %}