mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
* docs(plan): #340 secubox-sentinelle-gsm v0.2 standalone webui + local alerts (ref #340) * feat(sentinelle-gsm): alert_sink — SQLite history + SSE broadcast hub (ref #340) Introduces lib/sentinelle_gsm/alert_sink.py: a SQLite-backed alert history with an in-memory asyncio pub/sub hub that fans new alerts out to live Server-Sent Events subscribers. The Alert dataclass carries only privacy-safe fields — `subscriber_hash` is HMAC-truncated (Anonymizer), never plaintext. AlertSink.write() enforces this with a load-bearing privacy guard: any string field matching `\b\d{15}\b` (plaintext IMSI shape) triggers ValueError and the row is refused. SQLite is opened with check_same_thread=False so the FastAPI app can read/write from multiple coroutines safely. Adds api/tests/test_alert_sink.py with 5 tests covering write/list roundtrip, the plaintext-IMSI privacy guard, HMAC acceptance, live subscriber fan-out, and SSE chunk format. 5 passed locally. * feat(sentinelle-gsm): trusted phones registry — HMAC-hashed IMSI + label (ref #340) Privacy contract: - Plaintext IMSI is accepted by add() ONCE, immediately HMAC-hashed via the existing sentinelle_gsm.observer.Anonymizer.anonymize() helper. - The plaintext never persisted, never logged, never returned by any method. After add() returns, plaintext_imsi is out of scope. - The on-disk store contains only {id, imsi_hash, label, added_at}. Storage format: JSON via the stdlib `json` module instead of TOML. `python3-tomli-w` (the only writer for the new tomllib reader) is not in Debian bookworm — it first ships in trixie. python3-toml (legacy) writes TOML but is deprecated. JSON is stdlib, atomically renderable, and human-grep-able, which is what a trusted-phones registry needs. Anonymizer integration: uses .anonymize() (HMAC-SHA256, 16 hex chars truncated). The plan referenced .hash_subscriber_id() — that method does not exist on Anonymizer in observer.py; .anonymize() is the canonical hashing entry point. Tests: 6/6 passing - test_add_hashes_and_does_not_store_plaintext (privacy invariant enforced: plaintext IMSI never appears on disk) - test_lookup_by_hash_finds_added_phone - test_lookup_by_hash_returns_none_for_unknown - test_delete_removes_entry - test_invalid_imsi_rejected - test_persistence_across_instances * feat(sentinelle-gsm): API — /alerts (history + SSE + test) + /trusted CRUD (ref #340) Adds 6 endpoints under the existing /api/v1/sensor/gsm/ prefix (nginx-added; mounted at root of the FastAPI app): GET /alerts — paginated SQLite-backed history (query: ?limit=100&since=<epoch>) GET /alerts/stream — SSE live feed; Cache-Control: no-cache, X-Accel-Buffering: no, Connection: keep-alive so nginx and HTTP caches don't buffer it POST /alerts/test — operator manual trigger; ValueError from the AlertSink privacy guard (plaintext-IMSI shape) surfaces as HTTP 500 GET /trusted — list trusted phones (HMAC + label, no plaintext) POST /trusted — body {imsi, label}; ValueError → 400 DELETE /trusted/{id} — unknown id → 404 Wiring: * AlertSink + TrustedRegistry are module-level singletons initialised on FastAPI startup so tests can override via monkeypatch. * `require_jwt` is a no-op dependency stub — real JWT enforcement is done by nginx + Authelia in front of the Unix socket. The stub exists so test code (and future host-direct callers) can swap auth via `app.dependency_overrides[require_jwt]`. * Anonymizer is loaded via a new `_get_anonymizer()` helper that reads /etc/secubox/secrets/sentinelle-gsm-hmac when present and falls back to an ephemeral key otherwise. * trusted.json path is /etc/secubox/sentinelle-gsm/trusted.json (the Task-2 deviation from the plan's TOML; bookworm lacks python3-tomli-w). * version bumped 0.1.0 → 0.2.0 in the FastAPI metadata only; the debian/changelog bump lands in Task 6. Tests (7 new in api/tests/): * test_alerts_api.py — POST /alerts/test happy path, GET history roundtrip, plaintext-IMSI guard → 500, SSE route registration (via app.routes + /openapi.json, not by opening the stream — the async generator awaits forever on q.get(), incompatible with sync TestClient iteration; SSE chunk format is covered by test_alert_sink.py::test_stream_emits_sse_format). * test_trusted_api.py — add/list/delete roundtrip with hash-not- plaintext invariant, invalid IMSI → 400, unknown id → 404. Run: pytest api/tests/test_alerts_api.py api/tests/test_trusted_api.py Result: 7 passed. * feat(sentinelle-gsm): standalone webui — live alerts + browser Notification API (ref #340) Adds the Task 4 standalone WebUI for sentinelle-gsm at packages/secubox-sentinelle-gsm/www/sentinelle/: - index.html — canonical SecuBox scaffold (body flex, aside.sidebar 220px fixed with section anchors, main reserves the global menu bar with padding-top: calc(48px + 1.5rem)). Four panels: status strip, alerts table (aria-live=polite), trusted phones table, actions. - sentinelle.css — MIND palette (--mind-violet: #3D35A0) per Charte SecuBox, Space Grotesk titles + JetBrains Mono data/code, modal with z-index 1500 (above the global-menu-bar z-index 998), toast banner, score chip colour grading (low/med/high), responsive breakpoints. - sentinelle.js — vanilla single-IIFE module, no framework, no CDN. EventSource consumer of /api/v1/sensor/gsm/alerts/stream (auto-reconnect surface), CRUD wrappers for /alerts /alerts/test /trusted /trusted/{id}, Browser Notification API (requested from a user-gesture handler), beep synthesised on-the-fly via Web Audio API (no asset), localStorage mute toggle, modal-based add flow, ESC/backdrop close, accessible aria-live region for the alerts list. All API calls hit root-relative /api/v1/sensor/gsm/* — Task 5 will mount the nginx route. Local smoke test booted uvicorn against the API and verified the HTML/CSS/JS serve and all four CRUD/test routes respond as the UI expects. * feat(sentinelle-gsm): nginx route /sentinelle/ + MIND menu entry + .install (ref #340) * nginx/sentinelle-webui.conf: alias for /sentinelle/ (static webui) + SSE-tuned override for /api/v1/sensor/gsm/alerts/stream. Uses `^~` literal-prefix match so it wins precedence over the shorter /api/v1/sensor/gsm/ prefix in sentinelle-gsm.conf and against any future regex locations in the same server scope. Re-sets proxy_read_timeout / proxy_send_timeout to 24h after the common secubox-proxy include (which forces 30s) so long-lived SSE connections survive. * menu.d/45-sentinelle.json: MIND-category entry (icon: satellite, order: 45). Category emitted as lowercase `mind` to match the canonical schema used by every other package menu.d entry. * debian/secubox-sentinelle-gsm.install: ship the new files. The package uses a hand-rolled override_dh_auto_install in debian/rules, so the install file is documentation; debian/rules is extended with explicit cp steps for nginx/sentinelle-webui.conf and www/sentinelle/. menu.d already had a wildcard copy. * chore(sentinelle-gsm): bump 0.2.0 + extend privacy invariant tests (closes #340) - Append 3 new privacy invariant tests: * Alert dataclass has no plaintext IMSI/IMEI/TMSI/MSISDN/ICCID fields * TrustedPhone dataclass only stores imsi_hash (never plaintext) * AlertSink.write() raises ValueError on any 15-digit token in a textual field — non-regression guard for the OPAD-doctrine privacy invariant. - Bump debian/changelog to 0.2.0-1~bookworm1 with the full Task 1-6 feature summary: alert_sink, trusted registry, REST routes, WebUI, nginx + Hub menu integration. Notes the JSON-over-TOML deviation (python3-tomli-w not in bookworm). - 29/29 tests pass: 5 alert_sink + 4 alerts_api + 3 trusted_api + 6 trusted_registry + 11 privacy_invariant (8 prior + 3 new). --------- Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
447 lines
17 KiB
JavaScript
447 lines
17 KiB
JavaScript
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
|
// Copyright (c) 2026 CyberMind — Gerald Kerma <devel@cybermind.fr>
|
|
// Source-Disclosed License — All rights reserved except as expressly granted.
|
|
// See LICENCE-CMSD-1.0.md for terms.
|
|
|
|
/**
|
|
* SecuBox-Deb :: SENTINELLE-GSM :: standalone webui logic.
|
|
*
|
|
* - Live alerts via SSE (text/event-stream, "alert" events).
|
|
* - Browser Notification API for desktop popups (requires user gesture).
|
|
* - Web Audio API synthesised beep (no static asset).
|
|
* - CRUD against /api/v1/sensor/gsm/* (root-relative; nginx adds the prefix).
|
|
*
|
|
* No framework, no CDN — pure vanilla, consistent with other SecuBox webuis.
|
|
*/
|
|
|
|
(function () {
|
|
"use strict";
|
|
|
|
const API = "/api/v1/sensor/gsm";
|
|
const MAX_ROWS = 200;
|
|
const BEEP_MUTE_KEY = "sgsm.beep.muted";
|
|
|
|
// ── DOM refs ────────────────────────────────────────────────────────
|
|
const els = {
|
|
streamDot: document.getElementById("stream-dot"),
|
|
streamStatus: document.getElementById("stream-status"),
|
|
trustedCount: document.getElementById("trusted-count"),
|
|
lastAlertTs: document.getElementById("last-alert-ts"),
|
|
notifDot: document.getElementById("notif-dot"),
|
|
notifStatus: document.getElementById("notif-status"),
|
|
alertsTbody: document.getElementById("alerts-tbody"),
|
|
alertsCount: document.getElementById("alerts-count"),
|
|
trustedTbody: document.getElementById("trusted-tbody"),
|
|
btnRefresh: document.getElementById("btn-refresh-alerts"),
|
|
btnAdd: document.getElementById("btn-add-trusted"),
|
|
btnTest: document.getElementById("btn-test-alert"),
|
|
btnNotif: document.getElementById("btn-request-notif"),
|
|
btnMute: document.getElementById("btn-mute-beep"),
|
|
muteLabel: document.getElementById("mute-label"),
|
|
modal: document.getElementById("modal-add"),
|
|
modalForm: document.getElementById("form-add-trusted"),
|
|
fieldImsi: document.getElementById("field-imsi"),
|
|
fieldLabel: document.getElementById("field-label"),
|
|
btnCancelAdd: document.getElementById("btn-cancel-add"),
|
|
toast: document.getElementById("toast"),
|
|
};
|
|
|
|
// ── state ───────────────────────────────────────────────────────────
|
|
let alertCount = 0;
|
|
let beepMuted = (localStorage.getItem(BEEP_MUTE_KEY) === "1");
|
|
let _audioCtx = null;
|
|
let _streamErrorTimer = null;
|
|
|
|
// ── utils ───────────────────────────────────────────────────────────
|
|
function fmtTime(epochSec) {
|
|
if (!epochSec) return "—";
|
|
const d = new Date(epochSec * 1000);
|
|
const hh = String(d.getHours()).padStart(2, "0");
|
|
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
const ss = String(d.getSeconds()).padStart(2, "0");
|
|
return `${hh}:${mm}:${ss}`;
|
|
}
|
|
|
|
function fmtDateTime(epochSec) {
|
|
if (!epochSec) return "—";
|
|
const d = new Date(epochSec * 1000);
|
|
return d.toLocaleString("sv-SE"); // ISO-ish, locale-stable
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s == null ? "" : s)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function shortId(id) {
|
|
const s = String(id || "");
|
|
return s.length > 8 ? s.slice(0, 8) : s;
|
|
}
|
|
|
|
function scoreClass(score) {
|
|
const n = Number(score) || 0;
|
|
if (n >= 70) return "high";
|
|
if (n >= 40) return "med";
|
|
return "low";
|
|
}
|
|
|
|
function toast(msg, kind) {
|
|
els.toast.textContent = msg;
|
|
els.toast.className = "toast show " + (kind || "");
|
|
clearTimeout(toast._t);
|
|
toast._t = setTimeout(() => {
|
|
els.toast.classList.remove("show");
|
|
}, 3000);
|
|
}
|
|
|
|
async function apiFetch(path, opts) {
|
|
const res = await fetch(API + path, opts || {});
|
|
let body = null;
|
|
try { body = await res.json(); } catch (_) { /* ignore */ }
|
|
if (!res.ok) {
|
|
const msg = (body && body.detail) || `HTTP ${res.status}`;
|
|
const err = new Error(msg);
|
|
err.status = res.status;
|
|
throw err;
|
|
}
|
|
return body;
|
|
}
|
|
|
|
// ── notifications ───────────────────────────────────────────────────
|
|
function refreshNotifStatus() {
|
|
if (!("Notification" in window)) {
|
|
els.notifStatus.textContent = "unsupported";
|
|
els.notifDot.className = "dot dot-down";
|
|
els.btnNotif.disabled = true;
|
|
return;
|
|
}
|
|
const p = Notification.permission;
|
|
els.notifStatus.textContent = p;
|
|
els.notifDot.className =
|
|
(p === "granted") ? "dot dot-live" :
|
|
(p === "denied") ? "dot dot-down" :
|
|
"dot dot-warn";
|
|
if (p === "granted") {
|
|
els.btnNotif.textContent = "Desktop notifications enabled";
|
|
els.btnNotif.disabled = true;
|
|
}
|
|
}
|
|
|
|
async function ensureNotificationPermission() {
|
|
// Must be called from a user gesture handler — modern browsers reject
|
|
// permission requests originating from page-load JS.
|
|
if (!("Notification" in window)) return false;
|
|
if (Notification.permission === "granted") return true;
|
|
if (Notification.permission === "denied") return false;
|
|
try {
|
|
const p = await Notification.requestPermission();
|
|
refreshNotifStatus();
|
|
return p === "granted";
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function showDesktopAlert(alert) {
|
|
if (!("Notification" in window)) return;
|
|
if (Notification.permission !== "granted") return;
|
|
const title = `SENTINELLE-GSM — score ${alert.score}`;
|
|
let body = `${alert.reason}\ncell ${alert.cell_id} arfcn ${alert.arfcn}`;
|
|
if (alert.trusted_label) body += `\ntargets: ${alert.trusted_label}`;
|
|
try {
|
|
const n = new Notification(title, {
|
|
body: body,
|
|
tag: `sgsm-${alert.id}`,
|
|
// icon path is best-effort; nginx may 404 it, that's fine.
|
|
icon: "/shared/secubox-mind.png",
|
|
});
|
|
n.onclick = () => { window.focus(); n.close(); };
|
|
} catch (e) {
|
|
// Some Linux desktops without a notification daemon throw; swallow.
|
|
console.warn("Notification failed:", e);
|
|
}
|
|
}
|
|
|
|
// ── beep (Web Audio synthesised, no asset) ──────────────────────────
|
|
function playBeep() {
|
|
if (beepMuted) return;
|
|
try {
|
|
if (!_audioCtx) {
|
|
const AC = window.AudioContext || window.webkitAudioContext;
|
|
if (!AC) return;
|
|
_audioCtx = new AC();
|
|
}
|
|
if (_audioCtx.state === "suspended") _audioCtx.resume();
|
|
const ctx = _audioCtx;
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
osc.type = "sine";
|
|
osc.frequency.setValueAtTime(880, ctx.currentTime);
|
|
// Short envelope: 0 → 0.18 → 0 over 200ms (avoids click)
|
|
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.18, ctx.currentTime + 0.02);
|
|
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.20);
|
|
osc.connect(gain).connect(ctx.destination);
|
|
osc.start();
|
|
osc.stop(ctx.currentTime + 0.22);
|
|
} catch (e) {
|
|
console.warn("Beep failed:", e);
|
|
}
|
|
}
|
|
|
|
function refreshMuteUi() {
|
|
els.btnMute.setAttribute("aria-pressed", beepMuted ? "true" : "false");
|
|
els.muteLabel.textContent = beepMuted ? "Beep: off" : "Beep: on";
|
|
}
|
|
|
|
function toggleMute() {
|
|
beepMuted = !beepMuted;
|
|
localStorage.setItem(BEEP_MUTE_KEY, beepMuted ? "1" : "0");
|
|
refreshMuteUi();
|
|
}
|
|
|
|
// ── alerts table ────────────────────────────────────────────────────
|
|
function clearAlertsPlaceholder() {
|
|
const empty = els.alertsTbody.querySelector("tr.empty");
|
|
if (empty) empty.remove();
|
|
}
|
|
|
|
function buildAlertRow(alert) {
|
|
const tr = document.createElement("tr");
|
|
tr.dataset.alertId = alert.id;
|
|
const targetCell = alert.trusted_label
|
|
? `<span class="target-pill">${escapeHtml(alert.trusted_label)}</span>`
|
|
: '<span style="color:var(--text-dim)">—</span>';
|
|
tr.innerHTML =
|
|
`<td class="col-time">${escapeHtml(fmtTime(alert.ts))}</td>` +
|
|
`<td class="col-cell">${escapeHtml(alert.cell_id)}</td>` +
|
|
`<td class="col-arfcn">${escapeHtml(alert.arfcn)}</td>` +
|
|
`<td><span class="score-chip ${scoreClass(alert.score)}">${escapeHtml(alert.score)}</span></td>` +
|
|
`<td>${escapeHtml(alert.reason)}</td>` +
|
|
`<td>${targetCell}</td>`;
|
|
return tr;
|
|
}
|
|
|
|
function prependToAlertList(alert) {
|
|
clearAlertsPlaceholder();
|
|
const row = buildAlertRow(alert);
|
|
els.alertsTbody.insertBefore(row, els.alertsTbody.firstChild);
|
|
while (els.alertsTbody.children.length > MAX_ROWS) {
|
|
els.alertsTbody.removeChild(els.alertsTbody.lastChild);
|
|
}
|
|
alertCount += 1;
|
|
els.alertsCount.textContent = String(alertCount);
|
|
els.lastAlertTs.textContent = fmtDateTime(alert.ts);
|
|
}
|
|
|
|
async function loadAlertHistory() {
|
|
try {
|
|
const data = await apiFetch("/alerts?limit=100");
|
|
const alerts = (data && data.alerts) || [];
|
|
els.alertsTbody.innerHTML = "";
|
|
if (alerts.length === 0) {
|
|
els.alertsTbody.innerHTML =
|
|
'<tr class="empty"><td colspan="6">no alerts in history — waiting for stream…</td></tr>';
|
|
alertCount = 0;
|
|
els.alertsCount.textContent = "0";
|
|
els.lastAlertTs.textContent = "—";
|
|
return;
|
|
}
|
|
// newest first
|
|
alerts.sort((a, b) => (b.ts || 0) - (a.ts || 0));
|
|
alerts.forEach((a) => els.alertsTbody.appendChild(buildAlertRow(a)));
|
|
alertCount = alerts.length;
|
|
els.alertsCount.textContent = String(alertCount);
|
|
els.lastAlertTs.textContent = fmtDateTime(alerts[0].ts);
|
|
} catch (e) {
|
|
toast("Failed to load alert history: " + e.message, "err");
|
|
}
|
|
}
|
|
|
|
// ── SSE stream ──────────────────────────────────────────────────────
|
|
function setStreamStatus(state, label) {
|
|
els.streamStatus.textContent = label;
|
|
els.streamDot.className = "dot " + state;
|
|
}
|
|
|
|
function startAlertStream() {
|
|
setStreamStatus("dot-warn", "connecting…");
|
|
const es = new EventSource(API + "/alerts/stream");
|
|
es.addEventListener("open", () => {
|
|
setStreamStatus("dot-live", "live");
|
|
});
|
|
es.addEventListener("alert", (e) => {
|
|
let alert;
|
|
try { alert = JSON.parse(e.data); }
|
|
catch (err) { console.warn("bad SSE payload:", err); return; }
|
|
prependToAlertList(alert);
|
|
showDesktopAlert(alert);
|
|
playBeep();
|
|
});
|
|
es.onerror = () => {
|
|
// EventSource auto-reconnects; just surface the state.
|
|
setStreamStatus("dot-warn", "reconnecting…");
|
|
clearTimeout(_streamErrorTimer);
|
|
_streamErrorTimer = setTimeout(() => {
|
|
// If the readyState is OPEN by then, restore "live".
|
|
if (es.readyState === EventSource.OPEN) {
|
|
setStreamStatus("dot-live", "live");
|
|
} else if (es.readyState === EventSource.CLOSED) {
|
|
setStreamStatus("dot-down", "closed");
|
|
}
|
|
}, 2500);
|
|
};
|
|
return es;
|
|
}
|
|
|
|
// ── trusted phones ──────────────────────────────────────────────────
|
|
function buildTrustedRow(phone) {
|
|
const tr = document.createElement("tr");
|
|
tr.dataset.phoneId = phone.id;
|
|
tr.innerHTML =
|
|
`<td class="col-id" title="${escapeHtml(phone.id)}">${escapeHtml(shortId(phone.id))}</td>` +
|
|
`<td>${escapeHtml(phone.label || "")}</td>` +
|
|
`<td class="mono">${escapeHtml(fmtDateTime(phone.added_at))}</td>` +
|
|
`<td class="actions-col">` +
|
|
`<button class="btn danger row-action" type="button" data-action="delete">Delete</button>` +
|
|
`</td>`;
|
|
return tr;
|
|
}
|
|
|
|
async function loadTrusted() {
|
|
try {
|
|
const data = await apiFetch("/trusted");
|
|
const phones = (data && data.phones) || [];
|
|
els.trustedTbody.innerHTML = "";
|
|
if (phones.length === 0) {
|
|
els.trustedTbody.innerHTML =
|
|
'<tr class="empty"><td colspan="4">no trusted phones — click "+ Add phone" to register one</td></tr>';
|
|
} else {
|
|
phones.sort((a, b) => (b.added_at || 0) - (a.added_at || 0));
|
|
phones.forEach((p) => els.trustedTbody.appendChild(buildTrustedRow(p)));
|
|
}
|
|
els.trustedCount.textContent = String(phones.length);
|
|
} catch (e) {
|
|
toast("Failed to load trusted phones: " + e.message, "err");
|
|
els.trustedCount.textContent = "?";
|
|
}
|
|
}
|
|
|
|
async function addTrusted(imsi, label) {
|
|
return apiFetch("/trusted", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ imsi: imsi, label: label }),
|
|
});
|
|
}
|
|
|
|
async function deleteTrusted(id) {
|
|
return apiFetch("/trusted/" + encodeURIComponent(id), { method: "DELETE" });
|
|
}
|
|
|
|
async function fireTestAlert() {
|
|
return apiFetch("/alerts/test", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({}),
|
|
});
|
|
}
|
|
|
|
// ── modal ───────────────────────────────────────────────────────────
|
|
function openModal() {
|
|
els.modal.classList.add("show");
|
|
els.modal.setAttribute("aria-hidden", "false");
|
|
setTimeout(() => els.fieldImsi.focus(), 50);
|
|
}
|
|
function closeModal() {
|
|
els.modal.classList.remove("show");
|
|
els.modal.setAttribute("aria-hidden", "true");
|
|
els.modalForm.reset();
|
|
}
|
|
|
|
// ── event wiring ────────────────────────────────────────────────────
|
|
function wireEvents() {
|
|
els.btnRefresh.addEventListener("click", loadAlertHistory);
|
|
|
|
els.btnAdd.addEventListener("click", openModal);
|
|
els.btnCancelAdd.addEventListener("click", closeModal);
|
|
els.modal.addEventListener("click", (e) => {
|
|
if (e.target === els.modal) closeModal();
|
|
});
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape" && els.modal.classList.contains("show")) closeModal();
|
|
});
|
|
|
|
els.modalForm.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const imsi = els.fieldImsi.value.trim();
|
|
const label = els.fieldLabel.value.trim();
|
|
if (!imsi || !label) return;
|
|
try {
|
|
await addTrusted(imsi, label);
|
|
toast("Trusted phone added", "ok");
|
|
closeModal();
|
|
await loadTrusted();
|
|
} catch (err) {
|
|
toast("Add failed: " + err.message, "err");
|
|
}
|
|
});
|
|
|
|
els.trustedTbody.addEventListener("click", async (e) => {
|
|
const btn = e.target.closest('button[data-action="delete"]');
|
|
if (!btn) return;
|
|
const tr = btn.closest("tr");
|
|
const id = tr && tr.dataset.phoneId;
|
|
if (!id) return;
|
|
if (!confirm("Delete trusted phone (id " + shortId(id) + ")?")) return;
|
|
try {
|
|
await deleteTrusted(id);
|
|
toast("Trusted phone removed", "ok");
|
|
await loadTrusted();
|
|
} catch (err) {
|
|
toast("Delete failed: " + err.message, "err");
|
|
}
|
|
});
|
|
|
|
els.btnTest.addEventListener("click", async () => {
|
|
// First click also doubles as the user gesture that unlocks AudioContext
|
|
// on browsers that require it.
|
|
if (_audioCtx && _audioCtx.state === "suspended") _audioCtx.resume();
|
|
try {
|
|
const r = await fireTestAlert();
|
|
toast("Test alert fired (id " + (r && r.id) + ")", "ok");
|
|
} catch (err) {
|
|
toast("Test failed: " + err.message, "err");
|
|
}
|
|
});
|
|
|
|
els.btnNotif.addEventListener("click", async () => {
|
|
const ok = await ensureNotificationPermission();
|
|
toast(ok ? "Notifications enabled" : "Notifications not granted",
|
|
ok ? "ok" : "warn");
|
|
});
|
|
|
|
els.btnMute.addEventListener("click", toggleMute);
|
|
}
|
|
|
|
// ── init ────────────────────────────────────────────────────────────
|
|
function init() {
|
|
refreshNotifStatus();
|
|
refreshMuteUi();
|
|
wireEvents();
|
|
loadAlertHistory();
|
|
loadTrusted();
|
|
startAlertStream();
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|