Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Fable 5.1 5ca0a672fa 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>
2026-10-05 16:05:12 -04:00
michael a3500f98b1 Merge pull request 'Send Message 1.0.0: one PIN, one place to send from; Slack 1.3.0 and ProdCom 1.4.0 become messaging providers' (#40) from claude/send-message-module-refactor-9af19a into main 2026-10-05 14:13:17 -04:00
13 changed files with 1572 additions and 12 deletions
+30
View File
@@ -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`. | | `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`. | | `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). | | `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` **Locked out?** Delete `adminPin` from the data directory's `proddash.json`
and restart, or start the server with `PRODDASH_PASSCODE` set — that passcode 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 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. 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 ## Installing modules and updating
Everything below happens in the **admin page**; ProdDash talks to its git 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 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 onto another tile. It saves with the layout, so a named layout comes back
at the density it was arranged on. 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, - Every tile has a **status dot**: green = live, amber = connecting,
red = upstream unreachable (hover it for details). Tiles reconnect red = upstream unreachable (hover it for details). Tiles reconnect
automatically. automatically.
@@ -553,7 +581,9 @@ ProdDash/
├── config/ proddash.json defaults (runtime state lives in the ├── config/ proddash.json defaults (runtime state lives in the
│ per-machine data directory — see "Configure") │ per-machine data directory — see "Configure")
├── docs/MODULE-GUIDE.md how to build and install a module ├── 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/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 ├── prodcom-listener/ reference fork — read-only, not served
└── band-lineup-display/ reference fork — read-only, not served └── band-lineup-display/ reference fork — read-only, not served
``` ```
+132
View File
@@ -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/<channel>` | 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.
+75
View File
@@ -437,6 +437,8 @@ export default function create({ root, moduleApi }) {
| `fetch(path, opts)` | `fetch` scoped to your server routes: `fetch('/state')` hits `/api/modules/<id>/state`. | | `fetch(path, opts)` | `fetch` scoped to your server routes: `fetch('/state')` hits `/api/modules/<id>/state`. |
| `sse(path, handlers)` | An `EventSource` scoped the same way, **with auto-reconnect** (see below). Returns a handle with `close()`. | | `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. | | `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.addButton(…)` | Put a small button in the tile's own title bar (see below). |
| `header.addMenu(…)` | Put a dropdown menu button there (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 - `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/ another module's routes over loopback (`http://127.0.0.1:${shell.port}/api/
modules/<id>/…`). Never guess it. modules/<id>/…`). 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 - Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is
the matched remainder (leading `/` included) and `req.search` is the query the matched remainder (leading `/` included) and `req.search` is the query
string (with `?`, or `''`) — so a proxy rebuilds the upstream path as 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 `propresenter-now-next` module is the reference implementation of this
pattern. 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 ## Working with other modules
Modules don't import each other. When one needs what another knows, it reads 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/<id>/...` — your routes, `curl`-able. For SSE: - `GET /api/modules/<id>/...` — your routes, `curl`-able. For SSE:
`curl -N http://localhost:24500/api/modules/<id>/stream`. `curl -N http://localhost:24500/api/modules/<id>/stream`.
- `GET /api/admin/state` — enabled flags, health, mount errors. - `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): **Mock upstreams** (so you can develop without the real gear):
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proddash", "name": "proddash",
"version": "1.10.2", "version": "1.11.0",
"private": true, "private": true,
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client", "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED", "license": "UNLICENSED",
+67
View File
@@ -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[open] > summary.admin-fold-title::after { transform: rotate(90deg); }
details.admin-fold > summary.admin-fold-title:hover { color: var(--accent); } 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 { .theme-swatches {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(116px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
+146 -1
View File
@@ -27,6 +27,10 @@ const shellVersionEl = document.getElementById('shell-version');
const themeSection = document.getElementById('theme-section'); const themeSection = document.getElementById('theme-section');
const themeSwatches = document.getElementById('theme-swatches'); const themeSwatches = document.getElementById('theme-swatches');
const themeStatus = document.getElementById('theme-status'); 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. */ /** The shell version the page loaded with — the update flow watches it change. */
let shellVersion = ''; let shellVersion = '';
@@ -64,6 +68,7 @@ function showLogin() {
layoutsSection.hidden = true; layoutsSection.hidden = true;
updateCard.hidden = true; updateCard.hidden = true;
themeSection.hidden = true; themeSection.hidden = true;
alertsSection.hidden = true;
securitySection.hidden = true; securitySection.hidden = true;
passcodeInput.value = ''; passcodeInput.value = '';
passcodeInput.focus(); passcodeInput.focus();
@@ -971,6 +976,7 @@ async function loadState() {
} }
renderPin(state); renderPin(state);
loadTheme(); loadTheme();
loadAlerts();
loadLayouts(); loadLayouts();
loadCatalog(false); 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 themeEvents = null;
let themeEventsRetry = null; let themeEventsRetry = null;
@@ -1087,6 +1229,9 @@ function subscribeThemeEvents() {
applyTheme(theme); applyTheme(theme);
renderThemes(); renderThemes();
}); });
themeEvents.addEventListener('alerts-config', (e) => {
try { applyAlertColorsEvent(JSON.parse(e.data).colors); } catch { /* a malformed frame changes nothing */ }
});
themeEvents.onerror = () => { themeEvents.onerror = () => {
if (themeEvents.readyState === EventSource.CLOSED) { if (themeEvents.readyState === EventSource.CLOSED) {
clearTimeout(themeEventsRetry); clearTimeout(themeEventsRetry);
+21
View File
@@ -70,6 +70,27 @@
<div id="layouts-list"></div> <div id="layouts-list"></div>
</section> </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> <section id="security-section" hidden>
<h1 class="admin-h1">Admin PIN</h1> <h1 class="admin-h1">Admin PIN</h1>
<p class="admin-copy" id="pin-state"></p> <p class="admin-copy" id="pin-state"></p>
+7
View File
@@ -17,6 +17,13 @@
<header id="topbar"> <header id="topbar">
<span id="brand">Prod<span class="accent">Dash</span></span> <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> <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"> <button class="tb-btn icon" id="autohide-btn" title="Auto-hide bars" aria-label="Auto-hide tile title bars" aria-pressed="false">
+301 -8
View File
@@ -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. */ away regardless; this is the windowed equivalent, asked for by hand. */
let autoHideHeads = false; 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 /* Beside the layout itself: the copy it replaced (restored if the layout
ever fails to parse) and, after loading a named layout, the arrangement ever fails to parse) and, after loading a named layout, the arrangement
that was on screen before (what Undo goes back to). */ that was on screen before (what Undo goes back to). */
@@ -235,7 +245,7 @@ const LS_LAYOUT_PREVIOUS = LS_LAYOUT + '.previous';
function saveLayout() { function saveLayout() {
try { 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); const current = localStorage.getItem(LS_LAYOUT);
if (current && current !== next) localStorage.setItem(LS_LAYOUT_BACKUP, current); if (current && current !== next) localStorage.setItem(LS_LAYOUT_BACKUP, current);
localStorage.setItem(LS_LAYOUT, next); localStorage.setItem(LS_LAYOUT, next);
@@ -1388,6 +1398,34 @@ function buildModuleApi(tile, man, entry) {
entry.dot.title = msg; 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 * The tile's title bar. Controls added here live next to the gear/close
* buttons and are removed automatically when the instance stops. The * 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. // Density first: the tiles are clamped and placed against it.
if (!Array.isArray(layout)) setGridCols(layout?.gridCols ?? GRID_DEFAULT, { save: false }); if (!Array.isArray(layout)) setGridCols(layout?.gridCols ?? GRID_DEFAULT, { save: false });
tiles = list.map(sanitizeTile); 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); for (const tile of tiles) if (tileIsOffered(tile)) buildTile(tile);
scheduleOfferedRecheck(); scheduleOfferedRecheck();
refreshEmptyState(); refreshEmptyState();
@@ -2207,9 +2248,11 @@ function renderAddMenu(menu) {
wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => { wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
menu.innerHTML = ''; menu.innerHTML = '';
// Density first: the one thing in here that is not a layout but the board // The board first — density, and which alert channels this screen
// the layouts are arranged on. // listens to: the two things in here that are not a layout but part of
// how one is arranged.
menu.appendChild(gridStepper()); menu.appendChild(gridStepper());
menu.appendChild(alertsFold());
const stepperDivider = document.createElement('div'); const stepperDivider = document.createElement('div');
stepperDivider.className = 'menu-divider'; stepperDivider.className = 'menu-divider';
menu.appendChild(stepperDivider); menu.appendChild(stepperDivider);
@@ -2245,7 +2288,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
const res = await fetch('/api/layouts/' + encodeURIComponent(name), { const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, 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(() => ({})); const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')'); if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
@@ -2339,15 +2382,25 @@ const topbarSizer = new ResizeObserver(() => {
}); });
topbarSizer.observe(topbar); 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 } = {}) { function setMenuHidden(hidden, { persist = true } = {}) {
document.body.classList.toggle('menu-hidden', hidden); menuHiddenWanted = Boolean(hidden);
paintMenuHidden();
if (persist) { if (persist) {
try { localStorage.setItem(LS_MENU, hidden ? '1' : '0'); } catch { /* fine */ } try { localStorage.setItem(LS_MENU, hidden ? '1' : '0'); } catch { /* fine */ }
} }
} }
topbarNotch.addEventListener('click', () => { topbarNotch.addEventListener('click', () => {
setMenuHidden(!document.body.classList.contains('menu-hidden')); setMenuHidden(!menuHiddenWanted);
}); });
try { try {
@@ -2490,7 +2543,7 @@ document.addEventListener('fullscreenchange', () => {
if (active) { if (active) {
closeSettingsPopovers(); closeSettingsPopovers();
applyAllHeadStates(); // fullscreen has its own rule for the bars 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 setMenuHidden(true, { persist: false }); // the menu auto-hides; its notch brings it back
// let the collapsed menubar's space settle before measuring // let the collapsed menubar's space settle before measuring
requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit)); requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit));
@@ -2519,9 +2572,15 @@ new ResizeObserver(() => {
function subscribeEvents() { function subscribeEvents() {
// Rides on the page's one stream connection (see sseSubscribe) as `shell:`. // Rides on the page's one stream connection (see sseSubscribe) as `shell:`.
sseSubscribe('/api/events', { 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: { events: {
'modules-changed': () => handleModulesChanged().then(reconcileOfferedTiles), 'modules-changed': () => handleModulesChanged().then(reconcileOfferedTiles),
theme: handleThemeEvent, theme: handleThemeEvent,
alert: handleAlertEvent,
'alert-clear': handleAlertClearEvent,
'alerts-config': handleAlertsConfigEvent,
}, },
}); });
} }
@@ -2591,6 +2650,240 @@ function initTheme() {
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 ───────────────────────────────────────────────────────────── */ /* ── boot ───────────────────────────────────────────────────────────── */
async function boot() { async function boot() {
+143
View File
@@ -36,6 +36,7 @@ button { font-family: inherit; }
/* ── topbar ────────────────────────────────────────────────────────── */ /* ── topbar ────────────────────────────────────────────────────────── */
#topbar { #topbar {
position: relative; /* the alert banner centres itself against the bar */
flex: none; flex: none;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -135,6 +136,110 @@ body.menu-hidden #topbar {
color: var(--accent-text); 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 menus ────────────────────────────────────────────────── */
.dropdown { position: relative; } .dropdown { position: relative; }
@@ -217,6 +322,43 @@ body.menu-hidden #topbar {
text-align: center; text-align: center;
font-variant-numeric: tabular-nums; 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 { .menu-title {
padding: 6px 10px 3px; padding: 6px 10px 3px;
color: var(--muted); color: var(--muted);
@@ -980,6 +1122,7 @@ body.fullscreen #grid { padding-bottom: 0; }
@media (max-width: 560px) { @media (max-width: 560px) {
#topbar { padding: 0 8px; gap: 6px; } #topbar { padding: 0 8px; gap: 6px; }
#alert-banner { font-size: 14px; gap: 6px; }
} }
/* ── themes ────────────────────────────────────────────────────────── */ /* ── themes ────────────────────────────────────────────────────────── */
+289 -2
View File
@@ -434,8 +434,14 @@ function mountModule(id) {
const mod = require(serverPath); const mod = require(serverPath);
const config = effectiveConfig(id); const config = effectiveConfig(id);
// What a module needs to reach the rest of ProdDash — another module's // What a module needs to reach the rest of ProdDash — another module's
// routes over loopback, chiefly — without guessing. // routes over loopback, chiefly — without guessing. alert() and
const shell = { port: PORT }; // clearAlert() flash the dashboards listening to a channel (see the
// alerts section); an alert this module raises ends when it stops.
const shell = {
port: PORT,
alert: (opts) => raiseAlert(opts, id),
clearAlert: (channel) => clearAlert(channel),
};
if (typeof mod.init === 'function') { if (typeof mod.init === 'function') {
entry.handle = mod.init({ config, log, shell }) || null; entry.handle = mod.init({ config, log, shell }) || null;
} }
@@ -460,6 +466,9 @@ function unmountModule(id) {
} catch (err) { } catch (err) {
console.error(`[proddash] module "${id}" failed to stop cleanly:`, err); console.error(`[proddash] module "${id}" failed to stop cleanly:`, err);
} }
// Its alerts go with it: a "held until cleared" alert from a module that
// is no longer running would otherwise flash for ever.
clearAlertsFrom(id);
mounted.delete(id); mounted.delete(id);
} }
@@ -816,6 +825,189 @@ function setTheme(id) {
return themeView(); return themeView();
} }
/* ── alerts ─────────────────────────────────────────────────────────── */
/* A module that notices something a tech must see now — a timer overrun, a
meter past its limit, a feed gone quiet — raises an alert, and every
dashboard subscribed to its channel flashes: the menu bar and a frame
round the screen alternate between two colours, with the alert's text
bold in the middle of the bar. Six channels for now, so a booth can send
the stage's alerts and the video world's to different screens. The
server is the hub: it holds what is active (one alert per channel — a
new one replaces the last), times them out, tells open dashboards over
the shell stream (`alert`, `alert-clear`), and answers GET /api/alerts
for a dashboard that opens or reconnects mid-alert. The level sets the
cadence (shell.js / style.css) and the two colours, chosen in Admin →
Alerts and kept as `alerts.colors` in this machine's proddash.json. */
const ALERT_LEVELS = ['low', 'medium', 'critical'];
const ALERT_CHANNELS = 6;
const ALERT_TEXT_MAX = 80;
const ALERT_DURATION_DEFAULT_S = 15;
const ALERT_DURATION_MAX_S = 600;
const ALERT_SOURCE_RE = /^[A-Za-z0-9_-]{1,40}$/;
const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
const DEFAULT_ALERT_COLORS = {
low: ['#1f6feb', '#0b2a5c'],
medium: ['#f0b429', '#6b4a0a'],
critical: ['#f4433c', '#1a0505'],
};
/** A full level → [colourA, colourB] set; anything missing or malformed takes the default. */
function normalizeAlertColors(raw) {
const out = {};
for (const level of ALERT_LEVELS) {
const pair = Array.isArray(raw?.[level]) ? raw[level] : [];
out[level] = DEFAULT_ALERT_COLORS[level].map((fallback, i) => {
const v = String(pair[i] ?? '').trim();
return HEX_COLOR_RE.test(v) ? v.toLowerCase() : fallback;
});
}
return out;
}
/** The channel list a dashboard listens to: whole numbers 1–6, each once, in order. */
function sanitizeAlertChannels(list) {
const out = new Set();
for (const v of Array.isArray(list) ? list : []) {
const n = Number(v);
if (Number.isInteger(n) && n >= 1 && n <= ALERT_CHANNELS) out.add(n);
}
return [...out].sort((a, b) => a - b);
}
let alertColors = normalizeAlertColors(shellConfig.alerts?.colors);
const activeAlerts = new Map(); // channel -> { id, channel, level, text, source, startedAt, duration, endsAt, timer }
let alertSeq = 0;
/** One alert as pages see it. `remainingMs` is what a browser counts down
from — its clock and the server's need not agree. */
function alertView(a) {
return {
id: a.id,
channel: a.channel,
level: a.level,
text: a.text,
source: a.source,
startedAt: a.startedAt,
duration: a.duration,
endsAt: a.endsAt,
remainingMs: a.endsAt ? Math.max(0, a.endsAt - Date.now()) : null,
};
}
/** What GET /api/alerts returns — public, since dashboards need it before anyone logs in. */
function alertsView() {
return {
channels: ALERT_CHANNELS,
levels: ALERT_LEVELS.slice(),
colors: normalizeAlertColors(alertColors),
active: [...activeAlerts.values()].sort((a, b) => a.channel - b.channel).map(alertView),
};
}
/**
* Raise an alert: check it, replace whatever its channel was showing, time
* it out, and tell every open dashboard. `source` is where it came from —
* a module id (that module's alerts end when it stops), or 'admin' / 'api'.
* Throws an httpError for bad input; a route answers 400 with its message,
* a module gets it as a plain exception.
*/
function raiseAlert(input, source) {
const opts = input && typeof input === 'object' ? input : {};
const channel = Number(opts.channel);
if (!Number.isInteger(channel) || channel < 1 || channel > ALERT_CHANNELS) {
throw httpError(400, `channel must be a whole number from 1 to ${ALERT_CHANNELS}.`);
}
const level = String(opts.level ?? '').toLowerCase();
if (!ALERT_LEVELS.includes(level)) {
throw httpError(400, `level must be one of ${ALERT_LEVELS.join(', ')}.`);
}
const text = String(opts.text ?? '')
.replace(/[\u0000-\u001f\u007f]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, ALERT_TEXT_MAX);
if (!text) throw httpError(400, 'text is required.');
let duration = opts.duration === undefined || opts.duration === null
? ALERT_DURATION_DEFAULT_S
: Number(opts.duration);
if (!Number.isFinite(duration) || duration < 0) {
throw httpError(400, `duration is seconds, 1 to ${ALERT_DURATION_MAX_S}, or 0 to hold the alert until it is cleared.`);
}
if (duration > 0) duration = Math.min(ALERT_DURATION_MAX_S, Math.max(1, duration));
const src = ALERT_SOURCE_RE.test(String(source || '')) ? String(source) : 'api';
const previous = activeAlerts.get(channel);
if (previous) clearTimeout(previous.timer);
const now = Date.now();
const alert = {
id: 'a' + (++alertSeq),
channel,
level,
text,
source: src,
startedAt: now,
duration,
endsAt: duration > 0 ? now + Math.round(duration * 1000) : null,
timer: null,
};
if (alert.endsAt) {
alert.timer = setTimeout(() => {
if (activeAlerts.get(channel) === alert) clearAlert(channel);
}, alert.endsAt - now);
alert.timer.unref?.();
}
activeAlerts.set(channel, alert);
broadcastShellEvent('alert', alertView(alert));
console.log(`[proddash] alert: ${level} on channel ${channel} from ${src} — "${text}"`
+ (alert.endsAt ? ` (${duration} s)` : ' (until cleared)'));
return alertView(alert);
}
/** End the alert on a channel — early, or because its time is up. True when there was one. */
function clearAlert(channel) {
const alert = activeAlerts.get(Number(channel));
if (!alert) return false;
clearTimeout(alert.timer);
activeAlerts.delete(alert.channel);
broadcastShellEvent('alert-clear', { channel: alert.channel, id: alert.id });
return true;
}
/** A module that stops takes its alerts with it — a held alert must never outlive what raised it. */
function clearAlertsFrom(source) {
for (const alert of [...activeAlerts.values()]) {
if (alert.source === source) clearAlert(alert.channel);
}
}
/**
* Persist the alert colours into this machine's proddash.json (every other
* key there is kept), remember them, and tell every open dashboard and
* admin page. Throws an httpError for an unreadable settings file.
*/
function setAlertColors(raw) {
const colors = normalizeAlertColors(raw);
const file = path.join(DATA_DIR, 'proddash.json');
let existing = {};
if (fs.existsSync(file)) {
existing = readJson(file, null);
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
throw httpError(500, `${file} is not a JSON object — fix it by hand before changing the alert colours.`);
}
}
const alerts = existing.alerts && typeof existing.alerts === 'object' && !Array.isArray(existing.alerts)
? existing.alerts
: {};
writeJson(file, { ...existing, alerts: { ...alerts, colors } });
alertColors = colors;
shellConfig.alerts = { ...(shellConfig.alerts || {}), colors };
broadcastShellEvent('alerts-config', { colors: normalizeAlertColors(colors) });
return normalizeAlertColors(colors);
}
/* ── the repo: module catalog and shell updates ──────────────────────── */ /* ── the repo: module catalog and shell updates ──────────────────────── */
/* The admin page installs modules from, and updates the shell against, the /* The admin page installs modules from, and updates the shell against, the
@@ -1734,9 +1926,91 @@ async function handleAdminApi(req, res, urlPath) {
} }
} }
/* — alert colours: { "colors": { "low": [a, b], "medium": [a, b], "critical": [a, b] } } — */
if (urlPath === '/api/admin/alerts' && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return;
let body = {};
try {
body = JSON.parse((await readBody(req)) || '{}');
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
try {
return sendJson(res, 200, { ok: true, colors: setAlertColors(body.colors) });
} catch (err) {
return sendJson(res, err.status || 500, { error: err.message || String(err) });
}
}
/* — a test flash, so the colours can be judged on a real dashboard:
{ "level": "critical", "channel": 1 } — */
if (urlPath === '/api/admin/alerts/test' && req.method === 'POST') {
if (refuseAdminWrite(req, res)) return;
let body = {};
try {
body = JSON.parse((await readBody(req)) || '{}');
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
try {
const level = String(body.level ?? '');
const alert = raiseAlert({
channel: body.channel,
level,
text: `Test — ${level} alert`,
duration: 8,
}, 'admin');
return sendJson(res, 200, { ok: true, alert });
} catch (err) {
return sendJson(res, err.status || 500, { error: err.message || String(err) });
}
}
sendJson(res, 404, { error: 'Unknown admin endpoint.' }); sendJson(res, 404, { error: 'Unknown admin endpoint.' });
} }
/* ── alerts API ─────────────────────────────────────────────────────── */
/* Open to every dashboard and to module servers over loopback, like a
module's own routes: an alert is a flash on screens in this building and
nothing more, so it is gated no harder than saving a layout. Requiring
application/json on the POST forces the CORS preflight a page on another
origin cannot pass. */
async function handleAlertsApi(req, res, urlPath) {
if (urlPath === '/api/alerts' && req.method === 'GET') {
return sendJson(res, 200, alertsView());
}
if (urlPath === '/api/alerts' && req.method === 'POST') {
const contentType = String(req.headers['content-type'] || '').toLowerCase();
if (!contentType.startsWith('application/json')) {
return sendJson(res, 415, { error: 'Send application/json.' });
}
let body = {};
try {
body = JSON.parse((await readBody(req)) || '{}');
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
try {
return sendJson(res, 200, { ok: true, alert: raiseAlert(body, body.source) });
} catch (err) {
return sendJson(res, err.status || 500, { error: err.message || String(err) });
}
}
const m = urlPath.match(/^\/api\/alerts\/(\d{1,2})$/);
if (m && req.method === 'DELETE') {
const channel = Number(m[1]);
if (channel < 1 || channel > ALERT_CHANNELS) {
return sendJson(res, 400, { error: `channel must be a whole number from 1 to ${ALERT_CHANNELS}.` });
}
return sendJson(res, 200, { ok: true, cleared: clearAlert(channel) });
}
sendJson(res, 404, { error: 'Unknown alerts endpoint.' });
}
/* ── named layouts (JSON files under <data dir>/layouts/) ───────────── */ /* ── named layouts (JSON files under <data dir>/layouts/) ───────────── */
/** Names double as file names — keep them boring on purpose. */ /** Names double as file names — keep them boring on purpose. */
@@ -1795,6 +2069,9 @@ function sanitizeLayout(raw) {
// The grid density the layout was arranged on, when it says and it is one // The grid density the layout was arranged on, when it says and it is one
// the shell knows; a browser loading it cuts its board the same way. // the shell knows; a browser loading it cuts its board the same way.
...(GRID_STEPS.includes(Number(raw?.gridCols)) ? { gridCols: Number(raw.gridCols) } : {}), ...(GRID_STEPS.includes(Number(raw?.gridCols)) ? { gridCols: Number(raw.gridCols) } : {}),
// Which alert channels the dashboard listens to — which screen flashes
// for what is part of how a booth is arranged, so it rides along too.
...(Array.isArray(raw?.alertChannels) ? { alertChannels: sanitizeAlertChannels(raw.alertChannels) } : {}),
tiles: tiles.map((t) => { tiles: tiles.map((t) => {
const tile = { const tile = {
id: String(t?.id || ''), id: String(t?.id || ''),
@@ -2093,6 +2370,16 @@ function handleRequest(req, res) {
return; return;
} }
/* — alerts: what is flashing, raising one, ending one — */
if (urlPath === '/api/alerts' || urlPath.startsWith('/api/alerts/')) {
handleAlertsApi(req, res, urlPath).catch((err) => {
console.error('[proddash] alerts API error:', err);
if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' });
else try { res.end(); } catch { /* gone */ }
});
return;
}
/* — named layouts — */ /* — named layouts — */
if (urlPath === '/api/layouts' || urlPath.startsWith('/api/layouts/')) { if (urlPath === '/api/layouts' || urlPath.startsWith('/api/layouts/')) {
handleLayoutsApi(req, res, urlPath).catch((err) => { handleLayoutsApi(req, res, urlPath).catch((err) => {
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env node
'use strict';
/**
* Raise, clear or list ProdDash alerts from the command line — for trying a
* dashboard's channels and the admin colours, or from a script on the
* machine running ProdDash (anything that can POST JSON can raise one).
*
* node tools/alert.js "Service over by 5:00" # low, channel 1, 15 s
* node tools/alert.js -c 2 -l critical -d 0 "LTC lost" # held until cleared
* node tools/alert.js -c 2 --clear
* node tools/alert.js --list
*
* Options: -c/--channel 1–6 (default 1), -l/--level low|medium|critical
* (default low), -d/--duration seconds, 0 to hold until cleared (default the
* server's 15), --url http://host:port (default http://127.0.0.1:24500, or
* $PRODDASH_URL), --clear, --list. Node 18+, no dependencies.
*/
const args = process.argv.slice(2);
const opts = { channel: 1, level: 'low', duration: undefined, url: process.env.PRODDASH_URL || 'http://127.0.0.1:24500', clear: false, list: false };
const words = [];
for (let i = 0; i < args.length; i += 1) {
const a = args[i];
const next = () => args[++i];
if (a === '-c' || a === '--channel') opts.channel = Number(next());
else if (a === '-l' || a === '--level') opts.level = String(next() || '');
else if (a === '-d' || a === '--duration') opts.duration = Number(next());
else if (a === '--url') opts.url = String(next() || opts.url);
else if (a === '--clear') opts.clear = true;
else if (a === '--list') opts.list = true;
else if (a === '-h' || a === '--help') {
console.log('usage: node tools/alert.js [-c 1-6] [-l low|medium|critical] [-d seconds] [--url http://host:port] "text"\n'
+ ' node tools/alert.js -c <n> --clear\n'
+ ' node tools/alert.js --list');
process.exit(0);
} else words.push(a);
}
const base = opts.url.replace(/\/+$/, '');
async function call(path, init) {
const res = await fetch(base + path, init);
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
return body;
}
(async () => {
if (opts.list) {
const body = await call('/api/alerts');
const active = body.active || [];
console.log(`${active.length} active alert${active.length === 1 ? '' : 's'}; colours:`,
Object.entries(body.colors || {}).map(([k, v]) => `${k} ${v.join('→')}`).join(', '));
for (const a of active) {
console.log(` channel ${a.channel} ${a.level.padEnd(8)} "${a.text}" from ${a.source}`
+ (a.remainingMs === null ? ' (until cleared)' : ` (${Math.ceil(a.remainingMs / 1000)} s left)`));
}
return;
}
if (opts.clear) {
const body = await call(`/api/alerts/${opts.channel}`, { method: 'DELETE' });
console.log(body.cleared ? `cleared channel ${opts.channel}` : `nothing was on channel ${opts.channel}`);
return;
}
const text = words.join(' ').trim();
if (!text) {
console.error('say what happened: node tools/alert.js -c 1 -l critical "LTC lost"');
process.exit(2);
}
const payload = { channel: opts.channel, level: opts.level, text, source: 'cli' };
if (opts.duration !== undefined) payload.duration = opts.duration;
const body = await call('/api/alerts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const a = body.alert;
console.log(`raised ${a.level} on channel ${a.channel}: "${a.text}"`
+ (a.remainingMs === null ? ' (until cleared)' : ` (${a.duration} s)`));
})().catch((err) => {
console.error(err.message || String(err));
process.exit(1);
});
+277
View File
@@ -0,0 +1,277 @@
'use strict';
/**
* Drives the shell's alerts through its HTTP API and shell stream: the
* defaults, every refusal, raise → `alert` frame → timeout → `alert-clear`
* frame, replacement on a channel, an early clear, text tidying, the
* channel list riding along in a saved layout, the admin colour save (and
* its `alerts-config` frame, and what lands in proddash.json), and the admin
* test flash.
*
* node tools/alerts.test.js
*
* Plain Node, no dependencies. Starts server.js on a spare port with a
* temporary data directory, talks to it over HTTP, exits non-zero on the
* first failure and always stops the server it started.
*/
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const fs = require('node:fs');
const http = require('node:http');
const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
function freePort() {
return new Promise((resolve, reject) => {
const probe = net.createServer();
probe.listen(0, '127.0.0.1', () => {
const { port } = probe.address();
probe.close(() => resolve(port));
});
probe.on('error', reject);
});
}
async function waitFor(url, ms = 15000) {
const deadline = Date.now() + ms;
let last = '';
while (Date.now() < deadline) {
try {
const r = await fetch(url);
if (r.ok) return;
last = 'HTTP ' + r.status;
} catch (err) {
last = err.message;
}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error('server did not come up: ' + last);
}
/** A bare SSE reader for /api/events: collects { event, data } frames and waits for one. */
function openEvents(url) {
const frames = [];
const waiters = [];
const req = http.get(url, { headers: { accept: 'text/event-stream' } }, (res) => {
let buf = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
buf += chunk;
let i;
while ((i = buf.indexOf('\n\n')) >= 0) {
const block = buf.slice(0, i);
buf = buf.slice(i + 2);
let event = 'message';
const data = [];
for (const line of block.split('\n')) {
if (line.startsWith('event:')) event = line.slice(6).trim();
else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''));
}
if (!data.length) continue;
let parsed = null;
try { parsed = JSON.parse(data.join('\n')); } catch { parsed = data.join('\n'); }
const frame = { event, data: parsed };
frames.push(frame);
for (const w of [...waiters]) {
if (w.match(frame)) {
waiters.splice(waiters.indexOf(w), 1);
w.resolve(frame);
}
}
}
});
});
return {
frames,
/** The next frame (already seen or still to come) that `match` accepts. */
next(match, ms = 5000, label = 'frame') {
const seen = frames.find(match);
if (seen) {
frames.splice(frames.indexOf(seen), 1);
return Promise.resolve(seen);
}
return new Promise((resolve, reject) => {
const w = { match, resolve: (f) => { clearTimeout(timer); frames.splice(frames.indexOf(f), 1); resolve(f); } };
const timer = setTimeout(() => {
waiters.splice(waiters.indexOf(w), 1);
reject(new Error(`no ${label} within ${ms} ms`));
}, ms);
waiters.push(w);
});
},
close() { req.destroy(); },
};
}
(async () => {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-alerts-test-'));
const port = await freePort();
const child = spawn(process.execPath, ['server.js'], {
cwd: ROOT,
env: { ...process.env, PRODDASH_DATA_DIR: dataDir, PORT: String(port), PRODDASH_NO_REMOTE_CHECK: '1', PRODDASH_LAUNCHER: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let log = '';
child.stdout.on('data', (d) => { log += d; });
child.stderr.on('data', (d) => { log += d; });
const base = `http://127.0.0.1:${port}`;
const api = async (p, init = {}) => {
const r = await fetch(base + p, { headers: { 'Content-Type': 'application/json' }, ...init });
return { status: r.status, body: await r.json().catch(() => ({})) };
};
const raise = (alert) => api('/api/alerts', { method: 'POST', body: JSON.stringify(alert) });
let events = null;
let failed = false;
try {
await waitFor(base + '/api/alerts');
events = openEvents(base + '/api/events');
// ── defaults ──
let r = await api('/api/alerts');
assert.equal(r.status, 200);
assert.equal(r.body.channels, 6, 'six channels');
assert.deepEqual(r.body.levels, ['low', 'medium', 'critical']);
assert.deepEqual(Object.keys(r.body.colors), ['low', 'medium', 'critical'], 'a pair per level');
for (const pair of Object.values(r.body.colors)) {
assert.equal(pair.length, 2);
for (const c of pair) assert.match(c, /^#[0-9a-f]{6}$/, 'colours are #rrggbb');
}
assert.deepEqual(r.body.active, [], 'nothing active on a fresh server');
const defaults = r.body.colors;
// ── refusals say why ──
r = await raise({ channel: 7, level: 'low', text: 'x' });
assert.equal(r.status, 400); assert.match(r.body.error, /channel/);
r = await raise({ channel: 1, level: 'high', text: 'x' });
assert.equal(r.status, 400); assert.match(r.body.error, /level/);
r = await raise({ channel: 1, level: 'low', text: ' ' });
assert.equal(r.status, 400); assert.match(r.body.error, /text/);
r = await raise({ channel: 1, level: 'low', text: 'x', duration: -1 });
assert.equal(r.status, 400); assert.match(r.body.error, /duration/);
r = await raise({ channel: 1, level: 'low', text: 'x', duration: 'soon' });
assert.equal(r.status, 400); assert.match(r.body.error, /duration/);
{
const plain = await fetch(base + '/api/alerts', { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: '{}' });
assert.equal(plain.status, 415, 'a POST that is not application/json is refused');
}
r = await fetch(base + '/api/alerts/9', { method: 'DELETE' }).then(async (x) => ({ status: x.status, body: await x.json() }));
assert.equal(r.status, 400, 'clearing a channel that does not exist is refused');
r = await api('/api/alerts');
assert.deepEqual(r.body.active, [], 'a refused alert is not half-raised');
// ── raise → frame → timeout → clear frame ──
r = await raise({ channel: 2, level: 'MEDIUM', text: 'Service over by 5:00', duration: 1, source: 'pco-plan' });
assert.equal(r.status, 200, 'raise: ' + JSON.stringify(r.body));
const a = r.body.alert;
assert.equal(a.channel, 2);
assert.equal(a.level, 'medium', 'level is case-insensitive');
assert.equal(a.text, 'Service over by 5:00');
assert.equal(a.source, 'pco-plan', 'the source is kept');
assert.equal(a.duration, 1);
assert.ok(a.remainingMs > 0 && a.remainingMs <= 1000, 'remainingMs counts down from the duration');
let frame = await events.next((f) => f.event === 'alert' && f.data.id === a.id, 5000, '`alert` frame');
assert.equal(frame.data.text, a.text, 'the frame carries the alert');
r = await api('/api/alerts');
assert.equal(r.body.active.length, 1, 'it is active');
assert.equal(r.body.active[0].id, a.id);
frame = await events.next((f) => f.event === 'alert-clear' && f.data.channel === 2, 5000, '`alert-clear` frame at timeout');
assert.equal(frame.data.id, a.id, 'the clear names the alert that ended');
r = await api('/api/alerts');
assert.deepEqual(r.body.active, [], 'gone once its time is up');
// ── defaults for duration; a held alert; replacement; early clear ──
r = await raise({ channel: 3, level: 'low', text: 'first' });
assert.equal(r.body.alert.duration, 15, '15 s when omitted');
r = await raise({ channel: 3, level: 'low', text: 'LTC lost', duration: 0 });
assert.equal(r.status, 200);
assert.equal(r.body.alert.endsAt, null, 'duration 0 holds the alert');
assert.equal(r.body.alert.remainingMs, null);
const held = r.body.alert;
r = await api('/api/alerts');
assert.equal(r.body.active.length, 1, 'one alert per channel — the second replaced the first');
assert.equal(r.body.active[0].id, held.id);
r = await raise({ channel: 3, level: 'critical', text: 'replaced', duration: 600 });
const replaced = r.body.alert;
assert.equal(replaced.duration, 600, 'the cap is 600 s');
r = await raise({ channel: 3, level: 'critical', text: 'capped', duration: 9999 });
assert.equal(r.body.alert.duration, 600, 'longer is clamped to the cap');
r = await api('/api/alerts');
assert.equal(r.body.active.length, 1);
assert.equal(r.body.active[0].text, 'capped');
r = await fetch(base + '/api/alerts/3', { method: 'DELETE' }).then(async (x) => ({ status: x.status, body: await x.json() }));
assert.equal(r.status, 200);
assert.equal(r.body.cleared, true, 'an early clear says it cleared something');
frame = await events.next((f) => f.event === 'alert-clear' && f.data.channel === 3, 5000, '`alert-clear` frame after DELETE');
r = await fetch(base + '/api/alerts/3', { method: 'DELETE' }).then(async (x) => ({ status: x.status, body: await x.json() }));
assert.equal(r.body.cleared, false, 'clearing an empty channel is honest about it');
r = await api('/api/alerts');
assert.deepEqual(r.body.active, []);
// ── text is tidied: control characters out, whitespace collapsed, 80 chars at most ──
r = await raise({ channel: 4, level: 'low', text: ' SPL\n\t 102 dB\u0007 — over ' + 'x'.repeat(100), duration: 60 });
assert.equal(r.status, 200);
assert.equal(r.body.alert.text.length, 80, 'cut at 80');
assert.ok(r.body.alert.text.startsWith('SPL 102 dB — over x'), 'whitespace collapsed, control characters gone');
assert.equal(r.body.alert.source, 'api', 'no source given → api');
r = await raise({ channel: 4, level: 'low', text: 'bad source', duration: 60, source: 'not a module id!' });
assert.equal(r.body.alert.source, 'api', 'a malformed source → api');
await fetch(base + '/api/alerts/4', { method: 'DELETE' });
// ── the channel list rides along in a saved layout ──
const tile = { id: 't1', module: 'clock', x: 0, y: 0, w: 2, h: 1, settings: {} };
r = await api('/api/layouts/Stage', { method: 'PUT', body: JSON.stringify({ layout: { tiles: [tile], alertChannels: [3, 1, '2', 1, 9, 'x'] } }) });
assert.equal(r.status, 200, 'save: ' + JSON.stringify(r.body));
r = await api('/api/layouts/Stage');
assert.deepEqual(r.body.layout.alertChannels, [1, 2, 3], 'channels come back whole, deduplicated, sorted, 1–6 only');
r = await api('/api/layouts/Plain', { method: 'PUT', body: JSON.stringify({ layout: { tiles: [tile] } }) });
r = await api('/api/layouts/Plain');
assert.equal('alertChannels' in r.body.layout, false, 'a layout saved without the list does not grow one');
// ── admin: colours (admin is open here — a fresh data directory has no PIN) ──
r = await api('/api/admin/alerts', {
method: 'POST',
body: JSON.stringify({ colors: { low: ['#112233', 'nonsense'], critical: ['#ABCDEF', '#000000'], bogus: ['#111111', '#222222'] } }),
});
assert.equal(r.status, 200, 'colour save: ' + JSON.stringify(r.body));
assert.deepEqual(r.body.colors.low, ['#112233', defaults.low[1]], 'a malformed colour takes the default');
assert.deepEqual(r.body.colors.medium, defaults.medium, 'a level left out keeps the default');
assert.deepEqual(r.body.colors.critical, ['#abcdef', '#000000'], 'colours are lower-cased');
assert.equal('bogus' in r.body.colors, false, 'an unknown level is dropped');
frame = await events.next((f) => f.event === 'alerts-config', 5000, '`alerts-config` frame');
assert.deepEqual(frame.data.colors, r.body.colors, 'open dashboards hear the new colours');
r = await api('/api/alerts');
assert.deepEqual(r.body.colors.low, ['#112233', defaults.low[1]], 'GET reflects the save');
const settings = JSON.parse(fs.readFileSync(path.join(dataDir, 'proddash.json'), 'utf8'));
assert.deepEqual(settings.alerts.colors.critical, ['#abcdef', '#000000'], 'saved under alerts.colors in proddash.json');
// ── admin: the test flash ──
r = await api('/api/admin/alerts/test', { method: 'POST', body: JSON.stringify({ level: 'critical', channel: 5 }) });
assert.equal(r.status, 200, 'test: ' + JSON.stringify(r.body));
assert.equal(r.body.alert.channel, 5);
assert.equal(r.body.alert.level, 'critical');
assert.equal(r.body.alert.source, 'admin');
assert.match(r.body.alert.text, /^Test — critical alert$/);
assert.equal(r.body.alert.duration, 8, 'a test flash is short');
r = await api('/api/admin/alerts/test', { method: 'POST', body: JSON.stringify({ level: 'nope', channel: 5 }) });
assert.equal(r.status, 400, 'a bad test level is refused like any other');
await fetch(base + '/api/alerts/5', { method: 'DELETE' });
console.log('alerts: every check passed');
} catch (err) {
failed = true;
console.error('FAIL:', err.message);
console.error('--- last of the server log ---\n' + log.slice(-2000));
} finally {
try { events?.close(); } catch { /* already closed */ }
child.kill('SIGTERM');
await new Promise((r) => setTimeout(r, 400));
fs.rmSync(dataDir, { recursive: true, force: true });
}
process.exit(failed ? 1 : 0);
})();