feat(portal): M2b-1 — alert rule config UI on the SLM detail page
Adds an "Alerts" card to /slm/{id}: lists rules and a create/edit/delete form
(simple-first — "Alert when [Leq] is [above] [65] dB for [N] s", optional
time-of-day window + day picker, advanced hysteresis/cooldown collapsed). Talks
to the existing SLMM alert CRUD via the proxy (/api/slmm/{unit}/alerts/rules);
no SLMM changes. Rule changes invalidate the evaluator's cache server-side.
Verified: alerts script JS balances, slm_detail.html parses, and the TV proxy
forwards method + JSON body + query params for POST/PUT/DELETE.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -112,4 +112,190 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Alerts -->
|
||||||
|
<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>
|
||||||
|
<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"
|
||||||
|
class="px-3 py-1.5 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">+ Add alert</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="alert-rules-list" class="space-y-2"></div>
|
||||||
|
|
||||||
|
<!-- create / edit form -->
|
||||||
|
<div id="alert-form" class="hidden mt-4 p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900/40">
|
||||||
|
<input type="hidden" id="ar-id">
|
||||||
|
<div class="grid sm:grid-cols-2 gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Name</label>
|
||||||
|
<input id="ar-name" type="text" placeholder="e.g. Night noise limit"
|
||||||
|
class="w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm text-gray-800 dark:text-gray-200">
|
||||||
|
</div>
|
||||||
|
<label class="flex items-end gap-2 text-sm text-gray-700 dark:text-gray-300 pb-1">
|
||||||
|
<input type="checkbox" id="ar-enabled" checked class="rounded"> Enabled
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<span>Alert when</span>
|
||||||
|
<select id="ar-metric" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
|
||||||
|
<option value="leq">Leq</option><option value="lp">Lp</option>
|
||||||
|
<option value="lmax">Lmax</option><option value="lpeak">Lpeak</option>
|
||||||
|
<option value="ln1">L1</option><option value="ln2">L10</option>
|
||||||
|
</select>
|
||||||
|
<span>is</span>
|
||||||
|
<select id="ar-comparison" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
|
||||||
|
<option value="above">above</option><option value="below">below</option>
|
||||||
|
</select>
|
||||||
|
<input id="ar-threshold" type="number" step="0.1" placeholder="65"
|
||||||
|
class="w-20 px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"> <span>dB</span>
|
||||||
|
<span>for</span>
|
||||||
|
<input id="ar-duration" type="number" min="0" value="0"
|
||||||
|
class="w-20 px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"> <span>seconds</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<input type="checkbox" id="ar-sched-on" onchange="toggleSchedule()" class="rounded"> Only during certain hours
|
||||||
|
</label>
|
||||||
|
<div id="ar-sched" class="hidden mt-2 flex flex-wrap items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<span>from</span><input id="ar-start" type="time" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
|
||||||
|
<span>to</span><input id="ar-end" type="time" class="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800">
|
||||||
|
<span class="ml-2">on</span>
|
||||||
|
<span id="ar-days" class="flex gap-1"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<summary class="cursor-pointer select-none">Advanced</summary>
|
||||||
|
<div class="mt-2 flex flex-wrap items-center gap-3">
|
||||||
|
<span>Clear margin</span><input id="ar-margin" type="number" step="0.1" value="2"
|
||||||
|
class="w-16 px-2 py-1 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"><span>dB (hysteresis)</span>
|
||||||
|
<span>Cooldown</span><input id="ar-cooldown" type="number" min="0" value="300"
|
||||||
|
class="w-20 px-2 py-1 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"><span>s</span>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<button onclick="saveAlertRule()" type="button" class="px-3 py-1.5 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Save</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>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const ALERT_UNIT = "{{ unit_id }}";
|
||||||
|
const METRIC_LABELS = { leq: 'Leq', lp: 'Lp', lmax: 'Lmax', lpeak: 'Lpeak', ln1: 'L1', ln2: 'L10' };
|
||||||
|
const DAY_LABELS = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; // Mon=0 .. Sun=6
|
||||||
|
|
||||||
|
// Render the day checkboxes once.
|
||||||
|
(function () {
|
||||||
|
const wrap = document.getElementById('ar-days');
|
||||||
|
DAY_LABELS.forEach((lbl, i) => {
|
||||||
|
const l = document.createElement('label');
|
||||||
|
l.className = 'inline-flex items-center gap-0.5';
|
||||||
|
l.innerHTML = `<input type="checkbox" id="ar-day-${i}" class="rounded"><span class="ml-0.5">${lbl}</span>`;
|
||||||
|
wrap.appendChild(l);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
function condText(r) {
|
||||||
|
const m = METRIC_LABELS[r.metric] || r.metric;
|
||||||
|
let s = `${m} ${r.comparison} ${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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRule(r) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700';
|
||||||
|
row.innerHTML = `<div class="min-w-0">
|
||||||
|
<div class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">${r.name}${r.enabled ? '' : ' <span class="text-xs text-gray-400">(disabled)</span>'}</div>
|
||||||
|
<div class="text-xs text-gray-500">${condText(r)}</div></div>
|
||||||
|
<div class="shrink-0 flex items-center gap-3 text-xs">
|
||||||
|
<button data-act="edit" class="text-seismo-orange hover:underline">Edit</button>
|
||||||
|
<button data-act="del" class="text-red-600 hover:underline">Delete</button>
|
||||||
|
</div>`;
|
||||||
|
row.querySelector('[data-act="edit"]').onclick = () => openAlertForm(r);
|
||||||
|
row.querySelector('[data-act="del"]').onclick = () => deleteAlertRule(r.id);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAlertRules() {
|
||||||
|
const list = document.getElementById('alert-rules-list');
|
||||||
|
try {
|
||||||
|
const j = await (await fetch(`/api/slmm/${ALERT_UNIT}/alerts/rules`)).json();
|
||||||
|
const rules = j.rules || [];
|
||||||
|
if (!rules.length) { list.innerHTML = '<div class="text-sm text-gray-400">No alerts configured.</div>'; return; }
|
||||||
|
list.innerHTML = '';
|
||||||
|
rules.forEach(r => list.appendChild(renderRule(r)));
|
||||||
|
} catch (e) { list.innerHTML = '<div class="text-sm text-red-500">Failed to load alerts.</div>'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSchedule() {
|
||||||
|
document.getElementById('ar-sched').classList.toggle('hidden', !document.getElementById('ar-sched-on').checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAlertForm(r) {
|
||||||
|
document.getElementById('alert-form').classList.remove('hidden');
|
||||||
|
document.getElementById('ar-id').value = r ? r.id : '';
|
||||||
|
document.getElementById('ar-name').value = r ? r.name : '';
|
||||||
|
document.getElementById('ar-metric').value = r ? r.metric : 'leq';
|
||||||
|
document.getElementById('ar-comparison').value = r ? r.comparison : 'above';
|
||||||
|
document.getElementById('ar-threshold').value = (r && r.threshold_db != null) ? r.threshold_db : '';
|
||||||
|
document.getElementById('ar-duration').value = r ? r.duration_s : 0;
|
||||||
|
document.getElementById('ar-enabled').checked = r ? r.enabled : true;
|
||||||
|
document.getElementById('ar-margin').value = r ? r.clear_margin_db : 2;
|
||||||
|
document.getElementById('ar-cooldown').value = r ? r.cooldown_s : 300;
|
||||||
|
const hasSched = !!(r && r.schedule_start && r.schedule_end);
|
||||||
|
document.getElementById('ar-sched-on').checked = hasSched;
|
||||||
|
document.getElementById('ar-start').value = hasSched ? r.schedule_start : '';
|
||||||
|
document.getElementById('ar-end').value = hasSched ? r.schedule_end : '';
|
||||||
|
const days = (r && r.schedule_days) ? r.schedule_days.split(',') : [];
|
||||||
|
DAY_LABELS.forEach((_, i) => { document.getElementById('ar-day-' + i).checked = days.includes(String(i)); });
|
||||||
|
toggleSchedule();
|
||||||
|
}
|
||||||
|
function closeAlertForm() { document.getElementById('alert-form').classList.add('hidden'); }
|
||||||
|
|
||||||
|
async function saveAlertRule() {
|
||||||
|
const id = document.getElementById('ar-id').value;
|
||||||
|
const threshold = parseFloat(document.getElementById('ar-threshold').value);
|
||||||
|
if (isNaN(threshold)) { if (window.showToast) showToast('Enter a threshold', 'error'); return; }
|
||||||
|
const schedOn = document.getElementById('ar-sched-on').checked;
|
||||||
|
const days = DAY_LABELS.map((_, i) => document.getElementById('ar-day-' + i).checked ? i : null).filter(v => v !== null);
|
||||||
|
const payload = {
|
||||||
|
name: document.getElementById('ar-name').value || 'Alert',
|
||||||
|
metric: document.getElementById('ar-metric').value,
|
||||||
|
comparison: document.getElementById('ar-comparison').value,
|
||||||
|
threshold_db: threshold,
|
||||||
|
duration_s: parseInt(document.getElementById('ar-duration').value) || 0,
|
||||||
|
clear_margin_db: parseFloat(document.getElementById('ar-margin').value) || 2,
|
||||||
|
cooldown_s: parseInt(document.getElementById('ar-cooldown').value) || 300,
|
||||||
|
schedule_start: schedOn ? (document.getElementById('ar-start').value || null) : null,
|
||||||
|
schedule_end: schedOn ? (document.getElementById('ar-end').value || null) : null,
|
||||||
|
schedule_days: (schedOn && days.length) ? days.join(',') : null,
|
||||||
|
enabled: document.getElementById('ar-enabled').checked,
|
||||||
|
};
|
||||||
|
const url = id ? `/api/slmm/${ALERT_UNIT}/alerts/rules/${id}` : `/api/slmm/${ALERT_UNIT}/alerts/rules`;
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, { method: id ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
||||||
|
if (!r.ok) throw new Error('save failed');
|
||||||
|
closeAlertForm(); loadAlertRules();
|
||||||
|
if (window.showToast) showToast('Alert saved', 'success');
|
||||||
|
} catch (e) { if (window.showToast) showToast('Failed to save alert', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAlertRule(id) {
|
||||||
|
if (!confirm('Delete this alert rule?')) return;
|
||||||
|
try { await fetch(`/api/slmm/${ALERT_UNIT}/alerts/rules/${id}`, { method: 'DELETE' }); loadAlertRules(); }
|
||||||
|
catch (e) { if (window.showToast) showToast('Failed to delete', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAlertRules();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user