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
+146
-1
@@ -27,6 +27,10 @@ const shellVersionEl = document.getElementById('shell-version');
|
||||
const themeSection = document.getElementById('theme-section');
|
||||
const themeSwatches = document.getElementById('theme-swatches');
|
||||
const themeStatus = document.getElementById('theme-status');
|
||||
const alertsSection = document.getElementById('alerts-section');
|
||||
const alertLevelsEl = document.getElementById('alert-levels');
|
||||
const alertsStatus = document.getElementById('alerts-status');
|
||||
const alertTestChannel = document.getElementById('alert-test-channel');
|
||||
|
||||
/** The shell version the page loaded with — the update flow watches it change. */
|
||||
let shellVersion = '';
|
||||
@@ -64,6 +68,7 @@ function showLogin() {
|
||||
layoutsSection.hidden = true;
|
||||
updateCard.hidden = true;
|
||||
themeSection.hidden = true;
|
||||
alertsSection.hidden = true;
|
||||
securitySection.hidden = true;
|
||||
passcodeInput.value = '';
|
||||
passcodeInput.focus();
|
||||
@@ -971,6 +976,7 @@ async function loadState() {
|
||||
}
|
||||
renderPin(state);
|
||||
loadTheme();
|
||||
loadAlerts();
|
||||
loadLayouts();
|
||||
loadCatalog(false);
|
||||
}
|
||||
@@ -1072,7 +1078,143 @@ async function chooseTheme(id) {
|
||||
}
|
||||
}
|
||||
|
||||
// Follow a theme change made elsewhere (another admin page) live.
|
||||
/* ── alerts ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/* The two colours each alert level flashes between are a server-wide
|
||||
choice, like the theme: GET /api/alerts lists them (public — dashboards
|
||||
need them too), POST /api/admin/alerts saves them, and the server's
|
||||
`alerts-config` shell event carries a change to every open dashboard and
|
||||
any other admin page. The cadence per level is the shell's (style.css);
|
||||
the swatch beside each row runs it, so a pair can be judged here before
|
||||
it is tested on a real dashboard with the buttons below. */
|
||||
|
||||
const ALERT_LEVELS = [
|
||||
{ id: 'low', name: 'Low', cadence: 'a slow fade between the two' },
|
||||
{ id: 'medium', name: 'Medium', cadence: 'a faster fade' },
|
||||
{ id: 'critical', name: 'Critical', cadence: 'no fade — it snaps between them' },
|
||||
];
|
||||
let alertColors = null; // level -> [a, b], as the server last said
|
||||
const alertInputs = new Map(); // level -> [inputA, inputB]
|
||||
|
||||
function alertsMessage(text, bad = false) {
|
||||
alertsStatus.textContent = text;
|
||||
alertsStatus.className = 'admin-status' + (bad ? ' error' : text ? ' ok' : '');
|
||||
}
|
||||
|
||||
function renderAlertLevels() {
|
||||
alertLevelsEl.innerHTML = '';
|
||||
alertInputs.clear();
|
||||
for (const level of ALERT_LEVELS) {
|
||||
const pair = alertColors?.[level.id] || ['#000000', '#000000'];
|
||||
const row = document.createElement('div');
|
||||
row.className = 'alert-level';
|
||||
const preview = document.createElement('span');
|
||||
preview.className = 'alert-preview ' + level.id;
|
||||
preview.setAttribute('aria-hidden', 'true');
|
||||
const name = document.createElement('div');
|
||||
name.className = 'alert-level-name';
|
||||
const strong = document.createElement('strong');
|
||||
strong.textContent = level.name;
|
||||
const cadence = document.createElement('span');
|
||||
cadence.textContent = level.cadence;
|
||||
name.append(strong, cadence);
|
||||
const inputs = pair.map((value, i) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'color';
|
||||
input.value = value;
|
||||
input.setAttribute('aria-label', `${level.name} colour ${i === 0 ? 'A' : 'B'}`);
|
||||
input.title = `${level.name} — colour ${i === 0 ? 'A' : 'B'}`;
|
||||
// The swatch follows the picker as it is dragged; the save waits for
|
||||
// the picker to close, so a drag is one write rather than a hundred.
|
||||
input.addEventListener('input', () => paintPreview());
|
||||
input.addEventListener('change', () => saveAlertColors());
|
||||
return input;
|
||||
});
|
||||
const paintPreview = () => {
|
||||
preview.style.setProperty('--alert-a', inputs[0].value);
|
||||
preview.style.setProperty('--alert-b', inputs[1].value);
|
||||
};
|
||||
paintPreview();
|
||||
alertInputs.set(level.id, inputs);
|
||||
row.append(preview, name, ...inputs);
|
||||
alertLevelsEl.appendChild(row);
|
||||
}
|
||||
if (!alertTestChannel.options.length) {
|
||||
for (let n = 1; n <= 6; n += 1) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(n);
|
||||
opt.textContent = String(n);
|
||||
alertTestChannel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What the pickers say right now, in the server's shape. */
|
||||
function readAlertColors() {
|
||||
const out = {};
|
||||
for (const [level, inputs] of alertInputs) out[level] = inputs.map((i) => i.value);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function loadAlerts() {
|
||||
let body;
|
||||
try {
|
||||
body = await api('/api/alerts');
|
||||
} catch {
|
||||
alertsSection.hidden = true; // a server without alerts — nothing to offer
|
||||
return;
|
||||
}
|
||||
alertColors = body.colors || {};
|
||||
renderAlertLevels();
|
||||
alertsSection.hidden = false;
|
||||
}
|
||||
|
||||
async function saveAlertColors() {
|
||||
const colors = readAlertColors();
|
||||
alertsMessage('');
|
||||
try {
|
||||
const body = await api('/api/admin/alerts', { method: 'POST', body: JSON.stringify({ colors }) });
|
||||
alertColors = body.colors || colors;
|
||||
alertsMessage('Saved — every open dashboard uses the new colours.');
|
||||
} catch (e) {
|
||||
alertsMessage(e.message, true);
|
||||
if (e.authRequired) showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
/** Another admin page changed the colours: follow, without yanking a picker out from under a hand. */
|
||||
function applyAlertColorsEvent(colors) {
|
||||
if (!colors || typeof colors !== 'object') return;
|
||||
alertColors = colors;
|
||||
for (const [level, inputs] of alertInputs) {
|
||||
const pair = Array.isArray(colors[level]) ? colors[level] : null;
|
||||
if (!pair) continue;
|
||||
inputs.forEach((input, i) => {
|
||||
if (document.activeElement !== input && /^#[0-9a-fA-F]{6}$/.test(String(pair[i] || ''))) input.value = pair[i];
|
||||
});
|
||||
inputs[0].dispatchEvent(new Event('input'));
|
||||
}
|
||||
}
|
||||
|
||||
for (const btn of document.querySelectorAll('.alert-test-btn')) {
|
||||
btn.addEventListener('click', async () => {
|
||||
const level = btn.dataset.level;
|
||||
const channel = Number(alertTestChannel.value) || 1;
|
||||
alertsMessage('');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api('/api/admin/alerts/test', { method: 'POST', body: JSON.stringify({ level, channel }) });
|
||||
alertsMessage(`Test sent — dashboards listening on channel ${channel} are flashing ${level} for 8 seconds.`);
|
||||
} catch (e) {
|
||||
alertsMessage(e.message, true);
|
||||
if (e.authRequired) showLogin();
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Follow a theme or alert-colour change made elsewhere (another admin page) live.
|
||||
let themeEvents = null;
|
||||
let themeEventsRetry = null;
|
||||
|
||||
@@ -1087,6 +1229,9 @@ function subscribeThemeEvents() {
|
||||
applyTheme(theme);
|
||||
renderThemes();
|
||||
});
|
||||
themeEvents.addEventListener('alerts-config', (e) => {
|
||||
try { applyAlertColorsEvent(JSON.parse(e.data).colors); } catch { /* a malformed frame changes nothing */ }
|
||||
});
|
||||
themeEvents.onerror = () => {
|
||||
if (themeEvents.readyState === EventSource.CLOSED) {
|
||||
clearTimeout(themeEventsRetry);
|
||||
|
||||
Reference in New Issue
Block a user