diff --git a/README.md b/README.md index d07fc91..ff45484 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ with this machine's shell settings — the same keys as the checked-in | `adminPasscode` | An older free-text passcode. Still honoured, so an existing install keeps working; new installs should use `adminPin`. | | `forge` | Where updates and module installs are fetched from. Absent or `"github"` uses GitHub; a Gitea or Forgejo base URL such as `"http://10.3.8.90:3000"` uses a server of your own. Set it per machine in the data directory, alongside `repo`. | | `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember`, `daylight`, `midnight`, `moss`, `plum`, `sepia`, `blackout`, `paper` or `iron`. The admin page's **Theme** section writes this one — see [Themes](#themes). | +| `alerts` | `{ "colors": { "low": [a, b], "medium": [a, b], "critical": [a, b] } }` — the two colours each alert level flashes between, as `#rrggbb`. The admin page's **Alerts** section writes this one — see [Alerts](#alerts). | **Locked out?** Delete `adminPin` from the data directory's `proddash.json` and restart, or start the server with `PRODDASH_PASSCODE` set — that passcode @@ -192,6 +193,25 @@ saw and applies it before the server answers, so nothing flashes. Modules style with the shell's colour variables, which is why they follow; a colour set on a tile itself (a PCO Plan header colour, say) is deliberately kept. +### Alerts + +A module that sees something a tech must act on now — a timer overrun, a +meter past its limit, timecode gone — can raise an **alert** on one of six +channels, and every dashboard listening to that channel flashes: the menu +bar (brought out if it was hidden, for as long as the alert lasts) and a +frame round the screen alternate between two colours, with the alert's text +bold across the bar. **Low** fades slowly between its two colours, +**medium** faster, **critical** snaps between them with no fade and floods +the board behind the tiles as well, in a muted cut of the two that shows +faintly through the tiles without costing their content any contrast. The admin +page's **Alerts** section (folded, near the bottom) holds the two colour +pickers for each level, with a swatch running that level's cadence, and a +**Test** row that flashes any level on any channel for eight seconds. The +choice is stored as `alerts.colors` in the data directory's `proddash.json`. +Which channels a dashboard listens to is chosen on the dashboard itself — +see *Using the dashboard* below — and [docs/ALERTS.md](docs/ALERTS.md) has +the whole feature, module side included. + ## Installing modules and updating Everything below happens in the **admin page**; ProdDash talks to its git @@ -309,6 +329,14 @@ below. Nothing is installed without someone clicking. that no longer fits where it was moves to the first free spot rather than onto another tile. It saves with the layout, so a named layout comes back at the density it was arranged on. +- The same menu's **Alerts** row says which of the six alert channels this + screen listens to, and opens to six numbered buttons that toggle them + without closing the menu. An alert a module raises on a chosen channel + flashes this dashboard's menu bar and screen edge with its text in the + bar; one on any other channel leaves it alone. A fresh dashboard listens + to nothing. The choice saves with the layout, local and named — a booth + loading its "Stage" layout gets the stage's channels with it. See + [Alerts](#alerts). - Every tile has a **status dot**: green = live, amber = connecting, red = upstream unreachable (hover it for details). Tiles reconnect automatically. @@ -553,7 +581,9 @@ ProdDash/ ├── config/ proddash.json defaults (runtime state lives in the │ per-machine data directory — see "Configure") ├── docs/MODULE-GUIDE.md how to build and install a module +├── docs/ALERTS.md the alerts feature: levels, channels, admin, API ├── tools/prodcom-mock.js mock ProdCom API for development +├── tools/alert.js raise / clear / list alerts from the command line ├── prodcom-listener/ reference fork — read-only, not served └── band-lineup-display/ reference fork — read-only, not served ``` diff --git a/docs/ALERTS.md b/docs/ALERTS.md new file mode 100644 index 0000000..3faeeb6 --- /dev/null +++ b/docs/ALERTS.md @@ -0,0 +1,132 @@ +# ProdDash Alerts + +A module that sees something a tech must act on *now* — a timer overrun, +a meter past its limit, timecode gone — raises an **alert**, and every +dashboard listening to its channel flashes until it is over. This page is +the whole feature: what an operator sees and sets, and what a module does +to raise one. The module-side contract also lives in the +[Module Guide](MODULE-GUIDE.md#raising-alerts), which is the authority on +how modules are written. + +## What an alert looks like + +On a dashboard that listens to the alert's channel: + +- The **menu bar** flashes between two colours, with the alert's text in + bold across its middle (and a small boxed number saying which channel it + came on). If the bar was hidden — by its notch, or because the screen is + in fullscreen — it comes out for the alert and goes back when it ends. +- A **frame round the whole screen** flashes with it, so a fullscreen wall + where the bar is the only chrome still carries the flash to its edges. +- For **low** and **medium**, that is all: the tiles and the board they sit + on are left alone. A **critical** alert floods the **board behind the + tiles** as well — in a muted cut of its two colours, mixed well toward the + page background and showing faintly through the tiles' own panels — so + the flash reaches the middle of a screen the tiles cover entirely, while + everything written on a tile keeps its contrast. A module's content is + never recoloured. + +The **level** sets the cadence. The two colours each level flashes between +are the admin's choice (below). + +| Level | Cadence | For | +| --- | --- | --- | +| **Low** | a slow fade between the two colours, about three seconds a cycle; bar and frame only | something to know about | +| **Medium** | a faster fade, about one second a cycle; bar and frame only | something to deal with soon | +| **Critical** | no fade — the colours snap, twice a second; bar, frame and the board behind the tiles | something to deal with now | + +When several alerts a dashboard listens to are on at once, the bar shows +the most severe (the newest among equals) and a **+1** says how many more +there are. + +## Channels + +There are **six channels**, numbered 1 to 6, and nothing else: a channel +carries no meaning of its own. A booth gives them meaning — "1 is the +stage, 2 is video, 3 is the lobby" — by pointing modules at them (a module +that raises alerts makes its channel an admin setting) and by choosing +which channels each dashboard listens to. + +**Choosing channels:** on the dashboard, open the Layouts menu (the grid +icon) and the **Alerts** row — it says which channels this screen listens +to and opens to six numbered buttons that toggle them. The choice is part +of the arrangement, like the grid size and auto-hide: it saves with this +browser's layout and with a named layout on the server, so a booth that +loads its "Stage" layout gets the stage's channels with it. A fresh +dashboard listens to nothing. + +## One alert per channel + +A channel shows one alert at a time. A new alert on a channel replaces +whatever was on it, whoever raised it; clearing a channel ends whatever is +on it. Two modules pointed at one channel will talk over each other — give +them channels of their own. + +An alert ends when its **duration** runs out (1 to 600 seconds; 15 when the +module gives none), or when the module that raised it clears it early, or +when that module stops (a disable, an uninstall, a config save that re-inits +it): a module's alerts never outlive it. A module may hold an alert **until +cleared** (duration 0) for a condition it will notice ending — timecode +lost, a feed down — and clear it when the condition does. + +## Admin → Alerts + +The admin page's **Alerts** section (folded by default, near the bottom) +holds two colour pickers per level — the two colours that level flashes +between — with a swatch beside each row running that level's cadence, so a +pair can be judged before it is tried. A change saves when the picker +closes and reaches every open dashboard at once. The row below sends a +**test alert** of any level on any channel: eight seconds of whatever the +dashboards listening to that channel will see. + +The colours are kept as `alerts.colors` in the data directory's +`proddash.json` (see the README's *Where settings are kept*); they hold +across restarts and updates. The defaults are a blue pair for low, amber +for medium and red against near-black for critical. + +## For module authors + +Raise from the server entry, which is where a condition is usually noticed +— `shell.alert({ channel, level, text, duration })` and +`shell.clearAlert(channel)` on the `shell` object `init()`, `routes()` and +`tiles()` receive — or from a tile with `moduleApi.alert(...)` and +`moduleApi.clearAlert(...)`, which go through the server. The fields, what +each is for, and the rules that keep alerts worth looking at (raise on the +transition, make it a setting, it is not a status) are in the +[Module Guide](MODULE-GUIDE.md#raising-alerts). + +## The HTTP API + +Everything above rides on three public routes and two admin ones. They are +open to anything that can reach ProdDash, like a module's own routes: +an alert is a flash on screens in this building and nothing more. + +| Route | Does | +| --- | --- | +| `GET /api/alerts` | `{ channels: 6, levels, colors: { low: [a, b], … }, active: [ … ] }` — what is flashing and the colours. Dashboards read it on load and whenever their stream reconnects. | +| `POST /api/alerts` | `{ channel, level, text, duration?, source? }` as `application/json`. Answers `200 { ok, alert }` or `400 { error }` saying what was wrong. `source` is the module id (anything else is recorded as `api`). | +| `DELETE /api/alerts/` | Ends the alert on that channel. `{ ok, cleared }` — `cleared` is false when nothing was on it. | +| `POST /api/admin/alerts` | `{ colors: { low: [a, b], medium: [a, b], critical: [a, b] } }` — a missing or malformed colour takes its default. Behind the admin PIN. | +| `POST /api/admin/alerts/test` | `{ level, channel }` — an eight-second test alert. Behind the admin PIN. | + +Open dashboards learn of changes over the shell stream (`/api/events`): +`alert` carries an alert as raised (`{ id, channel, level, text, source, +startedAt, duration, endsAt, remainingMs }` — a browser counts down from +`remainingMs`, so the clocks need not agree), `alert-clear` carries +`{ channel, id }` when one ends, and `alerts-config` carries `{ colors }` +when the admin changes them. + +## Trying it + +With ProdDash running, from the repo: + +```bash +node tools/alert.js -c 1 -l critical "LTC lost" +``` + +raises a critical alert on channel 1 for the default 15 seconds; `-d 0` +holds it, `node tools/alert.js -c 1 --clear` ends it, `--list` shows what +is on. Open a dashboard, choose channel 1 in its Layouts menu → Alerts, and +watch; a second browser listening to channel 2 should stay quiet. The admin +page's **Test** buttons do the same from the browser. +`node tools/alerts.test.js` runs the server's side of all this end to end. diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 74256db..773c054 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -437,6 +437,8 @@ export default function create({ root, moduleApi }) { | `fetch(path, opts)` | `fetch` scoped to your server routes: `fetch('/state')` hits `/api/modules//state`. | | `sse(path, handlers)` | An `EventSource` scoped the same way, **with auto-reconnect** (see below). Returns a handle with `close()`. | | `setStatus(state, msg)` | Drives the tile's status dot: `'ok'`, `'connecting'` or `'error'`, plus a hover message. Call it honestly — during a live service the dot is how an operator spots a dead feed. | +| `alert(opts)` | Flash every dashboard listening to an alert channel — `{ channel, level, text, duration }`, see **Raising alerts**. Returns a promise. A module with a server entry raises from there (`shell.alert`) instead. | +| `clearAlert(channel)` | End the alert on a channel early. Returns a promise. | | `header.addButton(…)` | Put a small button in the tile's own title bar (see below). | | `header.addMenu(…)` | Put a dropdown menu button there (see below). | @@ -600,6 +602,9 @@ module.exports = { - `shell.port` is the port ProdDash is serving on — what you need to read another module's routes over loopback (`http://127.0.0.1:${shell.port}/api/ modules//…`). Never guess it. +- `shell.alert(opts)` flashes every dashboard listening to an alert channel + and `shell.clearAlert(channel)` ends one early — see **Raising alerts**. + Alerts your module raised end when it stops. - Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is the matched remainder (leading `/` included) and `req.search` is the query string (with `?`, or `''`) — so a proxy rebuilds the upstream path as @@ -704,6 +709,72 @@ per-client `res.write` in try/catch (drop the client on failure). The `propresenter-now-next` module is the reference implementation of this pattern. +## Raising alerts + +An alert is for the thing a tech must act on *now*, whichever tile they +happen to be looking at — a timer that has overrun, a meter past its limit, +timecode that has gone quiet mid-service. Every dashboard that listens to +the alert's channel flashes: its menu bar (brought out for the duration, +even if it was hidden) and a frame round the whole screen alternate between +the level's two colours, with your text bold across the middle of the bar. +The shell owns all of that — the colours (Admin → Alerts), the cadence, +which screens listen (each dashboard's Layouts menu → Alerts) — so a module +only has to say what happened. [docs/ALERTS.md](ALERTS.md) has the +operator's side. + +Raise one from your server entry, where the condition is usually noticed: + +```js +init({ config, log, shell }) { + // …when timecode drops: + shell.alert({ channel: config.alertChannel, level: 'critical', text: 'LTC lost', duration: 0 }); + // …and when it is back: + shell.clearAlert(config.alertChannel); +} +``` + +or from a tile, through the shell's server: + +```js +await moduleApi.alert({ channel: 2, level: 'medium', text: 'Service over by 5:00', duration: 20 }); +``` + +| Field | Meaning | +| --- | --- | +| `channel` | `1`–`6`. Which dashboards flash: each listens to the channels its operator chose in the Layouts menu. A channel carries no meaning of its own — the booth decides that "1 is the stage, 2 is video" — so **make yours an admin setting** (`configSchema`, `type: "number"`, `min: 1`, `max: 6`) rather than hard-coding one. | +| `level` | `low` — a slow fade between the two colours, for something to know about; `medium` — a quicker pulse, for something to deal with soon; `critical` — no fade, the colours snap, and the board behind the tiles floods with a muted cut of them too, for something to deal with now. | +| `text` | What happened, in a few words — 80 characters at most (more is cut, whitespace is collapsed). It is shown as given, so write it the way it should read on a wall: `SPL 102 dB — over limit`, not `spl_over_limit`. | +| `duration` | Seconds, 1 to 600; `15` when omitted. `0` holds the alert until you clear it — for a condition you will *notice ending* (timecode lost, a feed down) and clear; a one-off event takes a duration. | + +Both return the alert as raised (`{ id, channel, level, text, source, +startedAt, duration, endsAt, remainingMs }`) and throw — reject, on the +client — with a one-line reason when the input is refused. That is a bug +in your module, not something to show the operator. + +What to know: + +- **One alert per channel.** A new alert on a channel replaces whatever was + on it, whoever raised it; `clearAlert(channel)` ends whatever is on it. + Two modules on one channel talk over each other — one more reason the + channel is admin config. +- **Your alerts end when you stop.** A disable, an uninstall or a config + save that re-inits you clears every alert your module raised, held ones + included. If the condition is still true, raise it again from `init()`. +- **Raise on the transition, not the state.** `if (over && !wasOver) + shell.alert(…)` — an alert re-raised on every poll restarts its timer and + flashes for ever. Clear it, or let it expire, when the condition ends. +- **Make alerting a setting**, and the threshold too: a `switch` in + `configSchema`, off by default unless alerting is the module's reason to + exist. A screen that flashes for nothing teaches a room to ignore it. +- **It is not a status.** The tile's dot (`setStatus`) says how *you* are; + an alert says what the *room* must do. An unreachable upstream turns the + dot red; it raises an alert only if that is something a tech has to act on + during the service, and the operator switched that on. +- Alerts reach the server over `POST /api/alerts` — what `moduleApi.alert` + calls, with your module id as `source` — which anything on the network may + use too; `node tools/alert.js -c 1 -l critical "LTC lost"` raises one from + the command line while you build (`--clear`, `--list`). + ## Working with other modules Modules don't import each other. When one needs what another knows, it reads @@ -961,6 +1032,10 @@ client-side failures (a tile that fails to start says so in the tile). - `GET /api/modules//...` — your routes, `curl`-able. For SSE: `curl -N http://localhost:24500/api/modules//stream`. - `GET /api/admin/state` — enabled flags, health, mount errors. +- `GET /api/alerts` — what is flashing, and the colours. `node tools/alert.js + -c 1 -l critical "LTC lost"` raises one without a module (`--clear`, + `--list`); a dashboard shows it once channel 1 is chosen in its Layouts + menu → Alerts. **Mock upstreams** (so you can develop without the real gear): diff --git a/package.json b/package.json index 42a599b..817bc41 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proddash", - "version": "1.10.2", + "version": "1.11.0", "private": true, "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client", "license": "UNLICENSED", diff --git a/public/admin/admin.css b/public/admin/admin.css index 0a7ae91..9796d91 100644 --- a/public/admin/admin.css +++ b/public/admin/admin.css @@ -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)); diff --git a/public/admin/admin.js b/public/admin/admin.js index 18dff9b..ee3b594 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -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); diff --git a/public/admin/index.html b/public/admin/index.html index fa819f0..6701559 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -70,6 +70,27 @@
+ +