mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-13 20:57:44 +00:00
Closes #2177. ## What Node notifications can mail alerts computed outside CoreScope. With `userManagement.notifications.externalAlerts` configured, users can choose a new event, `node.external`, which is off by default and only offered when configured: ```json "notifications": { "enabled": true, "externalAlerts": { "url": "http://coverage-job/alerts.json", "maxAgeHours": 48, "label": "Coverage" } } ``` The feed: ```json { "generatedAt": "2026-10-11T01:35:12Z", "alerts": [ { "pubkey": "<64 hex>", "key": "SE", "text": "never heard to the south-east", "url": "https://example.org/coverage" } ] } ``` Mail lines: `<name>: Coverage: never heard to the south-east, <time>` when an alert appears, `<name>: Coverage resolved (SE), <time>` when it is gone from a fresh feed. ## How - `cmd/server/notify_external.go`: feed types, `parseExternalFeed` (RFC 3339 `generatedAt`, age check, per-alert validation), `fetchExternalFeed` (200 only, 1 MB cap, 10 s timeout). - `notify_eval.go`: `evalExternal`. Subjects are `<pubkey>/<key>`; a `<pubkey>/*` row is the per-node baseline, so the first fresh feed after watching stores the current alerts without a mail (like `foreign.new`), and later alerts mail at once. `External == nil` (not configured, nobody chose it, or stale) leaves every `node.external` state as it is. - `notify_service.go`: the feed is fetched only when configured and at least one user chose the event. Each new reason the feed is unusable is logged once, and so is the recovery. Mail text and link: the alert's `url`, otherwise the node page. - `notify_handlers.go`: `availableEvents` and the new `externalLabel` include the event only when configured; PUT answers 400 for `node.external` otherwise. - `internal/users/notify.go`: event constant, `OptionalNodeNotifyEvents` (canonical order: node, optional, admin), `RemoveWatch` also deletes the node's `<pubkey>/...` rows. - Account page label, OpenAPI descriptions, `config.example.json` comment, `docs/user-guide/accounts.md` section. Decisions on the three open questions in #2177: the generic name `node.external`; the resolved mail names the key instead of repeating the text, so there is **no `users.db` schema change** (a bump would also make older binaries refuse the file); one feed. ## Invariants - Read/write separation (#1283): the server only reads the feed over HTTP. State rows go to `users.db` through `internal/users`, like the other events. - A dead, garbled or old feed never mails "resolved": `TestEvaluateExternalStaleFeedChangesNothing` and `TestNotifierMailsExternalAlertsAndSkipsAStaleFeed` (HTTP 502 and a 72 h old feed in a row, then a fresh empty feed resolves). ## Performance The evaluator adds one pass over the stored states (already read every tick) plus, per user with the event on and per watched node, the node's current alerts. Bounded by `maxWatchesPerUser` (50) times the alerts per node. The feed is fetched once per tick (default 5 minutes) only when someone chose the event, capped at 1 MB. No packet-store lock is taken for it. Nothing changes for instances without `externalAlerts`. ## Tests - `cmd/server`: `go test` green locally (Windows; `TestSaveGeoFilterPreservesFileMode` skipped as it needs Unix mode bits). New: `notify_external_test.go` (feed parsing and limits, HTTP fetch 200/404/oversized, config resolution, evaluator baseline/new/resolved/returning/stale/not chosen/notifications off, notifier end to end with a fake feed, account routes). - `internal/users`: green, 2 new tests (optional event and canonical order, `RemoveWatch` deletes external subjects only for that node). - Frontend: `test-all.sh` green, 1 new test in `test-notifications-ui.js` (label from `externalLabel`, escaped). - `scripts/check-xss-sinks.sh --file public/notifications.js`: clean. ## Not done - No E2E test: the account page change is one label, covered by the unit test. - Not run against a live instance yet. The first feed in use is the coverage job of on8ar.eu (`alerts.json`, 29 alerts at the moment). Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
207 lines
10 KiB
JavaScript
207 lines
10 KiB
JavaScript
/* Node notifications client (docs/specs/2026-10-07-node-notifications-design.md).
|
|
* window.CSNotify: the "Notify me" toggle on the node side pane and full
|
|
* page, and the account page's Notifications section. Inert unless
|
|
* /api/config/client advertises userManagement.notifications (roles.js sets
|
|
* window.MC_USER_MGMT). One GET /api/account/notifications per user, cached;
|
|
* every change answers the full state, which replaces the cache. Every
|
|
* dynamic value in HTML goes through escapeHtml; messages use textContent. */
|
|
(function () {
|
|
'use strict';
|
|
var cache = { userId: null, promise: null };
|
|
|
|
function enabled() { return !!(window.MC_USER_MGMT && window.MC_USER_MGMT.notifications); }
|
|
function currentUser() { return window.CSAuth ? window.CSAuth.user() : null; }
|
|
|
|
// load resolves to the caller's state (the GET body) or null.
|
|
function load() {
|
|
var u = currentUser();
|
|
if (!enabled() || !u) return Promise.resolve(null);
|
|
if (!cache.promise || cache.userId !== u.id) {
|
|
cache.userId = u.id;
|
|
cache.promise = window.CSAuth.request('GET', '/api/account/notifications').then(function (r) {
|
|
if (!r.ok) { cache.promise = null; return null; }
|
|
return r.data;
|
|
}, function () { cache.promise = null; return null; });
|
|
}
|
|
return cache.promise;
|
|
}
|
|
|
|
// store makes data (the answer of a change) the cached state.
|
|
function store(data) {
|
|
var u = currentUser();
|
|
cache.userId = u ? u.id : null;
|
|
cache.promise = Promise.resolve(data);
|
|
return data;
|
|
}
|
|
|
|
function isWatched(data, pubkey) {
|
|
var pk = String(pubkey || '').toLowerCase();
|
|
return !!(data && data.watches && data.watches.some(function (w) { return w.pubkey === pk; }));
|
|
}
|
|
|
|
// toggleState: 'hidden' (no state), 'on', 'off' or 'full' (limit reached).
|
|
function toggleState(data, pubkey) {
|
|
if (!data) return 'hidden';
|
|
if (isWatched(data, pubkey)) return 'on';
|
|
return (data.watches || []).length >= Number(data.limits && data.limits.maxWatches) ? 'full' : 'off';
|
|
}
|
|
|
|
function toggleHtml(state, data) {
|
|
if (state === 'hidden') return '';
|
|
var on = state === 'on';
|
|
var title = on ? 'You get a mail when this node goes offline, comes back or reports a low battery. Click to stop.'
|
|
: 'Get a mail when this node goes offline, comes back or reports a low battery.';
|
|
var html = '<button type="button" class="btn-primary node-notify-btn" data-notify-toggle aria-pressed="' + (on ? 'true' : 'false') + '"' +
|
|
(state === 'full' ? ' disabled' : '') + ' title="' + escapeHtml(title) + '"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-envelope-simple"/></svg> ' +
|
|
escapeHtml(on ? 'Notifying' : 'Notify me') + '</button>';
|
|
if (state === 'full') {
|
|
html += ' <small class="node-notify-hint">' +
|
|
escapeHtml('You watch the maximum of ' + data.limits.maxWatches + ' nodes; remove one on your account page.') + '</small>';
|
|
}
|
|
return html + ' <small class="node-notify-hint" data-notify-msg role="status" aria-live="polite"></small>';
|
|
}
|
|
|
|
function say(slot, text) {
|
|
var m = slot.querySelector('[data-notify-msg]');
|
|
if (m) m.textContent = text;
|
|
}
|
|
|
|
function render(slot, pubkey, data) {
|
|
var state = toggleState(data, pubkey);
|
|
slot.innerHTML = toggleHtml(state, data);
|
|
var btn = slot.querySelector('[data-notify-toggle]');
|
|
if (!btn) return;
|
|
btn.addEventListener('click', function () {
|
|
var on = btn.getAttribute('aria-pressed') === 'true';
|
|
btn.disabled = true;
|
|
var path = '/api/account/notifications/watches/' + encodeURIComponent(String(pubkey).toLowerCase());
|
|
return window.CSAuth.request(on ? 'DELETE' : 'PUT', path).then(function (r) {
|
|
if (!r.ok) { btn.disabled = false; say(slot, window.CSAuth.errText(r)); return; }
|
|
render(slot, pubkey, store(r.data));
|
|
}, function () { btn.disabled = false; say(slot, 'Network error, try again.'); });
|
|
});
|
|
}
|
|
|
|
// mount fills slot with the toggle for pubkey; empty when the feature is
|
|
// off or nobody is logged in.
|
|
function mount(slot, pubkey) {
|
|
if (!slot) return Promise.resolve();
|
|
slot.innerHTML = '';
|
|
var ready = window.CSAuth && window.CSAuth.ready ? window.CSAuth.ready() : Promise.resolve();
|
|
return Promise.resolve(ready).then(function () {
|
|
if (!enabled() || !currentUser()) return;
|
|
return load().then(function (data) { render(slot, pubkey, data); });
|
|
});
|
|
}
|
|
|
|
var EVENT_LABELS = {
|
|
'node.offline': 'A watched node goes offline or comes back',
|
|
'node.battery': 'A watched node reports a low battery or recovers',
|
|
'foreign.new': 'A new foreign node appears (admin)',
|
|
'observer.offline': 'An observer goes offline or comes back (admin)'
|
|
};
|
|
|
|
function eventLabel(e, data) {
|
|
if (e === 'node.external') return 'A watched node gets a ' + (data.externalLabel || 'external') + ' alert or it clears';
|
|
return EVENT_LABELS[e] || e;
|
|
}
|
|
|
|
function eventsHtml(data) {
|
|
return (data.availableEvents || []).map(function (e) {
|
|
var id = 'notifyEv-' + e.replace(/\./g, '-');
|
|
return '<label class="account-check" for="' + escapeHtml(id) + '"><input type="checkbox" id="' + escapeHtml(id) +
|
|
'" data-notify-event="' + escapeHtml(e) + '"' + ((data.events || []).indexOf(e) !== -1 ? ' checked' : '') + '> ' +
|
|
escapeHtml(eventLabel(e, data)) + '</label>';
|
|
}).join('');
|
|
}
|
|
|
|
function watchesHtml(data) {
|
|
if (!data.watches.length) return '<p class="account-hint">You watch no nodes yet. Use Notify me on a node page, or Watch my nodes below.</p>';
|
|
return '<ul class="account-watches">' + data.watches.map(function (w) {
|
|
return '<li data-pubkey="' + escapeHtml(w.pubkey) + '"><span><a href="#/nodes/' + encodeURIComponent(w.pubkey) + '">' +
|
|
escapeHtml(w.name || w.pubkey.slice(0, 12)) + '</a>' + (w.known ? '' : ' <small>(no longer in the database)</small>') + '</span>' +
|
|
'<button type="button" class="account-btn account-btn-secondary" data-unwatch="' + escapeHtml(w.pubkey) + '">Remove</button></li>';
|
|
}).join('') + '</ul>';
|
|
}
|
|
|
|
function sectionHtml(data) {
|
|
var lim = data.limits || {};
|
|
return '<form id="notifyForm" class="account-form" novalidate>' +
|
|
'<label class="account-check" for="notifyEnabled"><input type="checkbox" id="notifyEnabled"' + (data.enabled ? ' checked' : '') + '> Send me notification mails</label>' +
|
|
'<fieldset class="account-fieldset"><legend>Events</legend>' + eventsHtml(data) + '</fieldset>' +
|
|
'<p class="account-hint">' + escapeHtml('At most ' + lim.perUserPerDay + ' mails per 24 hours; sent in the last 24 hours: ' + lim.mailsLast24h + '.') + '</p>' +
|
|
'<button type="submit" class="account-btn account-btn-primary">Save</button></form>' +
|
|
'<h4>' + escapeHtml('Watched nodes (' + data.watches.length + ' of ' + lim.maxWatches + ')') + '</h4>' +
|
|
'<div id="notifyWatches">' + watchesHtml(data) + '</div>' +
|
|
'<button type="button" class="account-btn account-btn-secondary" id="notifyMyNodes">Watch my nodes</button>';
|
|
}
|
|
|
|
function sayIn(msgId, text, ok) { window.CSAuth.say(msgId, text, ok); }
|
|
function failed(msgId) { return function () { sayIn(msgId, 'Network error, try again.', false); }; }
|
|
|
|
// draw renders the section from data and binds its controls. Controls are
|
|
// found by id: they are unique on the account page. busy guards against a
|
|
// second request while one is in flight.
|
|
function draw(el, msgId, data) {
|
|
var busy = false;
|
|
function guarded(fn) {
|
|
if (busy) return Promise.resolve();
|
|
busy = true;
|
|
return fn().then(function () { busy = false; }, function () { busy = false; });
|
|
}
|
|
el.innerHTML = sectionHtml(data);
|
|
document.getElementById('notifyForm').addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
var events = (data.availableEvents || []).filter(function (ev) {
|
|
var box = document.getElementById('notifyEv-' + ev.replace(/\./g, '-'));
|
|
return !!(box && box.checked);
|
|
});
|
|
var en = document.getElementById('notifyEnabled');
|
|
return guarded(function () {
|
|
return window.CSAuth.request('PUT', '/api/account/notifications', { enabled: !!(en && en.checked), events: events }).then(function (r) {
|
|
if (!r.ok) { sayIn(msgId, window.CSAuth.errText(r), false); return; }
|
|
draw(el, msgId, store(r.data));
|
|
sayIn(msgId, 'Saved.', true);
|
|
}, failed(msgId));
|
|
});
|
|
});
|
|
document.getElementById('notifyWatches').addEventListener('click', function (e) {
|
|
var pk = e.target && e.target.getAttribute && e.target.getAttribute('data-unwatch');
|
|
if (!pk) return;
|
|
return guarded(function () {
|
|
return window.CSAuth.request('DELETE', '/api/account/notifications/watches/' + encodeURIComponent(pk)).then(function (r) {
|
|
if (!r.ok) { sayIn(msgId, window.CSAuth.errText(r), false); return; }
|
|
draw(el, msgId, store(r.data));
|
|
sayIn(msgId, 'Removed.', true);
|
|
}, failed(msgId));
|
|
});
|
|
});
|
|
document.getElementById('notifyMyNodes').addEventListener('click', function () {
|
|
return guarded(function () {
|
|
return window.CSAuth.request('POST', '/api/account/notifications/watch-my-nodes').then(function (r) {
|
|
if (!r.ok) { sayIn(msgId, window.CSAuth.errText(r), false); return; }
|
|
var d = r.data;
|
|
draw(el, msgId, store(d.account));
|
|
sayIn(msgId, (d.added + d.already + d.skipped) === 0
|
|
? 'Your synced My nodes list is empty. Add nodes to My nodes on the Nodes page and turn on settings sync.'
|
|
: 'Added ' + d.added + ', already watched ' + d.already + ', skipped ' + d.skipped + '.', true);
|
|
}, failed(msgId));
|
|
});
|
|
});
|
|
}
|
|
|
|
// mountSection loads the caller's state (always fresh) into el.
|
|
function mountSection(el, msgId) {
|
|
if (!el || !enabled() || !currentUser()) return Promise.resolve();
|
|
return window.CSAuth.request('GET', '/api/account/notifications').then(function (r) {
|
|
if (!r.ok) { sayIn(msgId, window.CSAuth.errText(r), false); return; }
|
|
draw(el, msgId, store(r.data));
|
|
}, failed(msgId));
|
|
}
|
|
|
|
window.addEventListener('cs-auth-changed', function () { cache.userId = null; cache.promise = null; });
|
|
|
|
window.CSNotify = { enabled: enabled, load: load, store: store, toggleState: toggleState, toggleHtml: toggleHtml, mount: mount,
|
|
sectionHtml: sectionHtml, mountSection: mountSection };
|
|
})();
|