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
+78 -1
View File
@@ -117,7 +117,9 @@
<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>
<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>
</div>
<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>
</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>
<script>
@@ -296,6 +307,72 @@ async function deleteAlertRule(id) {
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();
loadAlertEvents();
setInterval(loadAlertEvents, 20000); // surface new breaches / clears
</script>
{% endblock %}