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:
@@ -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", [])}
|
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) ---------------------------
|
# -- live stream (fan-out feed, scoped + scrubbed) ---------------------------
|
||||||
|
|
||||||
def _scrub_frame(raw: str) -> str:
|
def _scrub_frame(raw: str) -> str:
|
||||||
|
|||||||
@@ -16,6 +16,13 @@
|
|||||||
No device is currently assigned to this location.
|
No device is currently assigned to this location.
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% 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="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="rounded-lg bg-slate-800/60 border border-slate-700 p-3">
|
||||||
<div class="text-xs text-gray-400">Lp (Instant)</div>
|
<div class="text-xs text-gray-400">Lp (Instant)</div>
|
||||||
@@ -48,6 +55,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -203,10 +216,45 @@ document.addEventListener('visibilitychange', () => {
|
|||||||
});
|
});
|
||||||
window.addEventListener('beforeunload', closeStream);
|
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();
|
initChart();
|
||||||
prefill(); // instant first paint from cache
|
prefill(); // instant first paint from cache
|
||||||
backfill(); // seed the chart trail
|
backfill(); // seed the chart trail
|
||||||
openStream(); // then upgrade to the live feed
|
openStream(); // then upgrade to the live feed
|
||||||
|
loadEvents();
|
||||||
|
setInterval(loadEvents, 20000);
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -117,7 +117,9 @@
|
|||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mt-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mt-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Alerts</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white flex items-center gap-2">Alerts
|
||||||
|
<span id="alert-state-badge" class="hidden text-xs px-2 py-0.5 rounded-full"></span>
|
||||||
|
</h2>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">Threshold rules evaluated on this device's live feed.</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400">Threshold rules evaluated on this device's live feed.</p>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="openAlertForm()" type="button"
|
<button onclick="openAlertForm()" type="button"
|
||||||
@@ -185,6 +187,15 @@
|
|||||||
<button onclick="closeAlertForm()" type="button" class="px-3 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-700">Cancel</button>
|
<button onclick="closeAlertForm()" type="button" class="px-3 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-700">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Alert history -->
|
||||||
|
<div class="mt-6 pt-4 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">History</h3>
|
||||||
|
<button onclick="loadAlertEvents()" type="button" class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="alert-events" class="space-y-2"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -296,6 +307,72 @@ async function deleteAlertRule(id) {
|
|||||||
catch (e) { if (window.showToast) showToast('Failed to delete', 'error'); }
|
catch (e) { if (window.showToast) showToast('Failed to delete', 'error'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- alert history (events) ----------------------------------------------
|
||||||
|
|
||||||
|
function fmtAlertTime(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAlertState(events) {
|
||||||
|
const badge = document.getElementById('alert-state-badge');
|
||||||
|
badge.classList.remove('hidden');
|
||||||
|
const active = events.filter(e => e.status === 'active').length;
|
||||||
|
if (active) {
|
||||||
|
badge.textContent = `● ${active} active`;
|
||||||
|
badge.className = 'text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300';
|
||||||
|
} else {
|
||||||
|
badge.textContent = '✓ All clear';
|
||||||
|
badge.className = 'text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEvent(e) {
|
||||||
|
const m = METRIC_LABELS[e.metric] || e.metric;
|
||||||
|
const active = e.status === 'active';
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border ' +
|
||||||
|
(active ? 'border-red-300 dark:border-red-800 bg-red-50 dark:bg-red-900/20'
|
||||||
|
: 'border-slate-200 dark:border-slate-700');
|
||||||
|
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 ack = e.acknowledged_at ? ` · ack'd${e.acknowledged_by ? ' by ' + e.acknowledged_by : ''}` : '';
|
||||||
|
row.innerHTML = `<div class="min-w-0">
|
||||||
|
<div class="text-sm truncate">
|
||||||
|
<span class="${active ? 'text-red-600 dark:text-red-400 font-medium' : 'text-gray-800 dark:text-gray-200'}">${e.rule_name || 'Alert'}</span>
|
||||||
|
<span class="text-xs text-gray-500"> · ${m} ${e.threshold_db} dB</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500">${when}${peak}${ack}</div></div>`;
|
||||||
|
if (!e.acknowledged_at) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'shrink-0 text-xs text-seismo-orange hover:underline';
|
||||||
|
btn.textContent = 'Ack';
|
||||||
|
btn.onclick = () => ackEvent(e.id);
|
||||||
|
row.appendChild(btn);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAlertEvents() {
|
||||||
|
const list = document.getElementById('alert-events');
|
||||||
|
try {
|
||||||
|
const j = await (await fetch(`/api/slmm/${ALERT_UNIT}/alerts/events?limit=50`)).json();
|
||||||
|
const events = j.events || [];
|
||||||
|
updateAlertState(events);
|
||||||
|
if (!events.length) { list.innerHTML = '<div class="text-sm text-gray-400">No alerts have fired.</div>'; return; }
|
||||||
|
list.innerHTML = '';
|
||||||
|
events.forEach(e => list.appendChild(renderEvent(e)));
|
||||||
|
} catch (e) { list.innerHTML = '<div class="text-sm text-red-500">Failed to load history.</div>'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ackEvent(id) {
|
||||||
|
try { await fetch(`/api/slmm/${ALERT_UNIT}/alerts/events/${id}/ack`, { method: 'POST' }); loadAlertEvents(); }
|
||||||
|
catch (e) { if (window.showToast) showToast('Failed to acknowledge', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
loadAlertRules();
|
loadAlertRules();
|
||||||
|
loadAlertEvents();
|
||||||
|
setInterval(loadAlertEvents, 20000); // surface new breaches / clears
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user