Files
Waters ChurchandClaude Fable 5.1 5ca0a672fa Shell 1.11.0: alerts — a module flashes every dashboard listening on one of six channels
A module that sees something a tech must act on now (a timer overrun, a meter
past its limit, timecode gone) raises an alert on a channel, 1–6, and every
dashboard listening to that channel flashes: the menu bar — brought out for
the duration if it was hidden — and a frame round the screen alternate between
the level's two colours, with the alert's text bold across the bar. Low fades
slowly, medium faster, critical snaps with no fade and floods the board behind
the tiles too, in a muted cut that shows faintly through the tile panels
without costing their content any contrast.

Server: the hub. One alert per channel (a new one replaces the last), timed
out server-side (1–600 s, 15 by default, 0 = held until cleared), broadcast
over the shell stream as `alert` / `alert-clear`; GET /api/alerts for a
dashboard that opens or reconnects mid-alert; POST /api/alerts and
DELETE /api/alerts/<n>, open like a layout save. Modules raise from their
server entry with shell.alert() / shell.clearAlert() or from a tile with
moduleApi.alert() / moduleApi.clearAlert(); a module's alerts end when it
stops. Colours live as alerts.colors in the data directory's proddash.json.

Dashboard: the Layouts menu gains a folded Alerts row with six numbered
toggles; the channel list saves with the layout, local and named (the server
keeps it in a saved layout). Admin: a folded Alerts section with two colour
pickers per level, a swatch running each level's cadence, and a Test row.

Docs: docs/ALERTS.md (the feature), a "Raising alerts" section in the module
guide, README. Tools: tools/alert.js raises/clears/lists from the command
line; tools/alerts.test.js drives it all end to end.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 16:05:12 -04:00

