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>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
a3500f98b1
commit
5ca0a672fa
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Raise, clear or list ProdDash alerts from the command line — for trying a
|
||||
* dashboard's channels and the admin colours, or from a script on the
|
||||
* machine running ProdDash (anything that can POST JSON can raise one).
|
||||
*
|
||||
* node tools/alert.js "Service over by 5:00" # low, channel 1, 15 s
|
||||
* node tools/alert.js -c 2 -l critical -d 0 "LTC lost" # held until cleared
|
||||
* node tools/alert.js -c 2 --clear
|
||||
* node tools/alert.js --list
|
||||
*
|
||||
* Options: -c/--channel 1–6 (default 1), -l/--level low|medium|critical
|
||||
* (default low), -d/--duration seconds, 0 to hold until cleared (default the
|
||||
* server's 15), --url http://host:port (default http://127.0.0.1:24500, or
|
||||
* $PRODDASH_URL), --clear, --list. Node 18+, no dependencies.
|
||||
*/
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const opts = { channel: 1, level: 'low', duration: undefined, url: process.env.PRODDASH_URL || 'http://127.0.0.1:24500', clear: false, list: false };
|
||||
const words = [];
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const a = args[i];
|
||||
const next = () => args[++i];
|
||||
if (a === '-c' || a === '--channel') opts.channel = Number(next());
|
||||
else if (a === '-l' || a === '--level') opts.level = String(next() || '');
|
||||
else if (a === '-d' || a === '--duration') opts.duration = Number(next());
|
||||
else if (a === '--url') opts.url = String(next() || opts.url);
|
||||
else if (a === '--clear') opts.clear = true;
|
||||
else if (a === '--list') opts.list = true;
|
||||
else if (a === '-h' || a === '--help') {
|
||||
console.log('usage: node tools/alert.js [-c 1-6] [-l low|medium|critical] [-d seconds] [--url http://host:port] "text"\n'
|
||||
+ ' node tools/alert.js -c <n> --clear\n'
|
||||
+ ' node tools/alert.js --list');
|
||||
process.exit(0);
|
||||
} else words.push(a);
|
||||
}
|
||||
const base = opts.url.replace(/\/+$/, '');
|
||||
|
||||
async function call(path, init) {
|
||||
const res = await fetch(base + path, init);
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (opts.list) {
|
||||
const body = await call('/api/alerts');
|
||||
const active = body.active || [];
|
||||
console.log(`${active.length} active alert${active.length === 1 ? '' : 's'}; colours:`,
|
||||
Object.entries(body.colors || {}).map(([k, v]) => `${k} ${v.join('→')}`).join(', '));
|
||||
for (const a of active) {
|
||||
console.log(` channel ${a.channel} ${a.level.padEnd(8)} "${a.text}" from ${a.source}`
|
||||
+ (a.remainingMs === null ? ' (until cleared)' : ` (${Math.ceil(a.remainingMs / 1000)} s left)`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (opts.clear) {
|
||||
const body = await call(`/api/alerts/${opts.channel}`, { method: 'DELETE' });
|
||||
console.log(body.cleared ? `cleared channel ${opts.channel}` : `nothing was on channel ${opts.channel}`);
|
||||
return;
|
||||
}
|
||||
const text = words.join(' ').trim();
|
||||
if (!text) {
|
||||
console.error('say what happened: node tools/alert.js -c 1 -l critical "LTC lost"');
|
||||
process.exit(2);
|
||||
}
|
||||
const payload = { channel: opts.channel, level: opts.level, text, source: 'cli' };
|
||||
if (opts.duration !== undefined) payload.duration = opts.duration;
|
||||
const body = await call('/api/alerts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const a = body.alert;
|
||||
console.log(`raised ${a.level} on channel ${a.channel}: "${a.text}"`
|
||||
+ (a.remainingMs === null ? ' (until cleared)' : ` (${a.duration} s)`));
|
||||
})().catch((err) => {
|
||||
console.error(err.message || String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
'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);
|
||||
})();
|
||||
Reference in New Issue
Block a user