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:
Waters Church
2026-10-05 16:05:12 -04:00
co-authored by Claude Fable 5.1
parent a3500f98b1
commit 5ca0a672fa
13 changed files with 1572 additions and 12 deletions
+289 -2
View File
@@ -434,8 +434,14 @@ function mountModule(id) {
const mod = require(serverPath);
const config = effectiveConfig(id);
// What a module needs to reach the rest of ProdDash — another module's
// routes over loopback, chiefly — without guessing.
const shell = { port: PORT };
// routes over loopback, chiefly — without guessing. alert() and
// clearAlert() flash the dashboards listening to a channel (see the
// alerts section); an alert this module raises ends when it stops.
const shell = {
port: PORT,
alert: (opts) => raiseAlert(opts, id),
clearAlert: (channel) => clearAlert(channel),
};
if (typeof mod.init === 'function') {
entry.handle = mod.init({ config, log, shell }) || null;
}
@@ -460,6 +466,9 @@ function unmountModule(id) {
} catch (err) {
console.error(`[proddash] module "${id}" failed to stop cleanly:`, err);
}
// Its alerts go with it: a "held until cleared" alert from a module that
// is no longer running would otherwise flash for ever.
clearAlertsFrom(id);
mounted.delete(id);
}
@@ -816,6 +825,189 @@ function setTheme(id) {
return themeView();
}
/* ── alerts ─────────────────────────────────────────────────────────── */
/* A module that notices something a tech must see now — a timer overrun, a
meter past its limit, a feed gone quiet — raises an alert, and every
dashboard subscribed to its channel flashes: the menu bar and a frame
round the screen alternate between two colours, with the alert's text
bold in the middle of the bar. Six channels for now, so a booth can send
the stage's alerts and the video world's to different screens. The
server is the hub: it holds what is active (one alert per channel — a
new one replaces the last), times them out, tells open dashboards over
the shell stream (`alert`, `alert-clear`), and answers GET /api/alerts
for a dashboard that opens or reconnects mid-alert. The level sets the
cadence (shell.js / style.css) and the two colours, chosen in Admin →
Alerts and kept as `alerts.colors` in this machine's proddash.json. */
const ALERT_LEVELS = ['low', 'medium', 'critical'];
const ALERT_CHANNELS = 6;
const ALERT_TEXT_MAX = 80;
const ALERT_DURATION_DEFAULT_S = 15;
const ALERT_DURATION_MAX_S = 600;
const ALERT_SOURCE_RE = /^[A-Za-z0-9_-]{1,40}$/;
const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
const DEFAULT_ALERT_COLORS = {
low: ['#1f6feb', '#0b2a5c'],
medium: ['#f0b429', '#6b4a0a'],
critical: ['#f4433c', '#1a0505'],
};
/** A full level → [colourA, colourB] set; anything missing or malformed takes the default. */
function normalizeAlertColors(raw) {
const out = {};
for (const level of ALERT_LEVELS) {
const pair = Array.isArray(raw?.[level]) ? raw[level] : [];
out[level] = DEFAULT_ALERT_COLORS[level].map((fallback, i) => {
const v = String(pair[i] ?? '').trim();
return HEX_COLOR_RE.test(v) ? v.toLowerCase() : fallback;
});
}
return out;
}
/** The channel list a dashboard listens to: whole numbers 1–6, each once, in order. */
function sanitizeAlertChannels(list) {
const out = new Set();
for (const v of Array.isArray(list) ? list : []) {
const n = Number(v);
if (Number.isInteger(n) && n >= 1 && n <= ALERT_CHANNELS) out.add(n);
}
return [...out].sort((a, b) => a - b);
}
let alertColors = normalizeAlertColors(shellConfig.alerts?.colors);
const activeAlerts = new Map(); // channel -> { id, channel, level, text, source, startedAt, duration, endsAt, timer }
let alertSeq = 0;
/** One alert as pages see it. `remainingMs` is what a browser counts down
from — its clock and the server's need not agree. */
function alertView(a) {
return {
id: a.id,
channel: a.channel,
level: a.level,
text: a.text,
source: a.source,
startedAt: a.startedAt,
duration: a.duration,
endsAt: a.endsAt,
remainingMs: a.endsAt ? Math.max(0, a.endsAt - Date.now()) : null,
};
}
/** What GET /api/alerts returns — public, since dashboards need it before anyone logs in. */
function alertsView() {
return {
channels: ALERT_CHANNELS,
levels: ALERT_LEVELS.slice(),
colors: normalizeAlertColors(alertColors),
active: [...activeAlerts.values()].sort((a, b) => a.channel - b.channel).map(alertView),
};
}
/**
* Raise an alert: check it, replace whatever its channel was showing, time
* it out, and tell every open dashboard. `source` is where it came from —
* a module id (that module's alerts end when it stops), or 'admin' / 'api'.
* Throws an httpError for bad input; a route answers 400 with its message,
* a module gets it as a plain exception.
*/
function raiseAlert(input, source) {
const opts = input && typeof input === 'object' ? input : {};
const channel = Number(opts.channel);
if (!Number.isInteger(channel) || channel < 1 || channel > ALERT_CHANNELS) {
throw httpError(400, `channel must be a whole number from 1 to ${ALERT_CHANNELS}.`);
}
const level = String(opts.level ?? '').toLowerCase();
if (!ALERT_LEVELS.includes(level)) {
throw httpError(400, `level must be one of ${ALERT_LEVELS.join(', ')}.`);
}
const text = String(opts.text ?? '')
.replace(/[\u0000-\u001f\u007f]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, ALERT_TEXT_MAX);
if (!text) throw httpError(400, 'text is required.');
let duration = opts.duration === undefined || opts.duration === null
? ALERT_DURATION_DEFAULT_S
: Number(opts.duration);
if (!Number.isFinite(duration) || duration < 0) {
throw httpError(400, `duration is seconds, 1 to ${ALERT_DURATION_MAX_S}, or 0 to hold the alert until it is cleared.`);
}
if (duration > 0) duration = Math.min(ALERT_DURATION_MAX_S, Math.max(1, duration));
const src = ALERT_SOURCE_RE.test(String(source || '')) ? String(source) : 'api';
const previous = activeAlerts.get(channel);
if (previous) clearTimeout(previous.timer);
const now = Date.now();
const alert = {
id: 'a' + (++alertSeq),
channel,
level,
text,
source: src,
startedAt: now,
duration,
endsAt: duration > 0 ? now + Math.round(duration * 1000) : null,
timer: null,
};
if (alert.endsAt) {
alert.timer = setTimeout(() => {
if (activeAlerts.get(channel) === alert) clearAlert(channel);
}, alert.endsAt - now);
alert.timer.unref?.();
}
activeAlerts.set(channel, alert);
broadcastShellEvent('alert', alertView(alert));
console.log(`[proddash] alert: ${level} on channel ${channel} from ${src} — "${text}"`
+ (alert.endsAt ? ` (${duration} s)` : ' (until cleared)'));
return alertView(alert);
}
/** End the alert on a channel — early, or because its time is up. True when there was one. */
function clearAlert(channel) {
const alert = activeAlerts.get(Number(channel));
if (!alert) return false;
clearTimeout(alert.timer);
activeAlerts.delete(alert.channel);
broadcastShellEvent('alert-clear', { channel: alert.channel, id: alert.id });
return true;
}
/** A module that stops takes its alerts with it — a held alert must never outlive what raised it. */
function clearAlertsFrom(source) {
for (const alert of [...activeAlerts.values()]) {
if (alert.source === source) clearAlert(alert.channel);
}
}
/**
* Persist the alert colours into this machine's proddash.json (every other
* key there is kept), remember them, and tell every open dashboard and
* admin page. Throws an httpError for an unreadable settings file.
*/
function setAlertColors(raw) {
const colors = normalizeAlertColors(raw);
const file = path.join(DATA_DIR, 'proddash.json');
let existing = {};
if (fs.existsSync(file)) {
existing = readJson(file, null);
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
throw httpError(500, `${file} is not a JSON object — fix it by hand before changing the alert colours.`);
}
}
const alerts = existing.alerts && typeof existing.alerts === 'object' && !Array.isArray(existing.alerts)
? existing.alerts
: {};
writeJson(file, { ...existing, alerts: { ...alerts, colors } });
alertColors = colors;
shellConfig.alerts = { ...(shellConfig.alerts || {}), colors };
broadcastShellEvent('alerts-config', { colors: normalizeAlertColors(colors) });
return normalizeAlertColors(colors);
}
/* ── the repo: module catalog and shell updates ──────────────────────── */
/* The admin page installs modules from, and updates the shell against, the
@@ -1734,9 +1926,91 @@ async function handleAdminApi(req, res, urlPath) {
}
}
/* — alert colours: { "colors": { "low": [a, b], "medium": [a, b], "critical": [a, b] } } — */
if (urlPath === '/api/admin/alerts' && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return;
let body = {};
try {
body = JSON.parse((await readBody(req)) || '{}');
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
try {
return sendJson(res, 200, { ok: true, colors: setAlertColors(body.colors) });
} catch (err) {
return sendJson(res, err.status || 500, { error: err.message || String(err) });
}
}
/* — a test flash, so the colours can be judged on a real dashboard:
{ "level": "critical", "channel": 1 } — */
if (urlPath === '/api/admin/alerts/test' && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return;
let body = {};
try {
body = JSON.parse((await readBody(req)) || '{}');
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
try {
const level = String(body.level ?? '');
const alert = raiseAlert({
channel: body.channel,
level,
text: `Test — ${level} alert`,
duration: 8,
}, 'admin');
return sendJson(res, 200, { ok: true, alert });
} catch (err) {
return sendJson(res, err.status || 500, { error: err.message || String(err) });
}
}
sendJson(res, 404, { error: 'Unknown admin endpoint.' });
}
/* ── alerts API ─────────────────────────────────────────────────────── */
/* Open to every dashboard and to module servers over loopback, like a
module's own routes: an alert is a flash on screens in this building and
nothing more, so it is gated no harder than saving a layout. Requiring
application/json on the POST forces the CORS preflight a page on another
origin cannot pass. */
async function handleAlertsApi(req, res, urlPath) {
if (urlPath === '/api/alerts' && req.method === 'GET') {
return sendJson(res, 200, alertsView());
}
if (urlPath === '/api/alerts' && req.method === 'POST') {
const contentType = String(req.headers['content-type'] || '').toLowerCase();
if (!contentType.startsWith('application/json')) {
return sendJson(res, 415, { error: 'Send application/json.' });
}
let body = {};
try {
body = JSON.parse((await readBody(req)) || '{}');
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
try {
return sendJson(res, 200, { ok: true, alert: raiseAlert(body, body.source) });
} catch (err) {
return sendJson(res, err.status || 500, { error: err.message || String(err) });
}
}
const m = urlPath.match(/^\/api\/alerts\/(\d{1,2})$/);
if (m && req.method === 'DELETE') {
const channel = Number(m[1]);
if (channel < 1 || channel > ALERT_CHANNELS) {
return sendJson(res, 400, { error: `channel must be a whole number from 1 to ${ALERT_CHANNELS}.` });
}
return sendJson(res, 200, { ok: true, cleared: clearAlert(channel) });
}
sendJson(res, 404, { error: 'Unknown alerts endpoint.' });
}
/* ── named layouts (JSON files under <data dir>/layouts/) ───────────── */
/** Names double as file names — keep them boring on purpose. */
@@ -1795,6 +2069,9 @@ function sanitizeLayout(raw) {
// The grid density the layout was arranged on, when it says and it is one
// the shell knows; a browser loading it cuts its board the same way.
...(GRID_STEPS.includes(Number(raw?.gridCols)) ? { gridCols: Number(raw.gridCols) } : {}),
// Which alert channels the dashboard listens to — which screen flashes
// for what is part of how a booth is arranged, so it rides along too.
...(Array.isArray(raw?.alertChannels) ? { alertChannels: sanitizeAlertChannels(raw.alertChannels) } : {}),
tiles: tiles.map((t) => {
const tile = {
id: String(t?.id || ''),
@@ -2093,6 +2370,16 @@ function handleRequest(req, res) {
return;
}
/* — alerts: what is flashing, raising one, ending one — */
if (urlPath === '/api/alerts' || urlPath.startsWith('/api/alerts/')) {
handleAlertsApi(req, res, urlPath).catch((err) => {
console.error('[proddash] alerts API error:', err);
if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' });
else try { res.end(); } catch { /* gone */ }
});
return;
}
/* — named layouts — */
if (urlPath === '/api/layouts' || urlPath.startsWith('/api/layouts/')) {
handleLayoutsApi(req, res, urlPath).catch((err) => {