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
@@ -482,6 +482,73 @@ details.admin-fold > summary.admin-fold-title::after {
|
||||
details.admin-fold[open] > summary.admin-fold-title::after { transform: rotate(90deg); }
|
||||
details.admin-fold > summary.admin-fold-title:hover { color: var(--accent); }
|
||||
|
||||
/* Alerts: a row per level — a swatch running that level's cadence between
|
||||
the two chosen colours (the keyframes are the shell's, in style.css), the
|
||||
level's name, and its two pickers — then the test row. */
|
||||
.alert-levels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.alert-level {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.alert-preview {
|
||||
flex: none;
|
||||
width: 56px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--alert-a, var(--border));
|
||||
animation: alert-flash var(--alert-period, 3s) var(--alert-ease, ease-in-out) infinite;
|
||||
}
|
||||
.alert-preview.low { --alert-period: 3.2s; --alert-ease: ease-in-out; }
|
||||
.alert-preview.medium { --alert-period: 1.1s; --alert-ease: ease-in-out; }
|
||||
.alert-preview.critical { --alert-period: 0.6s; --alert-ease: steps(1, end); }
|
||||
.alert-level-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
.alert-level-name strong { font-size: 13px; }
|
||||
.alert-level-name span { font-size: 12px; color: var(--muted); }
|
||||
.alert-level input[type="color"] {
|
||||
flex: none;
|
||||
width: 44px;
|
||||
height: 26px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.alert-level input[type="color"]:focus { outline: none; border-color: var(--accent); }
|
||||
.alert-test {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.alert-test-label { font-size: 13px; color: var(--muted); }
|
||||
.alert-test-select {
|
||||
height: 26px;
|
||||
padding: 0 6px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.alert-test-select:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
.theme-swatches {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
|
||||
|
||||
+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);
|
||||
|
||||
@@ -70,6 +70,27 @@
|
||||
<div id="layouts-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="alerts-section" hidden>
|
||||
<details class="admin-fold">
|
||||
<summary class="admin-h1 admin-fold-title">Alerts</summary>
|
||||
<p class="admin-copy">A module that sees something a tech must act on raises an alert on one of
|
||||
six channels, and every dashboard listening to that channel flashes — menu bar and screen
|
||||
edge — with the alert's text across the bar. Each level alternates between two colours;
|
||||
pick them here. A dashboard chooses its channels in its Layouts menu, under Alerts.</p>
|
||||
<div class="admin-card">
|
||||
<div class="alert-levels" id="alert-levels"></div>
|
||||
<div class="alert-test">
|
||||
<span class="alert-test-label">Test on channel</span>
|
||||
<select id="alert-test-channel" class="alert-test-select" aria-label="Channel to test on"></select>
|
||||
<button class="btn small alert-test-btn" data-level="low">Low</button>
|
||||
<button class="btn small alert-test-btn" data-level="medium">Medium</button>
|
||||
<button class="btn small alert-test-btn" data-level="critical">Critical</button>
|
||||
</div>
|
||||
<div class="admin-status" id="alerts-status"></div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="security-section" hidden>
|
||||
<h1 class="admin-h1">Admin PIN</h1>
|
||||
<p class="admin-copy" id="pin-state"></p>
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
<header id="topbar">
|
||||
<span id="brand">Prod<span class="accent">Dash</span></span>
|
||||
|
||||
<!-- An alert this dashboard listens to: its text, bold, centred in the bar (shell.js fills it). -->
|
||||
<div id="alert-banner" role="status" aria-live="assertive" hidden>
|
||||
<span class="alert-channel" aria-hidden="true"></span>
|
||||
<span class="alert-text"></span>
|
||||
<span class="alert-more"></span>
|
||||
</div>
|
||||
|
||||
<span class="spacer"></span>
|
||||
|
||||
<button class="tb-btn icon" id="autohide-btn" title="Auto-hide bars" aria-label="Auto-hide tile title bars" aria-pressed="false">
|
||||
|
||||
+301
-8
@@ -227,6 +227,16 @@ const tileEls = new Map(); // tile id -> { el, body, dot, titleEl, instance }
|
||||
away regardless; this is the windowed equivalent, asked for by hand. */
|
||||
let autoHideHeads = false;
|
||||
|
||||
/* Which of the six alert channels this dashboard listens to. Which screen
|
||||
flashes for what is part of how a booth is arranged — the stage screen
|
||||
takes the stage's alerts, the video world its own — so like auto-hide it
|
||||
saves with the layout, local and named. Every dashboard hears every alert
|
||||
over the shell stream; this decides which ones it shows (see the alerts
|
||||
section). */
|
||||
let alertChannels = [];
|
||||
/** True while an alert this dashboard listens to is on screen: the menu bar is held open for it. */
|
||||
let alertShowing = false;
|
||||
|
||||
/* Beside the layout itself: the copy it replaced (restored if the layout
|
||||
ever fails to parse) and, after loading a named layout, the arrangement
|
||||
that was on screen before (what Undo goes back to). */
|
||||
@@ -235,7 +245,7 @@ const LS_LAYOUT_PREVIOUS = LS_LAYOUT + '.previous';
|
||||
|
||||
function saveLayout() {
|
||||
try {
|
||||
const next = JSON.stringify({ v: 1, autoHideHeads, gridCols: COLS, tiles });
|
||||
const next = JSON.stringify({ v: 1, autoHideHeads, gridCols: COLS, alertChannels, tiles });
|
||||
const current = localStorage.getItem(LS_LAYOUT);
|
||||
if (current && current !== next) localStorage.setItem(LS_LAYOUT_BACKUP, current);
|
||||
localStorage.setItem(LS_LAYOUT, next);
|
||||
@@ -1388,6 +1398,34 @@ function buildModuleApi(tile, man, entry) {
|
||||
entry.dot.title = msg;
|
||||
},
|
||||
|
||||
/**
|
||||
* Flash every dashboard listening to an alert channel — this one too,
|
||||
* if it listens. { channel: 1–6, level: 'low' | 'medium' | 'critical',
|
||||
* text (80 chars at most), duration: seconds, 0 to hold it until
|
||||
* cleared, 15 when omitted }. Goes through the server, which is the
|
||||
* hub; resolves with the alert as raised, rejects with the reason it
|
||||
* was refused. A module with a server entry raises from there instead
|
||||
* (shell.alert) — see docs/MODULE-GUIDE.md, "Raising alerts".
|
||||
*/
|
||||
async alert(opts = {}) {
|
||||
const res = await fetch('/api/alerts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...opts, source: man.id }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body.error || `Alert refused (${res.status})`);
|
||||
return body.alert;
|
||||
},
|
||||
|
||||
/** End the alert on a channel early. One alert per channel, so this ends whatever is on it. */
|
||||
async clearAlert(channel) {
|
||||
const res = await fetch('/api/alerts/' + encodeURIComponent(String(Number(channel))), { method: 'DELETE' });
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body.error || `Clear refused (${res.status})`);
|
||||
return Boolean(body.cleared);
|
||||
},
|
||||
|
||||
/**
|
||||
* The tile's title bar. Controls added here live next to the gear/close
|
||||
* buttons and are removed automatically when the instance stops. The
|
||||
@@ -2142,7 +2180,10 @@ function renderLayout(layout) {
|
||||
// Density first: the tiles are clamped and placed against it.
|
||||
if (!Array.isArray(layout)) setGridCols(layout?.gridCols ?? GRID_DEFAULT, { save: false });
|
||||
tiles = list.map(sanitizeTile);
|
||||
if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
|
||||
if (!Array.isArray(layout)) {
|
||||
setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
|
||||
setAlertChannels(layout?.alertChannels, { save: false });
|
||||
}
|
||||
for (const tile of tiles) if (tileIsOffered(tile)) buildTile(tile);
|
||||
scheduleOfferedRecheck();
|
||||
refreshEmptyState();
|
||||
@@ -2207,9 +2248,11 @@ function renderAddMenu(menu) {
|
||||
wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||||
menu.innerHTML = '';
|
||||
|
||||
// Density first: the one thing in here that is not a layout but the board
|
||||
// the layouts are arranged on.
|
||||
// The board first — density, and which alert channels this screen
|
||||
// listens to: the two things in here that are not a layout but part of
|
||||
// how one is arranged.
|
||||
menu.appendChild(gridStepper());
|
||||
menu.appendChild(alertsFold());
|
||||
const stepperDivider = document.createElement('div');
|
||||
stepperDivider.className = 'menu-divider';
|
||||
menu.appendChild(stepperDivider);
|
||||
@@ -2245,7 +2288,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||||
const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ layout: { autoHideHeads, gridCols: COLS, tiles } }),
|
||||
body: JSON.stringify({ layout: { autoHideHeads, gridCols: COLS, alertChannels, tiles } }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
|
||||
@@ -2339,15 +2382,25 @@ const topbarSizer = new ResizeObserver(() => {
|
||||
});
|
||||
topbarSizer.observe(topbar);
|
||||
|
||||
/* What the notch (or fullscreen) asked for. The bar itself is also held
|
||||
open for as long as an alert this dashboard listens to is showing — the
|
||||
alert's text lives in it — and goes back to this the moment it ends. */
|
||||
let menuHiddenWanted = false;
|
||||
|
||||
function paintMenuHidden() {
|
||||
document.body.classList.toggle('menu-hidden', menuHiddenWanted && !alertShowing);
|
||||
}
|
||||
|
||||
function setMenuHidden(hidden, { persist = true } = {}) {
|
||||
document.body.classList.toggle('menu-hidden', hidden);
|
||||
menuHiddenWanted = Boolean(hidden);
|
||||
paintMenuHidden();
|
||||
if (persist) {
|
||||
try { localStorage.setItem(LS_MENU, hidden ? '1' : '0'); } catch { /* fine */ }
|
||||
}
|
||||
}
|
||||
|
||||
topbarNotch.addEventListener('click', () => {
|
||||
setMenuHidden(!document.body.classList.contains('menu-hidden'));
|
||||
setMenuHidden(!menuHiddenWanted);
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -2490,7 +2543,7 @@ document.addEventListener('fullscreenchange', () => {
|
||||
if (active) {
|
||||
closeSettingsPopovers();
|
||||
applyAllHeadStates(); // fullscreen has its own rule for the bars
|
||||
menuHiddenBeforeFs = document.body.classList.contains('menu-hidden');
|
||||
menuHiddenBeforeFs = menuHiddenWanted;
|
||||
setMenuHidden(true, { persist: false }); // the menu auto-hides; its notch brings it back
|
||||
// let the collapsed menubar's space settle before measuring
|
||||
requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit));
|
||||
@@ -2519,9 +2572,15 @@ new ResizeObserver(() => {
|
||||
function subscribeEvents() {
|
||||
// Rides on the page's one stream connection (see sseSubscribe) as `shell:`.
|
||||
sseSubscribe('/api/events', {
|
||||
// (Re)connected: frames may have been missed, so the alerts picture is
|
||||
// taken whole again — what is active, and the colours.
|
||||
open: () => { refreshAlerts(); },
|
||||
events: {
|
||||
'modules-changed': () => handleModulesChanged().then(reconcileOfferedTiles),
|
||||
theme: handleThemeEvent,
|
||||
alert: handleAlertEvent,
|
||||
'alert-clear': handleAlertClearEvent,
|
||||
'alerts-config': handleAlertsConfigEvent,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2591,6 +2650,240 @@ function initTheme() {
|
||||
|
||||
initTheme();
|
||||
|
||||
/* ── alerts ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/* A module that sees something a tech must act on now raises an alert on
|
||||
one of six channels (server.js is the hub; docs/ALERTS.md the whole
|
||||
story). Every dashboard hears every alert over the shell stream and shows
|
||||
the ones on channels it listens to: the menu bar — held open for the
|
||||
duration, whatever its notch says — and a frame round the screen flash
|
||||
between the level's two colours (style.css sets the cadence per level;
|
||||
critical floods the board behind the tiles as well, in a muted cut), with
|
||||
the text bold in the middle of the bar. Several at once: the most
|
||||
severe takes the bar, the newest among equals, and a count says how many
|
||||
more are on. The server times alerts out and says so; a timer here ends
|
||||
one on time anyway, in case that frame is missed on a flaky connection. */
|
||||
|
||||
const ALERT_CHANNELS = 6;
|
||||
const ALERT_LEVEL_RANK = { low: 1, medium: 2, critical: 3 };
|
||||
const ALERT_COLORS_DEFAULT = {
|
||||
low: ['#1f6feb', '#0b2a5c'],
|
||||
medium: ['#f0b429', '#6b4a0a'],
|
||||
critical: ['#f4433c', '#1a0505'],
|
||||
};
|
||||
const activeAlerts = new Map(); // channel -> { id, channel, level, text, source, startedAt, endsAt (this clock) | null }
|
||||
let alertColors = ALERT_COLORS_DEFAULT;
|
||||
let alertEndTimer = null;
|
||||
const alertBanner = document.getElementById('alert-banner');
|
||||
|
||||
/** Whole numbers 1–6, each once, in order — whatever a layout file says. */
|
||||
function normalizeAlertChannels(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);
|
||||
}
|
||||
|
||||
function setAlertChannels(list, { save = true } = {}) {
|
||||
alertChannels = normalizeAlertChannels(list);
|
||||
paintAlerts();
|
||||
if (save) saveLayout();
|
||||
}
|
||||
|
||||
function toggleAlertChannel(n) {
|
||||
setAlertChannels(alertChannels.includes(n)
|
||||
? alertChannels.filter((c) => c !== n)
|
||||
: [...alertChannels, n]);
|
||||
}
|
||||
|
||||
const CARET_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>';
|
||||
let alertsFoldOpen = false;
|
||||
|
||||
/** The Layouts menu's Alerts row. Folded, it says which channels this
|
||||
dashboard listens to; open, six numbered buttons toggle them without
|
||||
closing the menu, so a change can be watched taking effect. */
|
||||
function alertsFold() {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'menu-fold';
|
||||
const head = document.createElement('button');
|
||||
head.type = 'button';
|
||||
head.className = 'menu-item menu-fold-head';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = 'Alerts';
|
||||
const sub = document.createElement('span');
|
||||
sub.className = 'sub';
|
||||
label.appendChild(sub);
|
||||
const caret = document.createElement('span');
|
||||
caret.className = 'menu-fold-caret';
|
||||
caret.innerHTML = CARET_SVG;
|
||||
head.append(label, caret);
|
||||
const body = document.createElement('div');
|
||||
body.className = 'menu-fold-body alert-channels';
|
||||
const buttons = [];
|
||||
for (let n = 1; n <= ALERT_CHANNELS; n += 1) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'alert-channel-btn';
|
||||
btn.textContent = String(n);
|
||||
btn.setAttribute('data-tip', `Channel ${n}`);
|
||||
btn.addEventListener('click', () => {
|
||||
toggleAlertChannel(n);
|
||||
paint();
|
||||
});
|
||||
buttons.push(btn);
|
||||
body.appendChild(btn);
|
||||
}
|
||||
const paint = () => {
|
||||
sub.textContent = alertChannels.length ? 'Channels ' + alertChannels.join(', ') : 'No channels';
|
||||
head.setAttribute('aria-expanded', alertsFoldOpen ? 'true' : 'false');
|
||||
wrap.classList.toggle('is-open', alertsFoldOpen);
|
||||
body.hidden = !alertsFoldOpen;
|
||||
buttons.forEach((btn, i) => {
|
||||
const on = alertChannels.includes(i + 1);
|
||||
btn.classList.toggle('is-on', on);
|
||||
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
});
|
||||
};
|
||||
head.addEventListener('click', () => {
|
||||
alertsFoldOpen = !alertsFoldOpen;
|
||||
paint();
|
||||
});
|
||||
paint();
|
||||
wrap.append(head, body);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** Remember an alert the server announced, its end put on this browser's clock. */
|
||||
function noteAlert(a) {
|
||||
const channel = Number(a?.channel);
|
||||
if (!Number.isInteger(channel) || channel < 1 || channel > ALERT_CHANNELS) return;
|
||||
const remaining = a.remainingMs === null || a.remainingMs === undefined ? null : Number(a.remainingMs);
|
||||
activeAlerts.set(channel, {
|
||||
id: String(a.id || ''),
|
||||
channel,
|
||||
level: ALERT_LEVEL_RANK[a.level] ? String(a.level) : 'low',
|
||||
text: String(a.text || ''),
|
||||
source: String(a.source || ''),
|
||||
startedAt: Number(a.startedAt) || Date.now(),
|
||||
endsAt: Number.isFinite(remaining) ? Date.now() + Math.max(0, remaining) : null,
|
||||
});
|
||||
}
|
||||
|
||||
/** `alert` shell event: a new alert, or a replacement on its channel. */
|
||||
function handleAlertEvent(e) {
|
||||
try {
|
||||
noteAlert(JSON.parse(e.data));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
paintAlerts();
|
||||
}
|
||||
|
||||
/** `alert-clear` shell event: ended early or timed out. */
|
||||
function handleAlertClearEvent(e) {
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(e.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const current = activeAlerts.get(Number(payload?.channel));
|
||||
if (!current) return;
|
||||
// A clear for an alert already replaced leaves the replacement alone.
|
||||
if (payload.id && current.id && payload.id !== current.id) return;
|
||||
activeAlerts.delete(current.channel);
|
||||
paintAlerts();
|
||||
}
|
||||
|
||||
/** `alerts-config` shell event: an admin changed the colours. */
|
||||
function handleAlertsConfigEvent(e) {
|
||||
try {
|
||||
setAlertColors(JSON.parse(e.data).colors);
|
||||
} catch { /* a malformed frame changes nothing */ }
|
||||
}
|
||||
|
||||
function setAlertColors(colors) {
|
||||
const next = {};
|
||||
for (const level of Object.keys(ALERT_COLORS_DEFAULT)) {
|
||||
const pair = Array.isArray(colors?.[level]) ? colors[level] : [];
|
||||
next[level] = ALERT_COLORS_DEFAULT[level].map((fallback, i) => {
|
||||
const v = String(pair[i] ?? '').trim();
|
||||
return /^#[0-9a-fA-F]{6}$/.test(v) ? v.toLowerCase() : fallback;
|
||||
});
|
||||
}
|
||||
alertColors = next;
|
||||
paintAlerts();
|
||||
}
|
||||
|
||||
/** The whole picture from the server: what is active, and the colours. */
|
||||
async function refreshAlerts() {
|
||||
try {
|
||||
const res = await fetchWithTimeout('/api/alerts', 8000);
|
||||
if (!res.ok) return;
|
||||
const body = await res.json();
|
||||
activeAlerts.clear();
|
||||
for (const a of body.active || []) noteAlert(a);
|
||||
setAlertColors(body.colors); // paints
|
||||
} catch { /* server away — what is known stands */ }
|
||||
}
|
||||
|
||||
/** Relative luminance of #rrggbb, 0 (black) to 1 (white). */
|
||||
function luminance(hex) {
|
||||
const n = parseInt(String(hex).slice(1), 16);
|
||||
const chan = (c) => {
|
||||
const s = c / 255;
|
||||
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * chan((n >> 16) & 255) + 0.7152 * chan((n >> 8) & 255) + 0.0722 * chan(n & 255);
|
||||
}
|
||||
|
||||
/** Black or white for the banner — whichever reads against BOTH colours the bar flashes between. */
|
||||
function alertTextColor(a, b) {
|
||||
const contrast = (l1, l2) => (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
const la = luminance(a);
|
||||
const lb = luminance(b);
|
||||
const white = Math.min(contrast(1, la), contrast(1, lb));
|
||||
const black = Math.min(contrast(0, la), contrast(0, lb));
|
||||
return white >= black ? '#ffffff' : '#000000';
|
||||
}
|
||||
|
||||
function paintAlerts() {
|
||||
clearTimeout(alertEndTimer);
|
||||
alertEndTimer = null;
|
||||
const now = Date.now();
|
||||
for (const [channel, a] of activeAlerts) {
|
||||
if (a.endsAt !== null && a.endsAt <= now) activeAlerts.delete(channel);
|
||||
}
|
||||
const showing = [...activeAlerts.values()]
|
||||
.filter((a) => alertChannels.includes(a.channel))
|
||||
.sort((a, b) => (ALERT_LEVEL_RANK[b.level] - ALERT_LEVEL_RANK[a.level]) || (b.startedAt - a.startedAt));
|
||||
const top = showing[0];
|
||||
const root = document.documentElement.style;
|
||||
if (!top) {
|
||||
delete document.body.dataset.alert;
|
||||
alertBanner.hidden = true;
|
||||
} else {
|
||||
const [a, b] = alertColors[top.level] || ALERT_COLORS_DEFAULT.low;
|
||||
root.setProperty('--alert-a', a);
|
||||
root.setProperty('--alert-b', b);
|
||||
root.setProperty('--alert-text', alertTextColor(a, b));
|
||||
document.body.dataset.alert = top.level;
|
||||
alertBanner.querySelector('.alert-channel').textContent = String(top.channel);
|
||||
alertBanner.querySelector('.alert-text').textContent = top.text;
|
||||
alertBanner.querySelector('.alert-more').textContent = showing.length > 1 ? `+${showing.length - 1}` : '';
|
||||
alertBanner.hidden = false;
|
||||
}
|
||||
alertShowing = Boolean(top);
|
||||
paintMenuHidden();
|
||||
// The next end among everything active — shown or not, since a channel
|
||||
// may be subscribed to meanwhile — repaints the picture when it comes.
|
||||
let next = Infinity;
|
||||
for (const a of activeAlerts.values()) if (a.endsAt !== null) next = Math.min(next, a.endsAt);
|
||||
if (next < Infinity) alertEndTimer = setTimeout(paintAlerts, Math.max(50, next - now + 20));
|
||||
}
|
||||
|
||||
/* ── boot ───────────────────────────────────────────────────────────── */
|
||||
|
||||
async function boot() {
|
||||
|
||||
@@ -36,6 +36,7 @@ button { font-family: inherit; }
|
||||
/* ── topbar ────────────────────────────────────────────────────────── */
|
||||
|
||||
#topbar {
|
||||
position: relative; /* the alert banner centres itself against the bar */
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -135,6 +136,110 @@ body.menu-hidden #topbar {
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
/* ── alerts ────────────────────────────────────────────────────────── */
|
||||
|
||||
/* An alert this dashboard listens to (shell.js sets body[data-alert] to its
|
||||
level and --alert-a / --alert-b to the level's two colours, from Admin →
|
||||
Alerts) flashes the shell's chrome: the menu bar's background and a frame
|
||||
round the whole screen swap between the two colours, with the alert's text
|
||||
bold in the middle of the bar. Low breathes slowly, medium pulses,
|
||||
critical snaps between the two with no fade at all — and, alone among
|
||||
them, floods the board behind the tiles too (below). Low and medium leave
|
||||
the tiles alone: the frame is what carries their flash on a fullscreen
|
||||
wall, where the bar (held open for the alert) is the only other chrome. */
|
||||
@keyframes alert-flash {
|
||||
0%, 100% { background-color: var(--alert-a); }
|
||||
50% { background-color: var(--alert-b); }
|
||||
}
|
||||
@keyframes alert-frame {
|
||||
0%, 100% { border-color: var(--alert-a); }
|
||||
50% { border-color: var(--alert-b); }
|
||||
}
|
||||
body[data-alert="low"] { --alert-period: 3.2s; --alert-ease: ease-in-out; }
|
||||
body[data-alert="medium"] { --alert-period: 1.1s; --alert-ease: ease-in-out; }
|
||||
body[data-alert="critical"] { --alert-period: 0.6s; --alert-ease: steps(1, end); }
|
||||
body[data-alert] #topbar {
|
||||
border-bottom-color: transparent;
|
||||
animation: alert-flash var(--alert-period, 3s) var(--alert-ease, ease-in-out) infinite;
|
||||
}
|
||||
body[data-alert]::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 85; /* over tiles and menus, under modals and the toast */
|
||||
pointer-events: none;
|
||||
border: 6px solid var(--alert-a);
|
||||
animation: alert-frame var(--alert-period, 3s) var(--alert-ease, ease-in-out) infinite;
|
||||
}
|
||||
/* The bar's buttons keep their own surface so they stay legible on either colour. */
|
||||
body[data-alert] #topbar .tb-btn { border-color: transparent; }
|
||||
|
||||
/* Critical reaches the whole page. The board behind the tiles flashes too,
|
||||
in a cut of the two colours mixed well into the page background — shifted
|
||||
toward it, so a tile still stands off the board — and the tiles' own
|
||||
panel thins a little to let that show through them, faintly enough that
|
||||
nothing written on a tile loses contrast. On a wall where tiles cover
|
||||
every pixel this is what carries the flash to the middle of the screen. */
|
||||
@keyframes alert-board {
|
||||
0%, 100% { background-color: color-mix(in srgb, var(--alert-a) 40%, var(--bg)); }
|
||||
50% { background-color: color-mix(in srgb, var(--alert-b) 40%, var(--bg)); }
|
||||
}
|
||||
body[data-alert="critical"] {
|
||||
animation: alert-board var(--alert-period) var(--alert-ease) infinite;
|
||||
}
|
||||
body[data-alert="critical"] .tile {
|
||||
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||
}
|
||||
body[data-alert="critical"] .tile.is-tinted {
|
||||
background: color-mix(in srgb, color-mix(in srgb, var(--tile-tint) 14%, var(--panel)) 88%, transparent);
|
||||
}
|
||||
|
||||
#alert-banner {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
/* clear of the wordmark at the left and the buttons at the right */
|
||||
max-width: clamp(140px, calc(100% - 420px), 70vw);
|
||||
pointer-events: none;
|
||||
color: var(--alert-text, #fff);
|
||||
font-weight: 800;
|
||||
font-size: 17px;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#alert-banner[hidden] { display: none; }
|
||||
#alert-banner .alert-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* which channel — small, boxed, before the text */
|
||||
#alert-banner .alert-channel {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
border: 2px solid currentColor;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
/* "+1" when more alerts are on than the bar shows */
|
||||
#alert-banner .alert-more {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
opacity: 0.85;
|
||||
}
|
||||
#alert-banner .alert-more:empty { display: none; }
|
||||
|
||||
/* ── dropdown menus ────────────────────────────────────────────────── */
|
||||
|
||||
.dropdown { position: relative; }
|
||||
@@ -217,6 +322,43 @@ body.menu-hidden #topbar {
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* the Layouts menu's Alerts row: a fold that opens to six channel buttons */
|
||||
.menu-fold-head { justify-content: space-between; }
|
||||
.menu-fold-head > span:first-child { flex: 1; }
|
||||
.menu-fold-caret {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
color: var(--muted);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.menu-fold-caret svg { width: 12px; height: 12px; display: block; }
|
||||
.menu-fold.is-open .menu-fold-caret { transform: rotate(90deg); }
|
||||
.menu-fold-body[hidden] { display: none; }
|
||||
.alert-channels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 6px;
|
||||
padding: 2px 10px 10px;
|
||||
}
|
||||
.alert-channel-btn {
|
||||
height: 30px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
}
|
||||
.alert-channel-btn:hover { border-color: var(--muted); }
|
||||
.alert-channel-btn.is-on,
|
||||
.alert-channel-btn.is-on:hover {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
.menu-title {
|
||||
padding: 6px 10px 3px;
|
||||
color: var(--muted);
|
||||
@@ -980,6 +1122,7 @@ body.fullscreen #grid { padding-bottom: 0; }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
#topbar { padding: 0 8px; gap: 6px; }
|
||||
#alert-banner { font-size: 14px; gap: 6px; }
|
||||
}
|
||||
|
||||
/* ── themes ────────────────────────────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user