278 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* Drives the shell's alerts through its HTTP API and shell stream: the
* defaults, every refusal, raise → `alert` frame → timeout → `alert-clear`
* frame, replacement on a channel, an early clear, text tidying, the
* channel list riding along in a saved layout, the admin colour save (and
* its `alerts-config` frame, and what lands in proddash.json), and the admin
* test flash.
*
* node tools/alerts.test.js
*
* Plain Node, no dependencies. Starts server.js on a spare port with a
* temporary data directory, talks to it over HTTP, exits non-zero on the
* first failure and always stops the server it started.
*/
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const fs = require('node:fs');
const http = require('node:http');
const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
function freePort() {
return new Promise((resolve, reject) => {
const probe = net.createServer();
probe.listen(0, '127.0.0.1', () => {
const { port } = probe.address();
probe.close(() => resolve(port));
});
probe.on('error', reject);
});
}
async function waitFor(url, ms = 15000) {
const deadline = Date.now() + ms;
let last = '';
while (Date.now() < deadline) {
try {
const r = await fetch(url);
if (r.ok) return;
last = 'HTTP ' + r.status;
} catch (err) {
last = err.message;
}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error('server did not come up: ' + last);
}
/** A bare SSE reader for /api/events: collects { event, data } frames and waits for one. */
function openEvents(url) {
const frames = [];
const waiters = [];
const req = http.get(url, { headers: { accept: 'text/event-stream' } }, (res) => {
let buf = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
buf += chunk;
let i;
while ((i = buf.indexOf('\n\n')) >= 0) {
const block = buf.slice(0, i);
buf = buf.slice(i + 2);
let event = 'message';
const data = [];
for (const line of block.split('\n')) {
if (line.startsWith('event:')) event = line.slice(6).trim();
else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''));
}
if (!data.length) continue;
let parsed = null;
try { parsed = JSON.parse(data.join('\n')); } catch { parsed = data.join('\n'); }
const frame = { event, data: parsed };
frames.push(frame);
for (const w of [...waiters]) {
if (w.match(frame)) {
waiters.splice(waiters.indexOf(w), 1);
w.resolve(frame);
}
}
}
});
});
return {
frames,
/** The next frame (already seen or still to come) that `match` accepts. */
next(match, ms = 5000, label = 'frame') {
const seen = frames.find(match);
if (seen) {
frames.splice(frames.indexOf(seen), 1);
return Promise.resolve(seen);
}
return new Promise((resolve, reject) => {
const w = { match, resolve: (f) => { clearTimeout(timer); frames.splice(frames.indexOf(f), 1); resolve(f); } };
const timer = setTimeout(() => {
waiters.splice(waiters.indexOf(w), 1);
reject(new Error(`no ${label} within ${ms} ms`));
}, ms);
waiters.push(w);
});
},
close() { req.destroy(); },
};
}
(async () => {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-alerts-test-'));
const port = await freePort();
const child = spawn(process.execPath, ['server.js'], {
cwd: ROOT,
env: { ...process.env, PRODDASH_DATA_DIR: dataDir, PORT: String(port), PRODDASH_NO_REMOTE_CHECK: '1', PRODDASH_LAUNCHER: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let log = '';
child.stdout.on('data', (d) => { log += d; });
child.stderr.on('data', (d) => { log += d; });
const base = `http://127.0.0.1:${port}`;
const api = async (p, init = {}) => {
const r = await fetch(base + p, { headers: { 'Content-Type': 'application/json' }, ...init });
return { status: r.status, body: await r.json().catch(() => ({})) };
};
const raise = (alert) => api('/api/alerts', { method: 'POST', body: JSON.stringify(alert) });
let events = null;
let failed = false;
try {
await waitFor(base + '/api/alerts');
events = openEvents(base + '/api/events');
// ── defaults ──
let r = await api('/api/alerts');
assert.equal(r.status, 200);
assert.equal(r.body.channels, 6, 'six channels');
assert.deepEqual(r.body.levels, ['low', 'medium', 'critical']);
assert.deepEqual(Object.keys(r.body.colors), ['low', 'medium', 'critical'], 'a pair per level');
for (const pair of Object.values(r.body.colors)) {
assert.equal(pair.length, 2);
for (const c of pair) assert.match(c, /^#[0-9a-f]{6}$/, 'colours are #rrggbb');
}
assert.deepEqual(r.body.active, [], 'nothing active on a fresh server');
const defaults = r.body.colors;
// ── refusals say why ──
r = await raise({ channel: 7, level: 'low', text: 'x' });
assert.equal(r.status, 400); assert.match(r.body.error, /channel/);
r = await raise({ channel: 1, level: 'high', text: 'x' });
assert.equal(r.status, 400); assert.match(r.body.error, /level/);
r = await raise({ channel: 1, level: 'low', text: ' ' });
assert.equal(r.status, 400); assert.match(r.body.error, /text/);
r = await raise({ channel: 1, level: 'low', text: 'x', duration: -1 });
assert.equal(r.status, 400); assert.match(r.body.error, /duration/);
r = await raise({ channel: 1, level: 'low', text: 'x', duration: 'soon' });
assert.equal(r.status, 400); assert.match(r.body.error, /duration/);
{
const plain = await fetch(base + '/api/alerts', { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: '{}' });
assert.equal(plain.status, 415, 'a POST that is not application/json is refused');
}
r = await fetch(base + '/api/alerts/9', { method: 'DELETE' }).then(async (x) => ({ status: x.status, body: await x.json() }));
assert.equal(r.status, 400, 'clearing a channel that does not exist is refused');
r = await api('/api/alerts');
assert.deepEqual(r.body.active, [], 'a refused alert is not half-raised');
// ── raise → frame → timeout → clear frame ──
r = await raise({ channel: 2, level: 'MEDIUM', text: 'Service over by 5:00', duration: 1, source: 'pco-plan' });
assert.equal(r.status, 200, 'raise: ' + JSON.stringify(r.body));
const a = r.body.alert;
assert.equal(a.channel, 2);
assert.equal(a.level, 'medium', 'level is case-insensitive');
assert.equal(a.text, 'Service over by 5:00');
assert.equal(a.source, 'pco-plan', 'the source is kept');
assert.equal(a.duration, 1);
assert.ok(a.remainingMs > 0 && a.remainingMs <= 1000, 'remainingMs counts down from the duration');
let frame = await events.next((f) => f.event === 'alert' && f.data.id === a.id, 5000, '`alert` frame');
assert.equal(frame.data.text, a.text, 'the frame carries the alert');
r = await api('/api/alerts');
assert.equal(r.body.active.length, 1, 'it is active');
assert.equal(r.body.active[0].id, a.id);
frame = await events.next((f) => f.event === 'alert-clear' && f.data.channel === 2, 5000, '`alert-clear` frame at timeout');
assert.equal(frame.data.id, a.id, 'the clear names the alert that ended');
r = await api('/api/alerts');
assert.deepEqual(r.body.active, [], 'gone once its time is up');
// ── defaults for duration; a held alert; replacement; early clear ──
r = await raise({ channel: 3, level: 'low', text: 'first' });
assert.equal(r.body.alert.duration, 15, '15 s when omitted');
r = await raise({ channel: 3, level: 'low', text: 'LTC lost', duration: 0 });
assert.equal(r.status, 200);
assert.equal(r.body.alert.endsAt, null, 'duration 0 holds the alert');
assert.equal(r.body.alert.remainingMs, null);
const held = r.body.alert;
r = await api('/api/alerts');
assert.equal(r.body.active.length, 1, 'one alert per channel — the second replaced the first');
assert.equal(r.body.active[0].id, held.id);
r = await raise({ channel: 3, level: 'critical', text: 'replaced', duration: 600 });
const replaced = r.body.alert;
assert.equal(replaced.duration, 600, 'the cap is 600 s');
r = await raise({ channel: 3, level: 'critical', text: 'capped', duration: 9999 });
assert.equal(r.body.alert.duration, 600, 'longer is clamped to the cap');
r = await api('/api/alerts');
assert.equal(r.body.active.length, 1);
assert.equal(r.body.active[0].text, 'capped');
r = await fetch(base + '/api/alerts/3', { method: 'DELETE' }).then(async (x) => ({ status: x.status, body: await x.json() }));
assert.equal(r.status, 200);
assert.equal(r.body.cleared, true, 'an early clear says it cleared something');
frame = await events.next((f) => f.event === 'alert-clear' && f.data.channel === 3, 5000, '`alert-clear` frame after DELETE');
r = await fetch(base + '/api/alerts/3', { method: 'DELETE' }).then(async (x) => ({ status: x.status, body: await x.json() }));
assert.equal(r.body.cleared, false, 'clearing an empty channel is honest about it');
r = await api('/api/alerts');
assert.deepEqual(r.body.active, []);
// ── text is tidied: control characters out, whitespace collapsed, 80 chars at most ──
r = await raise({ channel: 4, level: 'low', text: ' SPL\n\t 102 dB\u0007 — over ' + 'x'.repeat(100), duration: 60 });
assert.equal(r.status, 200);
assert.equal(r.body.alert.text.length, 80, 'cut at 80');
assert.ok(r.body.alert.text.startsWith('SPL 102 dB — over x'), 'whitespace collapsed, control characters gone');
assert.equal(r.body.alert.source, 'api', 'no source given → api');
r = await raise({ channel: 4, level: 'low', text: 'bad source', duration: 60, source: 'not a module id!' });
assert.equal(r.body.alert.source, 'api', 'a malformed source → api');
await fetch(base + '/api/alerts/4', { method: 'DELETE' });
// ── the channel list rides along in a saved layout ──
const tile = { id: 't1', module: 'clock', x: 0, y: 0, w: 2, h: 1, settings: {} };
r = await api('/api/layouts/Stage', { method: 'PUT', body: JSON.stringify({ layout: { tiles: [tile], alertChannels: [3, 1, '2', 1, 9, 'x'] } }) });
assert.equal(r.status, 200, 'save: ' + JSON.stringify(r.body));
r = await api('/api/layouts/Stage');
assert.deepEqual(r.body.layout.alertChannels, [1, 2, 3], 'channels come back whole, deduplicated, sorted, 1–6 only');
r = await api('/api/layouts/Plain', { method: 'PUT', body: JSON.stringify({ layout: { tiles: [tile] } }) });
r = await api('/api/layouts/Plain');
assert.equal('alertChannels' in r.body.layout, false, 'a layout saved without the list does not grow one');
// ── admin: colours (admin is open here — a fresh data directory has no PIN) ──
r = await api('/api/admin/alerts', {
method: 'POST',
body: JSON.stringify({ colors: { low: ['#112233', 'nonsense'], critical: ['#ABCDEF', '#000000'], bogus: ['#111111', '#222222'] } }),
});
assert.equal(r.status, 200, 'colour save: ' + JSON.stringify(r.body));
assert.deepEqual(r.body.colors.low, ['#112233', defaults.low[1]], 'a malformed colour takes the default');
assert.deepEqual(r.body.colors.medium, defaults.medium, 'a level left out keeps the default');
assert.deepEqual(r.body.colors.critical, ['#abcdef', '#000000'], 'colours are lower-cased');
assert.equal('bogus' in r.body.colors, false, 'an unknown level is dropped');
frame = await events.next((f) => f.event === 'alerts-config', 5000, '`alerts-config` frame');
assert.deepEqual(frame.data.colors, r.body.colors, 'open dashboards hear the new colours');
r = await api('/api/alerts');
assert.deepEqual(r.body.colors.low, ['#112233', defaults.low[1]], 'GET reflects the save');
const settings = JSON.parse(fs.readFileSync(path.join(dataDir, 'proddash.json'), 'utf8'));
assert.deepEqual(settings.alerts.colors.critical, ['#abcdef', '#000000'], 'saved under alerts.colors in proddash.json');
// ── admin: the test flash ──
r = await api('/api/admin/alerts/test', { method: 'POST', body: JSON.stringify({ level: 'critical', channel: 5 }) });
assert.equal(r.status, 200, 'test: ' + JSON.stringify(r.body));
assert.equal(r.body.alert.channel, 5);
assert.equal(r.body.alert.level, 'critical');
assert.equal(r.body.alert.source, 'admin');
assert.match(r.body.alert.text, /^Test — critical alert$/);
assert.equal(r.body.alert.duration, 8, 'a test flash is short');
r = await api('/api/admin/alerts/test', { method: 'POST', body: JSON.stringify({ level: 'nope', channel: 5 }) });
assert.equal(r.status, 400, 'a bad test level is refused like any other');
await fetch(base + '/api/alerts/5', { method: 'DELETE' });
console.log('alerts: every check passed');
} catch (err) {
failed = true;
console.error('FAIL:', err.message);
console.error('--- last of the server log ---\n' + log.slice(-2000));
} finally {
try { events?.close(); } catch { /* already closed */ }
child.kill('SIGTERM');
await new Promise((r) => setTimeout(r, 400));
fs.rmSync(dataDir, { recursive: true, force: true });
}
process.exit(failed ? 1 : 0);
})();