Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,19 @@ dashboard:

Access at `http://<pi-ip>:8080`. Bind to `127.0.0.1` to restrict to local access only.

### Browser notifications

**Settings → System → Browser notifications** enables LAN push alerts while any authenticated dashboard tab is open. Preferences are stored in the browser (`localStorage`), not on the device.

Supported alert types:

- **Node offline** — not heard for 2+ hours (matches the node-card online dot)
- **Node back online** — heard again after being offline
- **Low battery** — telemetry reports ≤20% (one alert per node per hour)
- **Storm guard** — reserved for a future release; toggle is present but inactive until storm-guard quarantine ships

Grant notification permission when prompted. Use **Suppress when this tab is focused** to avoid duplicate toasts while you are actively watching the dashboard.

---

## Device Identity
Expand Down
38 changes: 38 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,42 @@ <h3 class="auth-card__title">Display units</h3>
</fieldset>
<p class="auth-status" data-display-units-status aria-live="polite"></p>
</article>
<article class="auth-card push-notifications-card" id="push-notifications-prefs">
<h3 class="auth-card__title">Browser notifications</h3>
<p class="auth-card__hint">LAN push alerts while any dashboard tab is open. Preferences are saved in this browser only. Storm-guard alerts require the storm-guard feature (future release).</p>
<p class="auth-card__hint" data-push-permission-hint aria-live="polite"></p>
<label class="cfg-field cfg-field--toggle">
<input type="checkbox" data-push-enabled>
<span class="cfg-field__label">Enable mesh alerts</span>
</label>
<div class="auth-card__actions">
<button class="auth-button auth-button--primary" type="button" data-push-enable-btn>Enable notifications</button>
</div>
<fieldset class="cfg-fieldset">
<legend class="cfg-fieldset__legend">Alert types</legend>
<label class="cfg-field cfg-field--toggle">
<input type="checkbox" data-push-kind="node_offline" checked>
<span class="cfg-field__label">Node offline (2+ hours)</span>
</label>
<label class="cfg-field cfg-field--toggle">
<input type="checkbox" data-push-kind="node_online" checked>
<span class="cfg-field__label">Node back online</span>
</label>
<label class="cfg-field cfg-field--toggle">
<input type="checkbox" data-push-kind="battery_low" checked>
<span class="cfg-field__label">Low battery (&le;20%)</span>
</label>
<label class="cfg-field cfg-field--toggle">
<input type="checkbox" data-push-kind="storm_guard" checked disabled>
<span class="cfg-field__label">Storm guard (when available)</span>
</label>
</fieldset>
<label class="cfg-field cfg-field--toggle">
<input type="checkbox" data-push-suppress-focused checked>
<span class="cfg-field__label">Suppress when this tab is focused</span>
</label>
<p class="auth-status" data-push-notifications-status aria-live="polite"></p>
</article>
<header class="dangerous-panel__head dangerous-panel__head--secondary">
<h3 class="dangerous-panel__title">Service actions</h3>
<p class="dangerous-panel__subtitle">Each requires confirmation and is recorded in the audit log.</p>
Expand Down Expand Up @@ -753,6 +789,8 @@ <h3 class="dangerous-panel__title">Service actions</h3>
<script src="js/command_palette.js"></script>
<script src="js/keymap_overlay.js"></script>
<script src="js/sound_engine.js"></script>
<script src="js/push_notifications.js"></script>
<script src="js/settings/push_notifications_form.js"></script>
<script src="js/theme_controller.js"></script>
<script src="js/app.js"></script>
</body>
Expand Down
17 changes: 17 additions & 0 deletions frontend/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ document.addEventListener('DOMContentLoaded', async () => {
if (window.themeController) window.themeController.init();
_bootCommandPaletteAndKeymap(router);
_wireSoundEvents();
_wirePushNotifications();

new SignOutController('signout-btn').bind();

Expand Down Expand Up @@ -198,6 +199,10 @@ function _bootDangerousPanel(router) {
if (prefsRoot && window.MeshpointDisplayForm) {
new window.MeshpointDisplayForm(prefsRoot);
}
const pushRoot = document.getElementById('push-notifications-prefs');
if (pushRoot && window.PushNotificationsForm) {
new window.PushNotificationsForm(pushRoot);
}
const controller = new window.DangerousPanelController(root);
controller.bind();
let primed = false;
Expand Down Expand Up @@ -575,3 +580,15 @@ function _wireSoundEvents() {
ws.on('disconnected', () => window.soundEngine.play('disconnect'));
}
}

function _wirePushNotifications() {
if (!window.pushNotifications || !window.concentratorWS) return;
const pn = window.pushNotifications;
const ws = window.concentratorWS;
if (pn.isEnabled() && pn.permissionState() === 'granted') {
pn.registerServiceWorker();
}
if (typeof ws.on === 'function') {
ws.on('alert', (data) => pn.handleAlert(data));
}
}
150 changes: 150 additions & 0 deletions frontend/js/push_notifications.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* LAN browser push notifications for mesh alerts (PR 04).
*
* Preferences live in localStorage. Alerts arrive on the existing
* WebSocket as ``type: "alert"`` with ``event_type: "alert"`` in the
* payload. Works while any authenticated dashboard tab is open.
*/
class PushNotifications {
static STORAGE_KEY = 'meshpoint:push-notifications:v1';
static DEFAULT_PREFS = {
enabled: false,
node_offline: true,
node_online: true,
battery_low: true,
storm_guard: true,
suppress_when_focused: true,
};

constructor() {
this._prefs = this._readPrefs();
this._swReady = null;
}

getPrefs() {
return { ...this._prefs };
}

savePrefs(next) {
this._prefs = { ...PushNotifications.DEFAULT_PREFS, ...next };
try {
localStorage.setItem(
PushNotifications.STORAGE_KEY,
JSON.stringify(this._prefs),
);
} catch (_e) { /* ignore quota errors */ }
}

isEnabled() {
return !!this._prefs.enabled;
}

permissionState() {
if (!('Notification' in window)) return 'unsupported';
return Notification.permission;
}

async registerServiceWorker() {
if (!('serviceWorker' in navigator)) return null;
try {
const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/' });
this._swReady = navigator.serviceWorker.ready;
return reg;
} catch (err) {
console.warn('Service worker registration failed:', err);
return null;
}
}

async requestPermission() {
if (!('Notification' in window)) {
return 'unsupported';
}
if (Notification.permission === 'granted') {
await this.registerServiceWorker();
return 'granted';
}
if (Notification.permission === 'denied') {
return 'denied';
}
const result = await Notification.requestPermission();
if (result === 'granted') {
await this.registerServiceWorker();
}
return result;
}

async enable() {
const perm = await this.requestPermission();
if (perm !== 'granted') return perm;
this.savePrefs({ ...this._prefs, enabled: true });
return perm;
}

disable() {
this.savePrefs({ ...this._prefs, enabled: false });
}

handleAlert(data) {
if (!this.isEnabled() || !data) return;
const kind = data.alert_kind || '';
if (!this._isKindEnabled(kind)) return;
if (this._prefs.suppress_when_focused && document.visibilityState === 'visible') {
return;
}
const title = data.title || 'Meshpoint alert';
const body = data.body || '';
this._show(title, body, data);
}

_isKindEnabled(kind) {
const map = {
node_offline: this._prefs.node_offline,
node_online: this._prefs.node_online,
battery_low: this._prefs.battery_low,
storm_guard: this._prefs.storm_guard,
};
return map[kind] !== false;
}

async _show(title, body, payload) {
if (!('Notification' in window) || Notification.permission !== 'granted') {
return;
}
const tag = `meshpoint:${payload.alert_kind || 'alert'}:${payload.node_id || 'system'}`;
try {
if ('serviceWorker' in navigator) {
const reg = this._swReady
? await this._swReady
: await navigator.serviceWorker.ready;
const active = reg.active || navigator.serviceWorker.controller;
if (active) {
active.postMessage({
type: 'show-notification',
title,
body,
tag,
payload,
});
return;
}
}
new Notification(title, { body, tag, renotify: true });
} catch (err) {
console.warn('Notification failed:', err);
}
}

_readPrefs() {
try {
const raw = localStorage.getItem(PushNotifications.STORAGE_KEY);
if (!raw) return { ...PushNotifications.DEFAULT_PREFS };
return { ...PushNotifications.DEFAULT_PREFS, ...JSON.parse(raw) };
} catch (_e) {
return { ...PushNotifications.DEFAULT_PREFS };
}
}
}

window.PushNotifications = PushNotifications;
window.pushNotifications = new PushNotifications();
109 changes: 109 additions & 0 deletions frontend/js/settings/push_notifications_form.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* Settings → System: browser push notification preferences.
*/

class PushNotificationsForm {
constructor(rootEl) {
this.root = rootEl;
this._statusEl = rootEl.querySelector('[data-push-notifications-status]');
this._masterToggle = rootEl.querySelector('[data-push-enabled]');
this._kindInputs = Array.from(rootEl.querySelectorAll('[data-push-kind]'));
this._focusToggle = rootEl.querySelector('[data-push-suppress-focused]');
this._enableBtn = rootEl.querySelector('[data-push-enable-btn]');
this._bind();
this._syncFromStorage();
}

_bind() {
if (this._enableBtn) {
this._enableBtn.addEventListener('click', () => this._onEnableClick());
}
if (this._masterToggle) {
this._masterToggle.addEventListener('change', () => this._onMasterChange());
}
this._kindInputs.forEach((el) => {
el.addEventListener('change', () => this._saveKinds());
});
if (this._focusToggle) {
this._focusToggle.addEventListener('change', () => this._saveKinds());
}
}

_syncFromStorage() {
const prefs = window.pushNotifications.getPrefs();
if (this._masterToggle) {
this._masterToggle.checked = prefs.enabled;
}
this._kindInputs.forEach((el) => {
const key = el.dataset.pushKind;
el.checked = prefs[key] !== false;
el.disabled = !prefs.enabled;
});
if (this._focusToggle) {
this._focusToggle.checked = prefs.suppress_when_focused !== false;
this._focusToggle.disabled = !prefs.enabled;
}
this._updatePermissionHint();
}

async _onEnableClick() {
const perm = await window.pushNotifications.enable();
if (perm === 'granted') {
this._setStatus('success', 'Notifications enabled for this browser.');
} else if (perm === 'denied') {
this._setStatus('error', 'Permission denied. Allow notifications in browser settings.');
} else if (perm === 'unsupported') {
this._setStatus('error', 'This browser does not support notifications.');
} else {
this._setStatus('error', 'Permission not granted.');
}
this._syncFromStorage();
}

_onMasterChange() {
const enabled = !!(this._masterToggle && this._masterToggle.checked);
if (enabled) {
this._onEnableClick();
return;
}
window.pushNotifications.disable();
this._setStatus('success', 'Notifications disabled.');
this._syncFromStorage();
}

_saveKinds() {
const prefs = window.pushNotifications.getPrefs();
const next = { ...prefs };
this._kindInputs.forEach((el) => {
next[el.dataset.pushKind] = el.checked;
});
if (this._focusToggle) {
next.suppress_when_focused = this._focusToggle.checked;
}
window.pushNotifications.savePrefs(next);
this._setStatus('success', 'Alert preferences saved.');
}

_updatePermissionHint() {
const hint = this.root.querySelector('[data-push-permission-hint]');
if (!hint) return;
const perm = window.pushNotifications.permissionState();
if (perm === 'unsupported') {
hint.textContent = 'Notifications are not supported in this browser.';
} else if (perm === 'denied') {
hint.textContent = 'Blocked — reset site permissions to enable alerts.';
} else if (perm === 'granted') {
hint.textContent = 'Permission granted.';
} else {
hint.textContent = 'Click Enable to request browser permission.';
}
}

_setStatus(kind, message) {
if (!this._statusEl) return;
this._statusEl.dataset.kind = kind;
this._statusEl.textContent = message;
}
}

window.PushNotificationsForm = PushNotificationsForm;
Loading
Loading