From 5ca0a672fa488b5367d39f7b7008b909c4bcfc8c Mon Sep 17 00:00:00 2001 From: Waters Church Date: Mon, 5 Oct 2026 16:05:12 -0400 Subject: [PATCH] =?UTF-8?q?Shell=201.11.0:=20alerts=20=E2=80=94=20a=20modu?= =?UTF-8?q?le=20flashes=20every=20dashboard=20listening=20on=20one=20of=20?= =?UTF-8?q?six=20channels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/, 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 --- README.md | 30 ++++ docs/ALERTS.md | 132 +++++++++++++++++ docs/MODULE-GUIDE.md | 75 ++++++++++ package.json | 2 +- public/admin/admin.css | 67 +++++++++ public/admin/admin.js | 147 ++++++++++++++++++- public/admin/index.html | 21 +++ public/index.html | 7 + public/shell.js | 309 ++++++++++++++++++++++++++++++++++++++-- public/style.css | 143 +++++++++++++++++++ server.js | 291 ++++++++++++++++++++++++++++++++++++- tools/alert.js | 83 +++++++++++ tools/alerts.test.js | 277 +++++++++++++++++++++++++++++++++++ 13 files changed, 1572 insertions(+), 12 deletions(-) create mode 100644 docs/ALERTS.md create mode 100644 tools/alert.js create mode 100644 tools/alerts.test.js 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 @@
+ +