Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
459d8b9c38 | ||
|
|
6b51044be6 | ||
|
|
c9e03398a6 | ||
|
|
7bb1131378 | ||
|
|
f9cd292693 | ||
|
|
5ca0a672fa | ||
|
|
a3500f98b1 |
@@ -52,6 +52,7 @@ title-bar controls). The rules that matter most:
|
|||||||
`node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`).
|
`node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`).
|
||||||
`node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`).
|
`node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`).
|
||||||
`node tools/ableset-mock.js 39071` (AbleSet over OSC/UDP; point the AbleSet module at `127.0.0.1:39071`; `--stopped`, `--no-live`, `--speed 4`).
|
`node tools/ableset-mock.js 39071` (AbleSet over OSC/UDP; point the AbleSet module at `127.0.0.1:39071`; `--stopped`, `--no-live`, `--speed 4`).
|
||||||
|
`node tools/overflow-tap-mock.js` (ProPresenter slide notes on `127.0.0.1:1602` + the Overflow Dashboard API on `127.0.0.1:24720`; run ProdDash with `OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720`, any email/password signs in; `--fail-switch`, `--expire=20`, `--login=<email>:<password>`).
|
||||||
|
|
||||||
## Reference code (read-only forks — don't develop these here)
|
## Reference code (read-only forks — don't develop these here)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -375,10 +403,23 @@ below. Nothing is installed without someone clicking.
|
|||||||
and part, as in `260927 Where is Your Faith? - Part 6` — is taken as the
|
and part, as in `260927 Where is Your Faith? - Part 6` — is taken as the
|
||||||
message and lands on the plan's message item (an item titled Message,
|
message and lands on the plan's message item (an item titled Message,
|
||||||
Sermon or Teaching, else the first item under such a header). Admin
|
Sermon or Teaching, else the first item under such a header). Admin
|
||||||
config is the connection: Planning
|
config is the connection: Planning Center credentials (Personal Access
|
||||||
Center credentials (Personal Access Token app ID + secret, or an OAuth
|
Token app ID + secret, or an OAuth token), the service types to follow (a
|
||||||
token), the service type (a list grouped by your Planning Center folders)
|
list grouped by your Planning Center folders) and an optional pinned plan.
|
||||||
and an optional pinned plan. Everything about the display is per tile
|
**Tick as many service types as you like** — the module follows whichever
|
||||||
|
of them has a plan soonest, so a campus running Sunday mornings, a
|
||||||
|
Wednesday night and a youth service needs one dashboard, not three. The
|
||||||
|
ticked types are **numbered in priority order**, nudged up and down with
|
||||||
|
the arrows beside them, and that order decides the winner when two of them
|
||||||
|
have a plan at the same date and time. Every tile's title bar then carries
|
||||||
|
a **service-type control** for reading one of the others: pick a type and
|
||||||
|
every PCO Plan tile on *that* browser's dashboard — plan, teams and song
|
||||||
|
key together — moves to its plan, marked with an amber stripe, while the
|
||||||
|
machine goes on following the live one. The choice is that browser's
|
||||||
|
alone, it sticks until changed, and the control hides itself when only one
|
||||||
|
service type is ticked. Live machinery belongs to the followed type:
|
||||||
|
another type's plan shows its running order without the now marker, the
|
||||||
|
service clock or AbleSet. Everything about the display is per tile
|
||||||
(gear menu): the top-bar text (placeholders such as
|
(gear menu): the top-bar text (placeholders such as
|
||||||
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a
|
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a
|
||||||
show/hide checklist for every detail.
|
show/hide checklist for every detail.
|
||||||
@@ -469,6 +510,27 @@ below. Nothing is installed without someone clicking.
|
|||||||
read from the account), and a folded **Timing** group for the poll
|
read from the account), and a folded **Timing** group for the poll
|
||||||
intervals and a position trim.
|
intervals and a position trim.
|
||||||
Tile settings: cue name, previous cue, playhead, cue list.
|
Tile settings: cue name, previous cue, playhead, cue list.
|
||||||
|
- **Overflow Tap** — what Overflow's own "ProPresenter Integration" helper
|
||||||
|
app does, inside ProdDash, speaking the same Dashboard API it does: the
|
||||||
|
live slide's notes are read from ProPresenter (the presentation layer and
|
||||||
|
the announcement layer), and when they carry one of the keywords mapped in
|
||||||
|
the Overflow Dashboard, Overflow is told — and points that keyword's Tap
|
||||||
|
groups at its destination, so a tap on a disc follows what is on screen.
|
||||||
|
A slide with no keyword changes nothing. Keywords match as the helper
|
||||||
|
matches them: as a whole word of the notes, any case, punctuation around
|
||||||
|
it ignored (`give` fires on "Give!" and not on "forgiveness"). The tile is
|
||||||
|
one keyword, big: what Tap is on (and since when), its destination and the
|
||||||
|
groups moved, a chip saying whether the slide on screen carries a keyword,
|
||||||
|
and the last four switches with their times; a failed switch turns the
|
||||||
|
tile red with the reason, is retried, and — if switched on in admin —
|
||||||
|
flashes an alert channel. Admin config: the ProPresenter endpoint and an
|
||||||
|
**Overflow Dashboard login** (email and password; Google sign-in has no
|
||||||
|
password, so set one with "Forgot Your Password?"). The keywords, their
|
||||||
|
destinations and their Tap groups stay where Overflow keeps them —
|
||||||
|
Integrations › ProPresenter › Keyword Mappings in the Dashboard — and are
|
||||||
|
read from there on start, every few minutes, and on the tile's refresh
|
||||||
|
button. Tile settings are four switches: destination, groups, slide
|
||||||
|
match, history.
|
||||||
- **Band Lineup** — the backstage stage board: everyone on stage in their mic
|
- **Band Lineup** — the backstage stage board: everyone on stage in their mic
|
||||||
slot with photo, name and mic/pack label, on one shared board that every
|
slot with photo, name and mic/pack label, on one shared board that every
|
||||||
screen sees the same way — whoever edits it changes it everywhere, over SSE.
|
screen sees the same way — whoever edits it changes it everywhere, over SSE.
|
||||||
@@ -519,8 +581,15 @@ timers + timecode on `127.0.0.1:1600`), `node tools/pco-mock.js`
|
|||||||
run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`) and
|
run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`) and
|
||||||
`node tools/pco-band-mock.js 24710` (Planning Center for the Band Lineup
|
`node tools/pco-band-mock.js 24710` (Planning Center for the Band Lineup
|
||||||
board — a plan, its team members, birthdays and photos — run ProdDash with
|
board — a plan, its team members, birthdays and photos — run ProdDash with
|
||||||
`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). See the testing section of
|
`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`) and
|
||||||
the [module guide](docs/MODULE-GUIDE.md).
|
`node tools/overflow-tap-mock.js` (a ProPresenter whose slide notes carry
|
||||||
|
keywords on `127.0.0.1:1602`, announcement layer included, plus the Overflow
|
||||||
|
Dashboard API on `127.0.0.1:24720` — run ProdDash with
|
||||||
|
`OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720` and sign the module in with
|
||||||
|
any email and password; `--fail-switch` refuses every switch,
|
||||||
|
`--expire=20` makes sessions expire, `--login=<email>:<password>` accepts
|
||||||
|
one login only). See the testing section of the
|
||||||
|
[module guide](docs/MODULE-GUIDE.md).
|
||||||
|
|
||||||
## Install as a desktop web app
|
## Install as a desktop web app
|
||||||
|
|
||||||
@@ -553,7 +622,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
@@ -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.
|
||||||
+92
-1
@@ -222,7 +222,9 @@ Both schemas map field names to specs:
|
|||||||
"help": "One line shown under the control", // optional, admin config only
|
"help": "One line shown under the control", // optional, admin config only
|
||||||
"options": [ { "value": "a", "label": "A", "group": "Optgroup" }, … ], // "select" only
|
"options": [ { "value": "a", "label": "A", "group": "Optgroup" }, … ], // "select" only
|
||||||
"optionsRoute": "/devices", // "select" only, admin config only
|
"optionsRoute": "/devices", // "select" only, admin config only
|
||||||
"optionsDependsOn": "<other key>" // with optionsRoute; see below
|
"optionsDependsOn": "<other key>", // with optionsRoute; see below
|
||||||
|
"orderable": true, // "multiselect", admin config only
|
||||||
|
"addLabel": "Add a service type…" // with "orderable"; the dropdown's own line
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -242,8 +244,22 @@ marked, so nothing is dropped behind anyone's back. In a tile's gear popover
|
|||||||
it also takes `columns` (2–4) and `allByDefault: true`: a tile that has never
|
it also takes `columns` (2–4) and `allByDefault: true`: a tile that has never
|
||||||
chosen shows every option on and saves nothing until someone flips one, so
|
chosen shows every option on and saves nothing until someone flips one, so
|
||||||
options that turn up later are on as well (give it no `default` then).
|
options that turn up later are on as well (give it no `default` then).
|
||||||
|
A `multiselect` with **`orderable: true`** stops being a ticklist. It renders
|
||||||
|
as a list built on purpose — the picks, numbered in their saved order, each
|
||||||
|
with a nudge either way and a remove — over a dropdown (`addLabel` names it)
|
||||||
|
holding everything not picked yet, `group` and all. The array is stored in
|
||||||
|
that order, so a module can read it as a priority. It is for a setting whose
|
||||||
|
ORDER means something, and it is also what to reach for when the list is long:
|
||||||
|
an account with forty service types is forty switches to read past, but one
|
||||||
|
dropdown and two picked rows. `pco-plan`'s service types are the worked
|
||||||
|
example — several are followed, and when two of them have a plan at the same
|
||||||
|
moment the one higher up wins. Admin config only; the tile gear's own
|
||||||
|
multiselect ignores it.
|
||||||
|
|
||||||
`select` and `multiselect` may use `optionsRoute` in `instanceSchema` too —
|
`select` and `multiselect` may use `optionsRoute` in `instanceSchema` too —
|
||||||
the popover GETs `/api/modules/<id><optionsRoute>` like the admin page does.
|
the popover GETs `/api/modules/<id><optionsRoute>` like the admin page does.
|
||||||
|
`optionsDependsOn` follows any field, not just a select: a multiselect hands
|
||||||
|
its values over comma-joined.
|
||||||
|
|
||||||
`template` (tile settings) is a text with placeholders, built by dragging or
|
`template` (tile settings) is a text with placeholders, built by dragging or
|
||||||
clicking the variables the module offers into it: `"variables": [ { "value":
|
clicking the variables the module offers into it: `"variables": [ { "value":
|
||||||
@@ -437,6 +453,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 +618,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 +725,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 +1048,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):
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
/* Overflow Tap — the tile.
|
||||||
|
*
|
||||||
|
* One fact matters across a booth: which keyword Tap is on right now, and
|
||||||
|
* whether the switch actually landed. That is the big line. Under it, the
|
||||||
|
* destination it maps to and the groups it moved; a small line says whether
|
||||||
|
* the slide on screen carries a keyword at all; and the last four switches
|
||||||
|
* sit at the bottom with their times, so a tech can see the service's trail.
|
||||||
|
*
|
||||||
|
* Everything comes from the server's `state` stream. The tile never talks to
|
||||||
|
* ProPresenter or Overflow itself.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const HISTORY_SHOWN = 4;
|
||||||
|
|
||||||
|
const ICON_REFRESH = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9"/><path d="M13.5 2.5v3h-3"/></svg>';
|
||||||
|
|
||||||
|
export default function create({ root, moduleApi }) {
|
||||||
|
let state = null;
|
||||||
|
let stream = null;
|
||||||
|
let refreshBtn = null;
|
||||||
|
let streamDown = false; // the shared ProdDash stream dropped; cleared by the next state
|
||||||
|
|
||||||
|
const settings = () => moduleApi.instanceSettings || {};
|
||||||
|
const on = (key) => settings()[key] !== false;
|
||||||
|
|
||||||
|
function el(tag, className, text) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (className) node.className = className;
|
||||||
|
if (text !== undefined && text !== null) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clock(ms) {
|
||||||
|
if (!ms) return '';
|
||||||
|
try {
|
||||||
|
return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one-line reason the tile cannot do its job, or '' when it can. */
|
||||||
|
function blocker() {
|
||||||
|
if (!state) return 'Waiting for ProdDash';
|
||||||
|
const pp = state.propresenter || {};
|
||||||
|
const of = state.overflow || {};
|
||||||
|
if (!pp.configured) return 'No ProPresenter host — set one in /admin';
|
||||||
|
if (!of.configured) return 'No Overflow login — add it in /admin';
|
||||||
|
if (of.reachable && !(state.keywords || []).length) return 'No keyword mappings in the Overflow Dashboard yet';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
root.textContent = '';
|
||||||
|
const box = el('div', 'ot-root');
|
||||||
|
root.append(box);
|
||||||
|
|
||||||
|
const stop = blocker();
|
||||||
|
if (stop) {
|
||||||
|
box.append(el('div', 'ot-empty', stop));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pp = state.propresenter || {};
|
||||||
|
const of = state.overflow || {};
|
||||||
|
const cur = state.current;
|
||||||
|
const offline = streamDown || !pp.reachable || !of.reachable;
|
||||||
|
|
||||||
|
if (offline) {
|
||||||
|
box.classList.add('is-offline');
|
||||||
|
const why = streamDown ? 'ProdDash connection lost'
|
||||||
|
: !of.reachable ? (of.error || 'Overflow unreachable')
|
||||||
|
: (pp.error ? `ProPresenter unreachable — ${pp.error}` : 'ProPresenter unreachable');
|
||||||
|
box.append(el('div', 'ot-offline', why));
|
||||||
|
}
|
||||||
|
|
||||||
|
const main = el('div', 'ot-main');
|
||||||
|
if (cur) {
|
||||||
|
// The label carries the state and, when there is one, the moment it
|
||||||
|
// became true; the small lines under the destination are the groups
|
||||||
|
// moved — or, when the switch failed, what went wrong, in their place
|
||||||
|
// (a tile has room for one or the other).
|
||||||
|
let label = `TAP IS ON · since ${clock(cur.at)}`;
|
||||||
|
let tone = 'ok';
|
||||||
|
let note = '';
|
||||||
|
if (cur.pending) {
|
||||||
|
label = cur.attempts > 1 ? `SWITCHING · attempt ${cur.attempts}` : 'SWITCHING';
|
||||||
|
tone = 'muted';
|
||||||
|
} else if (!cur.ok) {
|
||||||
|
label = `NOT SWITCHED · ${clock(cur.at)}`;
|
||||||
|
tone = 'alert';
|
||||||
|
note = cur.error || '';
|
||||||
|
}
|
||||||
|
main.dataset.tone = tone;
|
||||||
|
main.append(el('div', 'ot-label', label));
|
||||||
|
main.append(el('div', 'ot-keyword', cur.keyword));
|
||||||
|
if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination || ''));
|
||||||
|
if (note) main.append(el('div', 'ot-error', note));
|
||||||
|
else if (on('showGroups') && cur.groups && cur.groups.length) main.append(el('div', 'ot-groups', cur.groups.join(' · ')));
|
||||||
|
} else {
|
||||||
|
main.dataset.tone = 'muted';
|
||||||
|
main.append(el('div', 'ot-label', 'NO KEYWORD YET'));
|
||||||
|
main.append(el('div', 'ot-keyword is-none', '—'));
|
||||||
|
main.append(el('div', 'ot-dest', 'Tap stays where Overflow left it'));
|
||||||
|
}
|
||||||
|
box.append(main);
|
||||||
|
|
||||||
|
if (on('showSlide')) {
|
||||||
|
const slide = state.slide || {};
|
||||||
|
const ann = state.announcement || {};
|
||||||
|
const slideKeys = Array.isArray(slide.keywords) ? slide.keywords : [];
|
||||||
|
const annKeys = Array.isArray(ann.keywords) ? ann.keywords : [];
|
||||||
|
let text;
|
||||||
|
let cls = 'ot-slide';
|
||||||
|
if (!pp.reachable) { text = 'ProPresenter: not answering'; cls += ' is-muted'; }
|
||||||
|
else if (!slide.live && !ann.live) { text = 'Nothing live in ProPresenter'; cls += ' is-muted'; }
|
||||||
|
else if (slideKeys.length || annKeys.length) {
|
||||||
|
const parts = [];
|
||||||
|
if (slideKeys.length) parts.push(`This slide: ${slideKeys.join(' + ')}`);
|
||||||
|
if (annKeys.length) parts.push(`Announcement: ${annKeys.join(' + ')}`);
|
||||||
|
text = parts.join(' · ');
|
||||||
|
cls += ' is-hit';
|
||||||
|
} else if (slide.live) { text = slide.hasNotes ? 'This slide: no keyword' : 'This slide: no notes'; cls += ' is-muted'; }
|
||||||
|
else { text = 'Announcement: no keyword'; cls += ' is-muted'; }
|
||||||
|
box.append(el('div', cls, text));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (on('showHistory')) {
|
||||||
|
const rows = (state.history || []).slice(0, HISTORY_SHOWN);
|
||||||
|
const list = el('ol', 'ot-history');
|
||||||
|
if (!rows.length) {
|
||||||
|
list.append(el('li', 'ot-h-empty', cur ? 'No earlier switches' : 'No switches yet'));
|
||||||
|
}
|
||||||
|
for (const h of rows) {
|
||||||
|
const row = el('li', `ot-h-row${h.ok ? '' : ' is-failed'}`);
|
||||||
|
row.title = h.ok
|
||||||
|
? `${h.keyword} → ${h.destination || ''}${h.groups && h.groups.length ? ` on ${h.groups.join(', ')}` : ''}`
|
||||||
|
: `${h.keyword}: ${h.error || 'failed'}`;
|
||||||
|
row.append(el('span', 'ot-h-time', clock(h.at)));
|
||||||
|
row.append(el('span', 'ot-h-kw', h.keyword));
|
||||||
|
row.append(el('span', 'ot-h-dest', h.destination || ''));
|
||||||
|
row.append(el('span', 'ot-h-mark', h.ok ? '✓' : '✕'));
|
||||||
|
list.append(row);
|
||||||
|
}
|
||||||
|
box.append(list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyStatus() {
|
||||||
|
if (!state) return moduleApi.setStatus('connecting', 'Waiting for ProdDash');
|
||||||
|
const s = state.status === 'ok' || state.status === 'connecting' || state.status === 'error' ? state.status : 'connecting';
|
||||||
|
moduleApi.setStatus(s, state.message || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleState(e) {
|
||||||
|
try {
|
||||||
|
state = JSON.parse(e.data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
streamDown = false;
|
||||||
|
applyStatus();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function backfill() {
|
||||||
|
try {
|
||||||
|
const res = await moduleApi.fetch('/state');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const body = await res.json();
|
||||||
|
if (!body || !body.state || !stream) return;
|
||||||
|
state = body.state;
|
||||||
|
streamDown = false;
|
||||||
|
applyStatus();
|
||||||
|
render();
|
||||||
|
} catch {
|
||||||
|
/* the stream will bring the next state */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
if (!refreshBtn) return;
|
||||||
|
refreshBtn.classList.add('active');
|
||||||
|
try {
|
||||||
|
const res = await moduleApi.fetch('/refresh', { method: 'POST' });
|
||||||
|
if (!res.ok) {
|
||||||
|
let why = `Keyword refresh failed (${res.status})`;
|
||||||
|
try { why = (await res.json()).error || why; } catch { /* keep the status */ }
|
||||||
|
moduleApi.setStatus('error', why);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
moduleApi.setStatus('error', `Keyword refresh failed: ${err && err.message ? err.message : err}`);
|
||||||
|
} finally {
|
||||||
|
refreshBtn.classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
moduleApi.setStatus('connecting', 'Connecting');
|
||||||
|
refreshBtn = moduleApi.header.addButton({
|
||||||
|
icon: ICON_REFRESH,
|
||||||
|
title: 'Refresh keywords',
|
||||||
|
onClick: refresh,
|
||||||
|
});
|
||||||
|
stream = moduleApi.sse('/stream', {
|
||||||
|
// A fresh connection replays the state; a tile joining a stream the
|
||||||
|
// page already carries gets only `open`, and the server sends state
|
||||||
|
// on change alone — so backfill, or a second tile waits for the next
|
||||||
|
// switch to draw anything.
|
||||||
|
open() { backfill(); },
|
||||||
|
error() {
|
||||||
|
moduleApi.setStatus('error', 'Lost the connection to ProdDash');
|
||||||
|
streamDown = true;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
events: { state: handleState },
|
||||||
|
});
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (stream) stream.close();
|
||||||
|
stream = null;
|
||||||
|
refreshBtn = null;
|
||||||
|
root.textContent = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
onConfigChange() {
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyword matching — which of Overflow's keywords a slide's notes carry.
|
||||||
|
*
|
||||||
|
* Pure functions, no I/O, so keywords.selftest.js can pin every rule down:
|
||||||
|
*
|
||||||
|
* cleanNotes(raw) → the notes with ProPresenter's invisible padding removed
|
||||||
|
* keywordMatches(notes, keyword) → boolean
|
||||||
|
* matchKeywords(notes, keywords) → every keyword (in the order given) the notes carry
|
||||||
|
* sameSet(a, b) → the two keyword lists hold the same keywords
|
||||||
|
*
|
||||||
|
* The rule is Overflow's own helper's: the notes are split into whitespace-
|
||||||
|
* separated words, each stripped of surrounding punctuation and lower-cased,
|
||||||
|
* and a keyword fires when it is one of those words. So `give` fires on
|
||||||
|
* "give", "Give!", "#give" and "(give)", and not on "forgiveness", "giveaway"
|
||||||
|
* or "give_now". Phrases work too, bounded the same way on both ends —
|
||||||
|
* something the helper cannot do, but nothing it would have matched is lost.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ZERO_WIDTH = /[-]/g;
|
||||||
|
|
||||||
|
function cleanNotes(raw) {
|
||||||
|
return String(raw ?? '').replace(ZERO_WIDTH, '').replace(/\r\n?/g, '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function norm(s) {
|
||||||
|
return String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRe(s) {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function keywordMatches(notes, keyword) {
|
||||||
|
const n = cleanNotes(notes);
|
||||||
|
const k = norm(keyword);
|
||||||
|
if (!k || !n.trim()) return false;
|
||||||
|
const flat = n.toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
const re = new RegExp(`(?:^|[^\\p{L}\\p{N}_])${escapeRe(k)}(?=$|[^\\p{L}\\p{N}_])`, 'u');
|
||||||
|
return re.test(flat);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every keyword the notes carry, in the order the list gives them, each once. Entries may be strings or objects with a `keyword`. */
|
||||||
|
function matchKeywords(notes, keywords) {
|
||||||
|
const out = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const raw of keywords || []) {
|
||||||
|
const k = typeof raw === 'string' ? raw : raw && raw.keyword;
|
||||||
|
const key = norm(k);
|
||||||
|
if (!key || seen.has(key)) continue;
|
||||||
|
if (keywordMatches(notes, k)) {
|
||||||
|
seen.add(key);
|
||||||
|
out.push(String(k).trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameSet(a, b) {
|
||||||
|
const x = [...(a || [])].map(norm).sort();
|
||||||
|
const y = [...(b || [])].map(norm).sort();
|
||||||
|
return x.length === y.length && x.every((v, i) => v === y[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { cleanNotes, norm, keywordMatches, matchKeywords, sameSet };
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-test for keywords.js — the matching rules, pinned.
|
||||||
|
*
|
||||||
|
* node modules/overflow-tap/keywords.selftest.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { keywordMatches, matchKeywords, sameSet, cleanNotes } = require('./keywords');
|
||||||
|
|
||||||
|
let n = 0;
|
||||||
|
function test(name, fn) {
|
||||||
|
n += 1;
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
console.log(`ok ${name}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`FAIL ${name}\n ${err.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a keyword fires as a word of the notes, whatever the case', () => {
|
||||||
|
assert.equal(keywordMatches('give', 'give'), true);
|
||||||
|
assert.equal(keywordMatches('Give', 'give'), true);
|
||||||
|
assert.equal(keywordMatches('please GIVE today', 'give'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('surrounding punctuation is ignored, as the helper strips it', () => {
|
||||||
|
assert.equal(keywordMatches('give!', 'give'), true);
|
||||||
|
assert.equal(keywordMatches('#give now', 'give'), true);
|
||||||
|
assert.equal(keywordMatches('go to (give) now', 'give'), true);
|
||||||
|
assert.equal(keywordMatches('"give"', 'give'), true);
|
||||||
|
assert.equal(keywordMatches('give.', 'give'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a keyword inside another word does not fire', () => {
|
||||||
|
assert.equal(keywordMatches('a sermon on forgiveness', 'give'), false);
|
||||||
|
assert.equal(keywordMatches('giveaway', 'give'), false);
|
||||||
|
assert.equal(keywordMatches('give_now', 'give'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a phrase keyword is bounded the same way', () => {
|
||||||
|
assert.equal(keywordMatches('Please fill out your Next Steps card', 'next steps'), true);
|
||||||
|
assert.equal(keywordMatches('nextsteps', 'next steps'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regex characters in a keyword are literal', () => {
|
||||||
|
assert.equal(keywordMatches('give.now', 'give.now'), true);
|
||||||
|
assert.equal(keywordMatches('giveXnow', 'give.now'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty notes never match; zero-width padding is ignored', () => {
|
||||||
|
assert.equal(keywordMatches('', 'give'), false);
|
||||||
|
assert.equal(keywordMatches('', 'give'), false);
|
||||||
|
assert.equal(cleanNotes('give'), 'give');
|
||||||
|
assert.equal(keywordMatches('give', 'give'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matchKeywords returns every hit, in list order, once each', () => {
|
||||||
|
const list = ['connect', 'give', 'prayer', { keyword: 'Events' }];
|
||||||
|
assert.deepEqual(matchKeywords('give and connect, then events', list), ['connect', 'give', 'Events']);
|
||||||
|
assert.deepEqual(matchKeywords('just worship', list), []);
|
||||||
|
assert.deepEqual(matchKeywords('give', []), []);
|
||||||
|
assert.deepEqual(matchKeywords('give give', ['give', 'GIVE']), ['give']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sameSet compares keyword lists regardless of order and case', () => {
|
||||||
|
assert.equal(sameSet(['give', 'connect'], ['Connect', 'give']), true);
|
||||||
|
assert.equal(sameSet(['give'], ['give', 'connect']), false);
|
||||||
|
assert.equal(sameSet([], []), true);
|
||||||
|
assert.equal(sameSet(['give'], []), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`${n} tests, exit ${process.exitCode || 0}`);
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
{
|
||||||
|
"id": "overflow-tap",
|
||||||
|
"name": "Overflow Tap",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"proddash": ">=1.11.0",
|
||||||
|
"description": "Switches Overflow Tap destinations from keywords in ProPresenter slide notes",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"kind": "local-network",
|
||||||
|
"reason": "Reaching ProPresenter on this network"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"client": "client.js",
|
||||||
|
"server": "server.js",
|
||||||
|
"style": "style.css",
|
||||||
|
"minSize": {
|
||||||
|
"w": 2,
|
||||||
|
"h": 2
|
||||||
|
},
|
||||||
|
"defaultSize": {
|
||||||
|
"w": 4,
|
||||||
|
"h": 3
|
||||||
|
},
|
||||||
|
"configGroups": {
|
||||||
|
"ProPresenter": {
|
||||||
|
"help": "The ProPresenter whose slide notes carry the keywords. Enable the network in ProPresenter's Preferences › Network and enter the host and port shown there. Only the live slide's notes (and the announcement layer's) are read — nothing on the stage screens changes."
|
||||||
|
},
|
||||||
|
"Overflow": {
|
||||||
|
"collapsed": "whenSet",
|
||||||
|
"help": "An Overflow Dashboard login with Tap access — what Overflow's own ProPresenter helper app signs in with. Google sign-in does not work here: the account needs a password (Forgot Your Password? on the Dashboard sets one). The keywords, their destinations and their Tap groups are managed in the Dashboard under Integrations › ProPresenter › Keyword Mappings and read from there; after changing them, press the tile's refresh button or wait a few minutes."
|
||||||
|
},
|
||||||
|
"Alerts": {
|
||||||
|
"toggle": "alertEnabled",
|
||||||
|
"collapsed": true,
|
||||||
|
"help": "Flash the dashboards listening on this channel when a switch fails — Overflow unreachable or refusing the change — so someone can fix Tap by hand before the moment passes."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configSchema": {
|
||||||
|
"propresenter": {
|
||||||
|
"type": "endpoint",
|
||||||
|
"label": "ProPresenter",
|
||||||
|
"group": "ProPresenter",
|
||||||
|
"default": {
|
||||||
|
"host": "",
|
||||||
|
"port": 1025
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"type": "password",
|
||||||
|
"label": "API password",
|
||||||
|
"group": "ProPresenter",
|
||||||
|
"default": "",
|
||||||
|
"help": "Only if a password is set in ProPresenter's network preferences."
|
||||||
|
},
|
||||||
|
"overflowEmail": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "Email",
|
||||||
|
"group": "Overflow",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"overflowPassword": {
|
||||||
|
"type": "password",
|
||||||
|
"label": "Password",
|
||||||
|
"group": "Overflow",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"nonprofitId": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "Nonprofit ID",
|
||||||
|
"group": "Overflow",
|
||||||
|
"default": "",
|
||||||
|
"help": "Leave blank: a church login carries its own. Only an @overflow.co staff login needs one."
|
||||||
|
},
|
||||||
|
"alertEnabled": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Alert when a switch fails",
|
||||||
|
"group": "Alerts",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"alertChannel": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Channel",
|
||||||
|
"group": "Alerts",
|
||||||
|
"default": 1,
|
||||||
|
"min": 1,
|
||||||
|
"max": 6,
|
||||||
|
"help": "1–6; each dashboard chooses the channels it listens to in its Layouts menu."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"instanceSchema": {
|
||||||
|
"showDestination": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Destination",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"showGroups": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Groups",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"showSlide": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Slide match",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"showHistory": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "History",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overflow Dashboard API — the calls Overflow's own "ProPresenter Integration"
|
||||||
|
* helper app (1.2.3) makes, read from its bundle on 2026-10-11. None of this is
|
||||||
|
* in Overflow's public API reference (which documents only reads for Tap): it
|
||||||
|
* is the Dashboard's own backend, used with a Dashboard login — the same
|
||||||
|
* email and password the helper asks for. Google sign-in does not work here;
|
||||||
|
* the account needs a password (Overflow's article says the same).
|
||||||
|
*
|
||||||
|
* POST /nonprofit-dashboard/authentication/login/jwt
|
||||||
|
* { email, password } → { token } a JWT; its payload carries
|
||||||
|
* email, nonprofitId, permissions [{ action, subject }] (subject TAP
|
||||||
|
* or all is required), nonprofits [...], exp
|
||||||
|
* GET /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/keywords
|
||||||
|
* → [ { keyword, destinationName, tapGroups: [{ id, tapGroupName }], … } ]
|
||||||
|
* the Dashboard's Integrations › ProPresenter › Keyword Mappings
|
||||||
|
* POST /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/keywords?syncNonprofit=true
|
||||||
|
* { keywords: ["give"] } → the same list, updated THE SWITCH:
|
||||||
|
* Overflow points the keyword's groups at its destination
|
||||||
|
* GET /nonprofit-dashboard/propresenter/nonprofits?search=&limit=
|
||||||
|
* POST /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/select
|
||||||
|
* only for an @overflow.co staff login, which has no nonprofit of its
|
||||||
|
* own and must pick one first
|
||||||
|
*
|
||||||
|
* Bearer auth on everything after login. A 401 means the session is gone —
|
||||||
|
* sign in again. The helper's own wording for a refused login is kept here
|
||||||
|
* (401 invalid email or password, 429 too many attempts, 403 locked).
|
||||||
|
*
|
||||||
|
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the client at
|
||||||
|
* tools/overflow-tap-mock.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_API_BASE = 'https://server.overflow.co';
|
||||||
|
const API_BASE = String(process.env.OVERFLOW_TAP_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
||||||
|
const DASH = '/nonprofit-dashboard';
|
||||||
|
const TIMEOUT_MS = 10000;
|
||||||
|
const TRIGGER_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
|
class OverflowError extends Error {
|
||||||
|
constructor(message, { status = 0, retryable = false, auth = false } = {}) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'OverflowError';
|
||||||
|
this.status = status;
|
||||||
|
this.retryable = retryable;
|
||||||
|
this.auth = auth; // the session was refused: sign in again before retrying
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The JWT's payload, or null when the token is not one. No signature check — we only read our own session. */
|
||||||
|
function decodeJwt(token) {
|
||||||
|
const parts = String(token || '').split('.');
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
try {
|
||||||
|
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
return JSON.parse(Buffer.from(b64, 'base64').toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the module needs to know about a session, from its token. */
|
||||||
|
function describeSession(token) {
|
||||||
|
const p = decodeJwt(token) || {};
|
||||||
|
const email = String(p.email || '');
|
||||||
|
const perms = Array.isArray(p.permissions) ? p.permissions : [];
|
||||||
|
const nonprofits = Array.isArray(p.nonprofits)
|
||||||
|
? p.nonprofits.map((n) => ({ id: String((n && n.id) || ''), name: String((n && n.name) || '') })).filter((n) => n.id)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
email,
|
||||||
|
staff: email.toLowerCase().endsWith('@overflow.co'),
|
||||||
|
nonprofitId: p.nonprofitId ? String(p.nonprofitId) : '',
|
||||||
|
nonprofits,
|
||||||
|
hasTap: perms.some((x) => x && (x.subject === 'TAP' || x.subject === 'all')),
|
||||||
|
expiresAt: Number.isFinite(Number(p.exp)) ? Number(p.exp) * 1000 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOverflowClient({ fetchImpl = globalThis.fetch } = {}) {
|
||||||
|
async function request(method, path, { query, body, token, timeout = TIMEOUT_MS } = {}) {
|
||||||
|
const url = new URL(API_BASE + path);
|
||||||
|
for (const [k, v] of Object.entries(query || {})) {
|
||||||
|
if (v === undefined || v === null || v === '') continue;
|
||||||
|
url.searchParams.set(k, String(v));
|
||||||
|
}
|
||||||
|
const headers = { Accept: 'application/json' };
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||||
|
const ctl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctl.abort(), timeout);
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetchImpl(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: ctl.signal });
|
||||||
|
} catch (err) {
|
||||||
|
const aborted = err && err.name === 'AbortError';
|
||||||
|
const cause = err && err.cause && (err.cause.code || err.cause.message);
|
||||||
|
throw new OverflowError(
|
||||||
|
aborted ? `Overflow did not answer within ${timeout / 1000} s` : `Overflow unreachable: ${err.message}${cause ? ` (${cause})` : ''}`,
|
||||||
|
{ retryable: true },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
const text = await res.text();
|
||||||
|
let data = null;
|
||||||
|
if (text) {
|
||||||
|
try { data = JSON.parse(text); } catch { data = null; }
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = data && (data.message || data.error || data.detail);
|
||||||
|
const what = Array.isArray(detail) ? detail.join('; ') : (detail ? String(detail) : '');
|
||||||
|
throw new OverflowError(what ? `Overflow answered ${res.status}: ${what}` : `Overflow answered ${res.status} ${res.statusText}`.trim(), {
|
||||||
|
status: res.status,
|
||||||
|
auth: res.status === 401,
|
||||||
|
retryable: res.status === 429 || res.status >= 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
base: API_BASE,
|
||||||
|
|
||||||
|
/** Sign in with a Dashboard login; resolves to the JWT. */
|
||||||
|
async login(email, password) {
|
||||||
|
try {
|
||||||
|
const data = await request('POST', `${DASH}/authentication/login/jwt`, { body: { email, password } });
|
||||||
|
const token = data && data.token;
|
||||||
|
if (!token || typeof token !== 'string') throw new OverflowError('Overflow sign-in answered without a token');
|
||||||
|
return token;
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof OverflowError) || !err.status) throw err;
|
||||||
|
if (err.status === 401) throw new OverflowError('Overflow refused the sign-in: invalid email or password', { status: 401 });
|
||||||
|
if (err.status === 429) throw new OverflowError('Overflow refused the sign-in: too many attempts — wait a while', { status: 429, retryable: true });
|
||||||
|
if (err.status === 403) throw new OverflowError('Overflow refused the sign-in: account locked', { status: 403 });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** The Keyword Mappings for a nonprofit, as the Dashboard holds them. */
|
||||||
|
async listKeywords(token, nonprofitId) {
|
||||||
|
const data = await request('GET', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/keywords`, { token });
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Tell Overflow these keywords are on screen; it moves their groups. Resolves to the updated list when one comes back. */
|
||||||
|
async trigger(token, nonprofitId, keywords) {
|
||||||
|
const data = await request('POST', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/keywords`, {
|
||||||
|
query: { syncNonprofit: 'true' },
|
||||||
|
body: { keywords },
|
||||||
|
token,
|
||||||
|
timeout: TRIGGER_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
return Array.isArray(data) ? data : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Staff logins only: the nonprofits this login may act for. */
|
||||||
|
async searchNonprofits(token, search = '', limit = 10) {
|
||||||
|
const data = await request('GET', `${DASH}/propresenter/nonprofits`, { token, query: { search, limit } });
|
||||||
|
const list = (data && data.data && data.data.nonprofits) || (data && data.nonprofits) || (data && data.data) || data;
|
||||||
|
return Array.isArray(list)
|
||||||
|
? list.map((n) => ({ id: String((n && n.id) || ''), name: String((n && n.name) || ''), slug: String((n && n.slug) || '') }))
|
||||||
|
: [];
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Staff logins only: bind the session to one nonprofit before reading its keywords. */
|
||||||
|
selectNonprofit(token, nonprofitId) {
|
||||||
|
return request('POST', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/select`, { token, body: {} });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createOverflowClient, OverflowError, decodeJwt, describeSession, API_BASE, DEFAULT_API_BASE };
|
||||||
@@ -0,0 +1,648 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overflow Tap — server entry.
|
||||||
|
*
|
||||||
|
* Does what Overflow's "ProPresenter Integration" helper app does, inside
|
||||||
|
* ProdDash: watches the live slide in ProPresenter (the presentation layer
|
||||||
|
* and the announcement layer), and when its notes carry one of the keywords
|
||||||
|
* mapped in the Overflow Dashboard, tells Overflow — which points that
|
||||||
|
* keyword's Tap groups at its destination, so what people reach when they
|
||||||
|
* tap a disc follows what is on screen. A slide with no keyword changes
|
||||||
|
* nothing; the last destination stays until the next keyworded slide.
|
||||||
|
*
|
||||||
|
* GET /state → the view-model every tile draws
|
||||||
|
* GET /stream → SSE, `state` events on every change (heartbeated)
|
||||||
|
* POST /refresh → re-read the keyword mappings from the Dashboard now
|
||||||
|
* GET /keywords → the mappings as read, for a look while setting up
|
||||||
|
*
|
||||||
|
* ProPresenter is polled (GET /v1/status/slide for the presentation layer,
|
||||||
|
* GET /v1/announcement/slide_index for announcements, once a second each —
|
||||||
|
* small requests, nothing heavier) because its chunked update stream is the
|
||||||
|
* thing that goes quiet without saying so. Overflow is the Dashboard's own
|
||||||
|
* API, signed in with a Dashboard login — the calls, and where they come
|
||||||
|
* from, are at the top of overflow.js. Keyword mappings are read on start,
|
||||||
|
* every few minutes after that, on /refresh, and from every trigger's answer.
|
||||||
|
*
|
||||||
|
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the module at
|
||||||
|
* tools/overflow-tap-mock.js, which also plays a ProPresenter on :1602.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { createOverflowClient, OverflowError, describeSession } = require('./overflow');
|
||||||
|
const { matchKeywords, sameSet, cleanNotes, norm } = require('./keywords');
|
||||||
|
|
||||||
|
const POLL_MS = 1000; // ProPresenter, while it answers
|
||||||
|
const RETRY_MS = 3000; // ProPresenter, while it does not
|
||||||
|
const PP_TIMEOUT_MS = 4000;
|
||||||
|
const HEARTBEAT_MS = 15000;
|
||||||
|
const KEYWORDS_REFRESH_MS = 5 * 60 * 1000;
|
||||||
|
const OVERFLOW_RETRY_MS = 30 * 1000;
|
||||||
|
const LOGIN_MIN_GAP_MS = 20 * 1000; // a refused sign-in is not retried sooner (429 = "too many attempts")
|
||||||
|
const SESSION_MARGIN_MS = 60 * 1000; // sign in again this long before the token expires
|
||||||
|
const SWITCH_RETRY_MS = 10 * 1000;
|
||||||
|
const SWITCH_RETRIES = 3;
|
||||||
|
const HISTORY_MAX = 8; // the tile shows four; a few more for the hover
|
||||||
|
const ALERT_SECONDS = 30;
|
||||||
|
|
||||||
|
/** Set by init(), read by routes() — both are rebuilt together on a config save. */
|
||||||
|
let current = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The signed-in session, kept across re-inits for the same login: an admin
|
||||||
|
* saving config five times in a minute must not become five sign-ins
|
||||||
|
* (Overflow refuses that with 429). Keyed on a hash of the login, so a
|
||||||
|
* changed email or password starts fresh; the token itself never leaves the
|
||||||
|
* server process.
|
||||||
|
*/
|
||||||
|
let sessionCache = null; // { key, token, expiresAt, email, nonprofitId }
|
||||||
|
|
||||||
|
function json(res, code, body) {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line for a log or a tile; a fetch failure names its cause (ECONNREFUSED, ENOTFOUND…). */
|
||||||
|
function describe(err) {
|
||||||
|
if (err instanceof OverflowError) return err.message;
|
||||||
|
if (err && err.name === 'AbortError') return 'timed out';
|
||||||
|
const msg = err && err.message ? err.message : String(err);
|
||||||
|
const cause = err && err.cause && (err.cause.code || err.cause.message);
|
||||||
|
return cause && !msg.includes(String(cause)) ? `${msg} (${cause})` : msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unique(list) {
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
for (const v of list) {
|
||||||
|
const k = norm(v);
|
||||||
|
if (!k || seen.has(k)) continue;
|
||||||
|
seen.add(k);
|
||||||
|
out.push(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The destination and group names Overflow attached to a keyword mapping, whatever the field names turn out to be. */
|
||||||
|
function destinationOf(entry) {
|
||||||
|
return String((entry && (entry.destinationName || (entry.destination && entry.destination.name))) || '');
|
||||||
|
}
|
||||||
|
function groupsOf(entry) {
|
||||||
|
const list = entry && Array.isArray(entry.tapGroups) ? entry.tapGroups : [];
|
||||||
|
return list.map((g) => String((g && (g.tapGroupName || g.name || g.id)) || '')).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init({ config, log, shell }) {
|
||||||
|
/* ── config ────────────────────────────────────────────────────── */
|
||||||
|
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
|
||||||
|
const host = String(ep.host || '').trim();
|
||||||
|
const port = Number(ep.port) || 1025;
|
||||||
|
const ppPassword = String(config.password || '');
|
||||||
|
const email = String(config.overflowEmail || '').trim();
|
||||||
|
const ofPassword = String(config.overflowPassword || '');
|
||||||
|
const staffNonprofit = String(config.nonprofitId || '').trim();
|
||||||
|
const configured = Boolean(email && ofPassword);
|
||||||
|
const alertEnabled = Boolean(config.alertEnabled);
|
||||||
|
const alertChannel = Math.min(6, Math.max(1, Math.round(Number(config.alertChannel) || 1)));
|
||||||
|
const sessionKey = crypto.createHash('sha256').update(`${email}\n${ofPassword}`).digest('hex');
|
||||||
|
|
||||||
|
const overflow = createOverflowClient();
|
||||||
|
|
||||||
|
/* ── state ─────────────────────────────────────────────────────── */
|
||||||
|
const streams = new Set();
|
||||||
|
const timers = new Set();
|
||||||
|
let stopped = false;
|
||||||
|
let lastFrame = '';
|
||||||
|
|
||||||
|
let keywordCache = new Map(); // norm(keyword) → the Dashboard's entry
|
||||||
|
let session = null; // { token, expiresAt, email, nonprofitId }
|
||||||
|
let lastLoginAt = 0;
|
||||||
|
let lastLoginError = null;
|
||||||
|
let wanted = null; // the keywords Tap should be on: { keywords, keyword, destination, groups }
|
||||||
|
let inFlight = false;
|
||||||
|
let retriesLeft = 0;
|
||||||
|
let retryTimer = null;
|
||||||
|
let alertRaised = false;
|
||||||
|
|
||||||
|
// Presentation layer: the slide /v1/status/slide reports.
|
||||||
|
let lastSlideKey = null;
|
||||||
|
let lastSlide = null;
|
||||||
|
let lastPresKeywords = [];
|
||||||
|
// Announcement layer: the slide /v1/announcement/slide_index points at, read from /v1/announcement/active.
|
||||||
|
let annSupported = true;
|
||||||
|
let lastAnnKey = null;
|
||||||
|
let annNotes = '';
|
||||||
|
let annLive = false;
|
||||||
|
let lastAnnKeywords = [];
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
status: 'connecting',
|
||||||
|
message: '',
|
||||||
|
propresenter: { configured: Boolean(host), reachable: false, error: '', host, port },
|
||||||
|
overflow: { configured, reachable: false, error: '', keywords: 0, loadedAt: 0 },
|
||||||
|
keywords: [],
|
||||||
|
slide: { live: false, keywords: [], hasNotes: false },
|
||||||
|
announcement: { live: false, keywords: [] },
|
||||||
|
current: null,
|
||||||
|
history: [],
|
||||||
|
updatedAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── timers that die with the handle ───────────────────────────── */
|
||||||
|
|
||||||
|
function later(fn, ms) {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
timers.delete(t);
|
||||||
|
if (!stopped) fn();
|
||||||
|
}, ms);
|
||||||
|
t.unref?.();
|
||||||
|
timers.add(t);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── health, shared by the admin line and the tiles ────────────── */
|
||||||
|
|
||||||
|
function assess(forAdmin = false) {
|
||||||
|
const pp = state.propresenter;
|
||||||
|
const of = state.overflow;
|
||||||
|
if (!pp.configured) return ['error', 'No ProPresenter host configured — set one in /admin'];
|
||||||
|
if (!of.configured) return ['error', 'Overflow login not set — add the email and password in /admin'];
|
||||||
|
if (!of.reachable) return ['error', of.error || 'Signing in to Overflow…'];
|
||||||
|
if (!pp.reachable) return ['error', pp.error ? `ProPresenter unreachable: ${pp.error}` : 'Reaching ProPresenter…'];
|
||||||
|
if (!state.keywords.length) return ['error', 'No keyword mappings in the Overflow Dashboard (Integrations › ProPresenter)'];
|
||||||
|
if (state.current && !state.current.pending && !state.current.ok) {
|
||||||
|
return ['error', `Tap not switched to ${state.current.keyword} — ${state.current.error}`];
|
||||||
|
}
|
||||||
|
if (state.current && state.current.pending) return ['connecting', `Switching Tap to ${state.current.keyword}…`];
|
||||||
|
const n = state.keywords.length;
|
||||||
|
const who = forAdmin && session && session.email ? `${session.email}, ` : '';
|
||||||
|
return ['ok', `Following ${host}:${port} · Overflow: ${who}${n} keyword${n === 1 ? '' : 's'}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
function publish() {
|
||||||
|
if (stopped) return;
|
||||||
|
[state.status, state.message] = assess(false);
|
||||||
|
const { updatedAt, ...rest } = state;
|
||||||
|
let frame;
|
||||||
|
try {
|
||||||
|
frame = JSON.stringify(rest);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (frame === lastFrame) return;
|
||||||
|
lastFrame = frame;
|
||||||
|
state.updatedAt = Date.now();
|
||||||
|
const payload = `event: state\ndata: ${JSON.stringify(state)}\n\n`;
|
||||||
|
for (const res of streams) {
|
||||||
|
try {
|
||||||
|
res.write(payload);
|
||||||
|
} catch {
|
||||||
|
streams.delete(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the Overflow session ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
async function ensureSession() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (session && now < session.expiresAt - SESSION_MARGIN_MS) return session;
|
||||||
|
if (sessionCache && sessionCache.key === sessionKey && now < sessionCache.expiresAt - SESSION_MARGIN_MS) {
|
||||||
|
session = sessionCache;
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
if (lastLoginError && now - lastLoginAt < LOGIN_MIN_GAP_MS) throw lastLoginError;
|
||||||
|
lastLoginAt = now;
|
||||||
|
let token;
|
||||||
|
try {
|
||||||
|
token = await overflow.login(email, ofPassword);
|
||||||
|
} catch (err) {
|
||||||
|
lastLoginError = err;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
lastLoginError = null;
|
||||||
|
const info = describeSession(token);
|
||||||
|
if (!info.hasTap) throw new OverflowError('This Overflow login has no Tap permission — use one with the Organization Admin or Tap Only role');
|
||||||
|
let nonprofitId = info.nonprofitId || (info.nonprofits.length === 1 ? info.nonprofits[0].id : '');
|
||||||
|
if (info.staff) {
|
||||||
|
if (!staffNonprofit) throw new OverflowError('An @overflow.co login has no nonprofit of its own — enter the Nonprofit ID in /admin');
|
||||||
|
await overflow.selectNonprofit(token, staffNonprofit);
|
||||||
|
nonprofitId = staffNonprofit;
|
||||||
|
} else if (!nonprofitId) {
|
||||||
|
nonprofitId = staffNonprofit;
|
||||||
|
}
|
||||||
|
if (!nonprofitId) throw new OverflowError('This Overflow login carries no nonprofit — enter the Nonprofit ID in /admin');
|
||||||
|
session = { token, expiresAt: info.expiresAt || now + 12 * 3600 * 1000, email: info.email || email, nonprofitId };
|
||||||
|
sessionCache = { key: sessionKey, ...session };
|
||||||
|
log(`signed in to Overflow as ${session.email} (nonprofit ${nonprofitId})`);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropSession() {
|
||||||
|
session = null;
|
||||||
|
if (sessionCache && sessionCache.key === sessionKey) sessionCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run a signed-in call; when the session is refused, sign in again once and retry. */
|
||||||
|
async function withSession(fn) {
|
||||||
|
const s = await ensureSession();
|
||||||
|
try {
|
||||||
|
return await fn(s);
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof OverflowError) || !err.auth) throw err;
|
||||||
|
dropSession();
|
||||||
|
const again = await ensureSession();
|
||||||
|
return fn(again);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the keyword mappings ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
function rebuildKeywordView() {
|
||||||
|
state.keywords = [...keywordCache.values()].map((e) => ({
|
||||||
|
keyword: String(e.keyword).trim(),
|
||||||
|
destination: destinationOf(e),
|
||||||
|
groups: groupsOf(e),
|
||||||
|
}));
|
||||||
|
state.overflow.keywords = state.keywords.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace the cache with the Dashboard's list (`merge` folds a partial answer into it instead). */
|
||||||
|
function applyKeywordList(list, merge = false) {
|
||||||
|
const next = merge ? new Map(keywordCache) : new Map();
|
||||||
|
for (const entry of list || []) {
|
||||||
|
if (!entry || typeof entry !== 'object') continue;
|
||||||
|
const k = norm(entry.keyword);
|
||||||
|
if (!k) continue;
|
||||||
|
if (entry.active === false || entry.enabled === false || entry.isActive === false) {
|
||||||
|
next.delete(k);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next.set(k, entry);
|
||||||
|
}
|
||||||
|
keywordCache = next;
|
||||||
|
rebuildKeywordView();
|
||||||
|
}
|
||||||
|
|
||||||
|
let loading = null;
|
||||||
|
let refreshTimer = null;
|
||||||
|
|
||||||
|
async function loadKeywords() {
|
||||||
|
if (stopped || !configured) return;
|
||||||
|
if (loading) return loading;
|
||||||
|
loading = (async () => {
|
||||||
|
try {
|
||||||
|
const list = await withSession((s) => overflow.listKeywords(s.token, s.nonprofitId));
|
||||||
|
applyKeywordList(list);
|
||||||
|
state.overflow.reachable = true;
|
||||||
|
state.overflow.error = '';
|
||||||
|
state.overflow.loadedAt = Date.now();
|
||||||
|
reconsider(); // a slide already up when the mappings arrive takes effect now
|
||||||
|
} catch (err) {
|
||||||
|
state.overflow.reachable = false;
|
||||||
|
state.overflow.error = describe(err);
|
||||||
|
log(`Overflow: ${state.overflow.error}`);
|
||||||
|
}
|
||||||
|
publish();
|
||||||
|
})();
|
||||||
|
try {
|
||||||
|
await loading;
|
||||||
|
} finally {
|
||||||
|
loading = null;
|
||||||
|
}
|
||||||
|
if (stopped) return;
|
||||||
|
if (refreshTimer) {
|
||||||
|
clearTimeout(refreshTimer);
|
||||||
|
timers.delete(refreshTimer);
|
||||||
|
}
|
||||||
|
refreshTimer = later(loadKeywords, state.overflow.reachable ? KEYWORDS_REFRESH_MS : OVERFLOW_RETRY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the switch ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function describeTarget(keys) {
|
||||||
|
const entries = keys.map((k) => keywordCache.get(norm(k))).filter(Boolean);
|
||||||
|
return {
|
||||||
|
keywords: keys,
|
||||||
|
keyword: keys.join(' + '),
|
||||||
|
destination: unique(entries.map(destinationOf).filter(Boolean)).join(' + '),
|
||||||
|
groups: unique(entries.flatMap(groupsOf)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushHistory(entry) {
|
||||||
|
const { pending, ...rest } = entry;
|
||||||
|
state.history.unshift(rest);
|
||||||
|
if (state.history.length > HISTORY_MAX) state.history.length = HISTORY_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
function raiseAlert(keyword) {
|
||||||
|
if (!alertEnabled || !shell || typeof shell.alert !== 'function') return;
|
||||||
|
alertRaised = true;
|
||||||
|
Promise.resolve(shell.alert({ channel: alertChannel, level: 'medium', text: `Tap not switched: ${keyword}`, duration: ALERT_SECONDS }))
|
||||||
|
.catch((err) => log(`alert refused: ${describe(err)}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAlert() {
|
||||||
|
if (!alertRaised || !shell || typeof shell.clearAlert !== 'function') return;
|
||||||
|
alertRaised = false;
|
||||||
|
Promise.resolve(shell.clearAlert(alertChannel)).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestSwitch(target) {
|
||||||
|
wanted = target;
|
||||||
|
retriesLeft = SWITCH_RETRIES;
|
||||||
|
if (retryTimer) {
|
||||||
|
clearTimeout(retryTimer);
|
||||||
|
timers.delete(retryTimer);
|
||||||
|
retryTimer = null;
|
||||||
|
}
|
||||||
|
runSwitch();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSwitch() {
|
||||||
|
if (stopped || inFlight || !wanted) return;
|
||||||
|
const target = wanted;
|
||||||
|
const cur = state.current;
|
||||||
|
const sameTarget = cur && sameSet(cur.keywords, target.keywords);
|
||||||
|
if (sameTarget && cur.ok) return; // already there
|
||||||
|
inFlight = true;
|
||||||
|
let entry;
|
||||||
|
if (sameTarget) {
|
||||||
|
entry = cur; // another go at the same switch
|
||||||
|
} else {
|
||||||
|
if (cur) pushHistory(cur);
|
||||||
|
entry = {
|
||||||
|
keyword: target.keyword,
|
||||||
|
keywords: target.keywords,
|
||||||
|
destination: target.destination,
|
||||||
|
groups: target.groups,
|
||||||
|
at: Date.now(),
|
||||||
|
ok: false,
|
||||||
|
pending: true,
|
||||||
|
error: '',
|
||||||
|
attempts: 0,
|
||||||
|
};
|
||||||
|
state.current = entry;
|
||||||
|
}
|
||||||
|
entry.pending = true;
|
||||||
|
entry.attempts += 1;
|
||||||
|
publish();
|
||||||
|
try {
|
||||||
|
const answer = await withSession((s) => overflow.trigger(s.token, s.nonprofitId, target.keywords));
|
||||||
|
if (answer) {
|
||||||
|
applyKeywordList(answer, true);
|
||||||
|
const fresh = describeTarget(target.keywords);
|
||||||
|
entry.destination = fresh.destination || entry.destination;
|
||||||
|
entry.groups = fresh.groups.length ? fresh.groups : entry.groups;
|
||||||
|
}
|
||||||
|
entry.ok = true;
|
||||||
|
entry.error = '';
|
||||||
|
entry.at = Date.now();
|
||||||
|
clearAlert();
|
||||||
|
log(`Tap → ${entry.destination || '?'} (${target.keyword})${entry.groups.length ? ` on ${entry.groups.join(', ')}` : ''}`);
|
||||||
|
} catch (err) {
|
||||||
|
if (stopped) return;
|
||||||
|
entry.ok = false;
|
||||||
|
entry.error = describe(err);
|
||||||
|
log(`Tap not switched to ${target.keyword}: ${entry.error}`);
|
||||||
|
if (retriesLeft > 0) {
|
||||||
|
retriesLeft -= 1;
|
||||||
|
retryTimer = later(() => {
|
||||||
|
retryTimer = null;
|
||||||
|
runSwitch();
|
||||||
|
}, SWITCH_RETRY_MS);
|
||||||
|
}
|
||||||
|
raiseAlert(target.keyword);
|
||||||
|
} finally {
|
||||||
|
entry.pending = false;
|
||||||
|
inFlight = false;
|
||||||
|
publish();
|
||||||
|
if (!stopped && wanted !== target) runSwitch(); // a newer keyword arrived meanwhile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── what is on screen, and what to do about it ────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The helper's rule, per layer: when the set of keywords the layer's
|
||||||
|
* slide carries changes to a non-empty set, send it. Returning to a
|
||||||
|
* keyword after a slide without one sends it again; staying on the same
|
||||||
|
* keyword across slides sends nothing.
|
||||||
|
*/
|
||||||
|
function reconsider() {
|
||||||
|
const list = [...keywordCache.values()];
|
||||||
|
const presNotes = lastSlide ? cleanNotes(lastSlide.notes) : '';
|
||||||
|
const presKeys = lastSlide ? matchKeywords(presNotes, list) : [];
|
||||||
|
state.slide = { live: Boolean(lastSlide), keywords: presKeys, hasNotes: Boolean(presNotes.trim()) };
|
||||||
|
const presChanged = !sameSet(presKeys, lastPresKeywords);
|
||||||
|
if (presChanged) lastPresKeywords = presKeys;
|
||||||
|
|
||||||
|
const annKeys = annLive ? matchKeywords(annNotes, list) : [];
|
||||||
|
state.announcement = { live: annLive, keywords: annKeys };
|
||||||
|
const annChanged = !sameSet(annKeys, lastAnnKeywords);
|
||||||
|
if (annChanged) lastAnnKeywords = annKeys;
|
||||||
|
|
||||||
|
const batch = [];
|
||||||
|
if (presChanged && presKeys.length) batch.push(...presKeys);
|
||||||
|
if (annChanged && annKeys.length) batch.push(...annKeys);
|
||||||
|
if (batch.length) requestSwitch(describeTarget(unique(batch)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ProPresenter ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function ppUrl(target) {
|
||||||
|
const u = new URL(`http://${host}:${port}${target}`);
|
||||||
|
if (ppPassword) u.searchParams.set('password', ppPassword);
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ppJson(target) {
|
||||||
|
const ctl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctl.abort(), PP_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const res = await fetch(ppUrl(target), { headers: { Accept: 'application/json' }, signal: ctl.signal });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = new Error(`ProPresenter ${res.status} ${res.statusText}`);
|
||||||
|
err.status = res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const text = await res.text();
|
||||||
|
return text ? JSON.parse(text) : null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The presentation layer: /v1/status/slide names the live slide and carries its notes. */
|
||||||
|
async function pollSlide() {
|
||||||
|
const data = await ppJson('/v1/status/slide');
|
||||||
|
const cur = data && data.current && typeof data.current === 'object' ? data.current : null;
|
||||||
|
const key = cur ? `${cur.uuid || ''}\u0000${cur.text || ''}\u0000${cur.notes || ''}` : '';
|
||||||
|
if (key === lastSlideKey) return false;
|
||||||
|
lastSlideKey = key;
|
||||||
|
lastSlide = cur;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The announcement layer: its slide index names the document and the
|
||||||
|
* slide; the document's slides (every group's, in order, disabled ones
|
||||||
|
* included — that is the index's space) carry the notes. An older
|
||||||
|
* ProPresenter without this API answers 404 once and is left alone.
|
||||||
|
*/
|
||||||
|
async function pollAnnouncement() {
|
||||||
|
if (!annSupported) return false;
|
||||||
|
let idx;
|
||||||
|
try {
|
||||||
|
idx = await ppJson('/v1/announcement/slide_index');
|
||||||
|
} catch (err) {
|
||||||
|
if (err && err.status === 404) {
|
||||||
|
annSupported = false;
|
||||||
|
log('this ProPresenter has no announcement layer API — following the presentation layer only');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const node = idx && idx.announcement_index;
|
||||||
|
if (!node || !Number.isInteger(node.index)) {
|
||||||
|
if (lastAnnKey === '') return false;
|
||||||
|
lastAnnKey = '';
|
||||||
|
annLive = false;
|
||||||
|
annNotes = '';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const id = node.presentation_id || {};
|
||||||
|
const key = `${id.uuid || ''}\u0000${id.name || ''}\u0000${node.index}`;
|
||||||
|
if (key === lastAnnKey) return false;
|
||||||
|
const doc = await ppJson('/v1/announcement/active');
|
||||||
|
const ann = doc && doc.announcement;
|
||||||
|
const slides = [];
|
||||||
|
for (const g of (ann && Array.isArray(ann.groups) ? ann.groups : [])) {
|
||||||
|
for (const s of (g && Array.isArray(g.slides) ? g.slides : [])) slides.push(s);
|
||||||
|
}
|
||||||
|
const slide = slides[node.index];
|
||||||
|
annNotes = slide ? cleanNotes(slide.notes) : '';
|
||||||
|
annLive = true;
|
||||||
|
lastAnnKey = key;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollTick() {
|
||||||
|
if (stopped) return;
|
||||||
|
let ok = false;
|
||||||
|
let changed = false;
|
||||||
|
try {
|
||||||
|
changed = await pollSlide();
|
||||||
|
state.propresenter.reachable = true;
|
||||||
|
state.propresenter.error = '';
|
||||||
|
ok = true;
|
||||||
|
try {
|
||||||
|
if (await pollAnnouncement()) changed = true;
|
||||||
|
} catch {
|
||||||
|
/* the presentation poll already said ProPresenter answers; a hiccup here waits for the next tick */
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
state.propresenter.reachable = false;
|
||||||
|
state.propresenter.error = err && err.name === 'AbortError' ? 'timed out' : describe(err);
|
||||||
|
}
|
||||||
|
if (changed) reconsider();
|
||||||
|
publish();
|
||||||
|
later(pollTick, ok ? POLL_MS : RETRY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── go ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
if (host) {
|
||||||
|
pollTick();
|
||||||
|
log(`following ProPresenter at ${host}:${port}`);
|
||||||
|
} else {
|
||||||
|
log('no ProPresenter host configured — set one in /admin');
|
||||||
|
}
|
||||||
|
if (configured) {
|
||||||
|
loadKeywords();
|
||||||
|
log(`Overflow at ${overflow.base}`);
|
||||||
|
} else {
|
||||||
|
log('Overflow login not set — add the email and password in /admin');
|
||||||
|
}
|
||||||
|
publish();
|
||||||
|
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
for (const res of streams) {
|
||||||
|
try {
|
||||||
|
res.write(': ping\n\n');
|
||||||
|
} catch {
|
||||||
|
streams.delete(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_MS);
|
||||||
|
heartbeat.unref?.();
|
||||||
|
|
||||||
|
const handle = {
|
||||||
|
streams,
|
||||||
|
configured,
|
||||||
|
getState: () => state,
|
||||||
|
refresh: () => loadKeywords(),
|
||||||
|
};
|
||||||
|
current = handle;
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
for (const t of timers) clearTimeout(t);
|
||||||
|
timers.clear();
|
||||||
|
clearAlert();
|
||||||
|
for (const res of streams) {
|
||||||
|
try { res.end(); } catch { /* already gone */ }
|
||||||
|
}
|
||||||
|
streams.clear();
|
||||||
|
if (current === handle) current = null;
|
||||||
|
},
|
||||||
|
health() {
|
||||||
|
const [status, message] = assess(true);
|
||||||
|
return { status, message };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
routes() {
|
||||||
|
return {
|
||||||
|
'GET /state': (req, res) => {
|
||||||
|
if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' });
|
||||||
|
json(res, 200, { state: current.getState() });
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /stream': (req, res) => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
});
|
||||||
|
res.write('retry: 3000\n\n');
|
||||||
|
if (!current) return void res.end();
|
||||||
|
res.write(`event: state\ndata: ${JSON.stringify(current.getState())}\n\n`);
|
||||||
|
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||||
|
current.streams.add(res);
|
||||||
|
req.on('close', () => current && current.streams.delete(res));
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /refresh': async (req, res) => {
|
||||||
|
if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' });
|
||||||
|
if (!current.configured) return json(res, 400, { error: 'Overflow login is not set.' });
|
||||||
|
await current.refresh();
|
||||||
|
const st = current.getState();
|
||||||
|
if (!st.overflow.reachable) return json(res, 502, { error: st.overflow.error || 'Overflow unreachable.' });
|
||||||
|
json(res, 200, { ok: true, keywords: st.overflow.keywords });
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /keywords': (req, res) => {
|
||||||
|
if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' });
|
||||||
|
const st = current.getState();
|
||||||
|
json(res, 200, { keywords: st.keywords, loadedAt: st.overflow.loadedAt, error: st.overflow.error });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
/* Overflow Tap — scoped under its tile via [data-module="overflow-tap"] and
|
||||||
|
styled with the shell's variables only. Sizes hang off the tile (container
|
||||||
|
units) so the keyword reads from across a booth on a big tile and still
|
||||||
|
fits a 2×2. */
|
||||||
|
|
||||||
|
[data-module="overflow-tap"] {
|
||||||
|
container-type: size;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="overflow-tap"] .ot-root {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: clamp(4px, 4cqmin, 18px);
|
||||||
|
gap: clamp(2px, 1.8cqmin, 10px);
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text);
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="overflow-tap"] .ot-offline {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: clamp(0.6rem, 4.6cqmin, 0.9rem);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--danger);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
[data-module="overflow-tap"] .ot-root.is-offline .ot-main,
|
||||||
|
[data-module="overflow-tap"] .ot-root.is-offline .ot-slide,
|
||||||
|
[data-module="overflow-tap"] .ot-root.is-offline .ot-history {
|
||||||
|
opacity: 0.45;
|
||||||
|
filter: grayscale(0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the live keyword ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
[data-module="overflow-tap"] .ot-main {
|
||||||
|
--tone: var(--accent);
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
container-type: size;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
[data-module="overflow-tap"] .ot-main[data-tone="ok"] { --tone: var(--accent); }
|
||||||
|
[data-module="overflow-tap"] .ot-main[data-tone="warn"] { --tone: var(--warn); }
|
||||||
|
[data-module="overflow-tap"] .ot-main[data-tone="alert"] { --tone: var(--danger); }
|
||||||
|
[data-module="overflow-tap"] .ot-main[data-tone="muted"] { --tone: var(--muted); }
|
||||||
|
|
||||||
|
[data-module="overflow-tap"] .ot-label {
|
||||||
|
color: var(--tone);
|
||||||
|
font-size: clamp(0.5rem, 9cqh, 0.95rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
line-height: 1.2;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="overflow-tap"] .ot-keyword {
|
||||||
|
color: var(--tone);
|
||||||
|
font-size: min(clamp(1rem, 38cqh, 7rem), 18cqw);
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 1.15;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-module="overflow-tap"] .ot-keyword.is-none { color: var(--muted); }
|
||||||
|
[data-module="overflow-tap"] .ot-main[data-tone="muted"] .ot-keyword {
|
||||||
|
animation: ot-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes ot-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.45; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="overflow-tap"] .ot-dest {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: min(clamp(0.7rem, 16cqh, 1.9rem), 8cqw);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-module="overflow-tap"] .ot-main[data-tone="muted"] .ot-dest { color: var(--muted); font-weight: 500; }
|
||||||
|
|
||||||
|
[data-module="overflow-tap"] .ot-groups {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: clamp(0.5rem, 9cqh, 0.95rem);
|
||||||
|
line-height: 1.3;
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="overflow-tap"] .ot-error {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: clamp(0.5rem, 9cqh, 0.95rem);
|
||||||
|
line-height: 1.3;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
[data-module="overflow-tap"] .ot-error.is-warn { color: var(--warn); }
|
||||||
|
|
||||||
|
/* ── this slide ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
[data-module="overflow-tap"] .ot-slide {
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: clamp(0.55rem, 4.8cqmin, 0.95rem);
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
padding: 0.25em 0.6em;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--text) 7%, transparent);
|
||||||
|
align-self: center;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
[data-module="overflow-tap"] .ot-slide.is-muted { color: var(--muted); }
|
||||||
|
[data-module="overflow-tap"] .ot-slide.is-hit {
|
||||||
|
color: var(--accent-text);
|
||||||
|
background: color-mix(in srgb, var(--accent) 80%, transparent);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
[data-module="overflow-tap"] .ot-slide.is-warn {
|
||||||
|
color: var(--warn);
|
||||||
|
background: color-mix(in srgb, var(--warn) 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the trail ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
[data-module="overflow-tap"] .ot-history {
|
||||||
|
flex-shrink: 0;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.3em 0 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: clamp(0.55rem, 4.6cqmin, 0.95rem);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="overflow-tap"] .ot-h-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1.4fr) auto;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0 0.7em;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
[data-module="overflow-tap"] .ot-h-row.is-failed .ot-h-kw,
|
||||||
|
[data-module="overflow-tap"] .ot-h-row.is-failed .ot-h-mark { color: var(--danger); }
|
||||||
|
[data-module="overflow-tap"] .ot-h-time {
|
||||||
|
color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
[data-module="overflow-tap"] .ot-h-kw { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
[data-module="overflow-tap"] .ot-h-dest { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
[data-module="overflow-tap"] .ot-h-mark { color: var(--accent); font-weight: 700; }
|
||||||
|
[data-module="overflow-tap"] .ot-h-empty { color: var(--muted); text-align: center; }
|
||||||
|
|
||||||
|
/* On a short tile the trail would crowd the keyword out: keep the
|
||||||
|
keyword, drop the trail. Inside the keyword block the same rule: when
|
||||||
|
it is short, the small lines go before the keyword does. */
|
||||||
|
@container (max-height: 120px) {
|
||||||
|
[data-module="overflow-tap"] .ot-history { display: none; }
|
||||||
|
}
|
||||||
|
@container (max-height: 70px) {
|
||||||
|
[data-module="overflow-tap"] .ot-groups,
|
||||||
|
[data-module="overflow-tap"] .ot-error { display: none; }
|
||||||
|
}
|
||||||
|
@container (max-height: 46px) {
|
||||||
|
[data-module="overflow-tap"] .ot-dest,
|
||||||
|
[data-module="overflow-tap"] .ot-label { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="overflow-tap"] .ot-empty {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6%;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
font-size: clamp(0.65rem, 5.5cqmin, 1rem);
|
||||||
|
}
|
||||||
+38
-20
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
import createTeams from "./teams-client.js";
|
import createTeams from "./teams-client.js";
|
||||||
import createKey from "./key-client.js";
|
import createKey from "./key-client.js";
|
||||||
|
import { viewOf, onViewedType, addServiceTypeSwitch } from "./viewed-type.js";
|
||||||
import { prettyKey } from "./key-notation.js";
|
import { prettyKey } from "./key-notation.js";
|
||||||
|
|
||||||
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
|
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
|
||||||
@@ -38,6 +39,10 @@ function createPlan({ root, moduleApi }) {
|
|||||||
let lastCurrentId = null;
|
let lastCurrentId = null;
|
||||||
let lastListKey = '';
|
let lastListKey = '';
|
||||||
let absView = null; // what the server says AbleSet is doing, if anything
|
let absView = null; // what the server says AbleSet is doing, if anything
|
||||||
|
// The plan this dashboard is looking at — usually the one the server
|
||||||
|
// follows, otherwise one of the others it also reads.
|
||||||
|
let view = viewOf(null);
|
||||||
|
let offViewed = null;
|
||||||
let userScrolledAt = 0;
|
let userScrolledAt = 0;
|
||||||
|
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
@@ -89,6 +94,7 @@ function createPlan({ root, moduleApi }) {
|
|||||||
moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) });
|
moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) });
|
||||||
applyPrefs();
|
applyPrefs();
|
||||||
}
|
}
|
||||||
|
const switcher = addServiceTypeSwitch({ moduleApi, getState: () => state });
|
||||||
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-1) });
|
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-1) });
|
||||||
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(1) });
|
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(1) });
|
||||||
|
|
||||||
@@ -144,7 +150,7 @@ function createPlan({ root, moduleApi }) {
|
|||||||
/* ── which items to show ────────────────────────────────────────── */
|
/* ── which items to show ────────────────────────────────────────── */
|
||||||
|
|
||||||
function visibleItems() {
|
function visibleItems() {
|
||||||
const items = Array.isArray(state?.items) ? state.items : [];
|
const items = Array.isArray(view.items) ? view.items : [];
|
||||||
return items.filter((it) => {
|
return items.filter((it) => {
|
||||||
if (it.itemType === 'header' && !on('showHeaders')) return false;
|
if (it.itemType === 'header' && !on('showHeaders')) return false;
|
||||||
if (it.servicePosition === 'pre' && !on('showPreService')) return false;
|
if (it.servicePosition === 'pre' && !on('showPreService')) return false;
|
||||||
@@ -194,14 +200,14 @@ function createPlan({ root, moduleApi }) {
|
|||||||
|
|
||||||
/** Placeholder → text; a hidden or empty value resolves to '' and drops its segment. */
|
/** Placeholder → text; a hidden or empty value resolves to '' and drops its segment. */
|
||||||
function templateValues() {
|
function templateValues() {
|
||||||
const plan = state?.plan || {};
|
const plan = view.plan || {};
|
||||||
const items = state?.items || [];
|
const items = state?.items || [];
|
||||||
const planned = plannedLength();
|
const planned = plannedLength();
|
||||||
return {
|
return {
|
||||||
series: on('showSeries') ? plan.seriesTitle || '' : '',
|
series: on('showSeries') ? plan.seriesTitle || '' : '',
|
||||||
part: on('showPart') ? plan.title || '' : '',
|
part: on('showPart') ? plan.title || '' : '',
|
||||||
title: on('showPart') ? plan.title || '' : '',
|
title: on('showPart') ? plan.title || '' : '',
|
||||||
servicetype: on('showServiceType') ? state?.serviceType?.name || '' : '',
|
servicetype: on('showServiceType') ? view.name || '' : '',
|
||||||
date: on('showDate') ? plan.dates || '' : '',
|
date: on('showDate') ? plan.dates || '' : '',
|
||||||
shortdate: on('showDate') ? plan.shortDates || plan.dates || '' : '',
|
shortdate: on('showDate') ? plan.shortDates || plan.dates || '' : '',
|
||||||
time: on('showServiceTime') && plan.serviceStartsAt
|
time: on('showServiceTime') && plan.serviceStartsAt
|
||||||
@@ -239,12 +245,12 @@ function createPlan({ root, moduleApi }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderHead() {
|
function renderHead() {
|
||||||
const plan = state?.plan;
|
const plan = view.plan;
|
||||||
const show = on('showTopBar') && plan;
|
const show = on('showTopBar') && plan;
|
||||||
headEl.hidden = !show;
|
headEl.hidden = !show;
|
||||||
if (!show) return;
|
if (!show) return;
|
||||||
const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
|
const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
|
||||||
planTitleEl.textContent = line1 || line2 || state.serviceType?.name || 'Plan';
|
planTitleEl.textContent = line1 || line2 || view.name || 'Plan';
|
||||||
planSubEl.textContent = line1 ? line2 || '' : '';
|
planSubEl.textContent = line1 ? line2 || '' : '';
|
||||||
planSubEl.hidden = !planSubEl.textContent;
|
planSubEl.hidden = !planSubEl.textContent;
|
||||||
renderClock();
|
renderClock();
|
||||||
@@ -258,9 +264,12 @@ function createPlan({ root, moduleApi }) {
|
|||||||
* time once it has ended.
|
* time once it has ended.
|
||||||
*/
|
*/
|
||||||
function renderClock() {
|
function renderClock() {
|
||||||
const plan = state?.plan;
|
const plan = view.plan;
|
||||||
const live = state?.live || {};
|
const live = view.live ? (state?.live || {}) : {};
|
||||||
const svc = state?.service || {};
|
// The service window belongs to the type being followed. Another type's
|
||||||
|
// plan must not borrow its clock — it would read as that service running.
|
||||||
|
// With none, the fall-through below shows that plan's own start time.
|
||||||
|
const svc = view.live ? (state?.service || {}) : {};
|
||||||
clockLabelEl.textContent = '';
|
clockLabelEl.textContent = '';
|
||||||
clockValueEl.textContent = '';
|
clockValueEl.textContent = '';
|
||||||
clockValueEl.className = 'pp-clock-value';
|
clockValueEl.className = 'pp-clock-value';
|
||||||
@@ -272,7 +281,7 @@ function createPlan({ root, moduleApi }) {
|
|||||||
clockValueEl.textContent = fmtDur((now - startedAt) / 1000);
|
clockValueEl.textContent = fmtDur((now - startedAt) / 1000);
|
||||||
if (live.following) {
|
if (live.following) {
|
||||||
// Ahead of / behind the plan: elapsed minus what the completed items were planned to take.
|
// Ahead of / behind the plan: elapsed minus what the completed items were planned to take.
|
||||||
const done = state.items.filter((it) => live.history?.[it.id]?.endedAt);
|
const done = view.items.filter((it) => live.history?.[it.id]?.endedAt);
|
||||||
const planned = done.reduce((sum, it) => sum + (it.length || 0), 0);
|
const planned = done.reduce((sum, it) => sum + (it.length || 0), 0);
|
||||||
const actual = done.reduce((sum, it) => sum + (live.history[it.id].endedAt - live.history[it.id].startedAt) / 1000, 0);
|
const actual = done.reduce((sum, it) => sum + (live.history[it.id].endedAt - live.history[it.id].startedAt) / 1000, 0);
|
||||||
const drift = actual - planned;
|
const drift = actual - planned;
|
||||||
@@ -321,13 +330,13 @@ function createPlan({ root, moduleApi }) {
|
|||||||
link: { href: '/admin', text: 'Open Admin' },
|
link: { href: '/admin', text: 'Open Admin' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!state.reachable && !state.plan) {
|
if (!state.reachable && !view.plan) {
|
||||||
return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' };
|
return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' };
|
||||||
}
|
}
|
||||||
if (!state.plan) {
|
if (!view.plan) {
|
||||||
return {
|
return {
|
||||||
title: 'No plan found',
|
title: 'No plan found',
|
||||||
hint: `${state.serviceType?.name || 'This service type'} has no upcoming or recent plans in Planning Center.`,
|
hint: `${view.name || 'This service type'} has no upcoming or recent plans in Planning Center.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!visibleItems().length) return { title: 'Nothing to show', hint: 'Every item is hidden by this tile’s settings (gear menu).' };
|
if (!visibleItems().length) return { title: 'Nothing to show', hint: 'Every item is hidden by this tile’s settings (gear menu).' };
|
||||||
@@ -483,7 +492,7 @@ function createPlan({ root, moduleApi }) {
|
|||||||
|
|
||||||
// Rebuild rows only when the plan content or the display settings
|
// Rebuild rows only when the plan content or the display settings
|
||||||
// changed; live highlighting and timers are applied in place.
|
// changed; live highlighting and timers are applied in place.
|
||||||
const key = JSON.stringify([state.plan.id, state.items, cfg(), state.live?.following]);
|
const key = JSON.stringify([view.id, view.plan.id, view.items, cfg(), view.live && state.live?.following]);
|
||||||
if (key !== lastListKey) {
|
if (key !== lastListKey) {
|
||||||
lastListKey = key;
|
lastListKey = key;
|
||||||
listEl.innerHTML = '';
|
listEl.innerHTML = '';
|
||||||
@@ -523,6 +532,7 @@ function createPlan({ root, moduleApi }) {
|
|||||||
/** The row AbleSet's song belongs to — only ever the current one. */
|
/** The row AbleSet's song belongs to — only ever the current one. */
|
||||||
function absRow() {
|
function absRow() {
|
||||||
const a = absView;
|
const a = absView;
|
||||||
|
if (!view.live) return null; // another service type is on screen
|
||||||
if (!a || !a.available || !a.reachable || !a.song || !a.itemId) return null;
|
if (!a || !a.available || !a.reachable || !a.song || !a.itemId) return null;
|
||||||
if (a.itemId !== lastCurrentId) return null;
|
if (a.itemId !== lastCurrentId) return null;
|
||||||
return listEl.querySelector(`.pp-row[data-id="${CSS.escape(a.itemId)}"]`) || null;
|
return listEl.querySelector(`.pp-row[data-id="${CSS.escape(a.itemId)}"]`) || null;
|
||||||
@@ -627,15 +637,17 @@ function createPlan({ root, moduleApi }) {
|
|||||||
|
|
||||||
/** Current / done classes and the elapsed timers, without rebuilding rows. */
|
/** Current / done classes and the elapsed timers, without rebuilding rows. */
|
||||||
function renderLiveMarks() {
|
function renderLiveMarks() {
|
||||||
const live = state?.live || {};
|
// Only the followed service type has a live item: this dashboard may be
|
||||||
|
// looking at another one, which has no ProPresenter and no clock.
|
||||||
|
const live = view.live ? (state?.live || {}) : {};
|
||||||
const now = serverNow();
|
const now = serverNow();
|
||||||
const currentId = live.following ? live.currentItemId || '' : '';
|
const currentId = live.following ? live.currentItemId || '' : '';
|
||||||
const history = live.history || {};
|
const history = live.history || {};
|
||||||
const byId = new Map(state.items.map((it) => [it.id, it]));
|
const byId = new Map(view.items.map((it) => [it.id, it]));
|
||||||
// Everything before the current item counts as done, matched or not.
|
// Everything before the current item counts as done, matched or not.
|
||||||
const doneIds = new Set();
|
const doneIds = new Set();
|
||||||
if (currentId) {
|
if (currentId) {
|
||||||
for (const it of state.items) {
|
for (const it of view.items) {
|
||||||
if (it.id === currentId) break;
|
if (it.id === currentId) break;
|
||||||
if (it.itemType !== 'header') doneIds.add(it.id);
|
if (it.itemType !== 'header') doneIds.add(it.id);
|
||||||
}
|
}
|
||||||
@@ -676,8 +688,8 @@ function createPlan({ root, moduleApi }) {
|
|||||||
/* ── footer: what ProPresenter is doing ─────────────────────────── */
|
/* ── footer: what ProPresenter is doing ─────────────────────────── */
|
||||||
|
|
||||||
function renderFoot() {
|
function renderFoot() {
|
||||||
const live = state?.live;
|
const live = view.live ? state?.live : null;
|
||||||
const show = on('showLiveFooter') && live?.following && state?.plan;
|
const show = on('showLiveFooter') && live?.following && view.plan;
|
||||||
footEl.hidden = !show;
|
footEl.hidden = !show;
|
||||||
footEl.className = 'pp-foot';
|
footEl.className = 'pp-foot';
|
||||||
if (!show) return;
|
if (!show) return;
|
||||||
@@ -713,7 +725,10 @@ function createPlan({ root, moduleApi }) {
|
|||||||
/* ── render + ticking ───────────────────────────────────────────── */
|
/* ── render + ticking ───────────────────────────────────────────── */
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
|
view = viewOf(state);
|
||||||
|
switcher.sync();
|
||||||
wrap.classList.toggle('is-offline', feedOffline);
|
wrap.classList.toggle('is-offline', feedOffline);
|
||||||
|
wrap.classList.toggle('is-other-type', Boolean(state) && !view.live);
|
||||||
wrap.classList.toggle('tint-types', Boolean(cfg().tintByType));
|
wrap.classList.toggle('tint-types', Boolean(cfg().tintByType));
|
||||||
wrap.classList.toggle('header-gradient', cfg().headerGradient !== false);
|
wrap.classList.toggle('header-gradient', cfg().headerGradient !== false);
|
||||||
const color = (value, fallback) => {
|
const color = (value, fallback) => {
|
||||||
@@ -744,9 +759,9 @@ function createPlan({ root, moduleApi }) {
|
|||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
renderAbleSet();
|
renderAbleSet();
|
||||||
if (!state?.plan) return;
|
if (!view.plan) return;
|
||||||
renderClock();
|
renderClock();
|
||||||
if (state.live?.following && on('showItemRuntime')) renderLiveMarks();
|
if (view.live && state.live?.following && on('showItemRuntime')) renderLiveMarks();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── live feed ──────────────────────────────────────────────────── */
|
/* ── live feed ──────────────────────────────────────────────────── */
|
||||||
@@ -805,11 +820,14 @@ function createPlan({ root, moduleApi }) {
|
|||||||
.then((body) => { if (body && !state) accept(body); })
|
.then((body) => { if (body && !state) accept(body); })
|
||||||
.catch(() => { /* the stream will cover it */ });
|
.catch(() => { /* the stream will cover it */ });
|
||||||
connect();
|
connect();
|
||||||
|
offViewed = onViewedType(() => { lastListKey = ''; render(); });
|
||||||
listEl.addEventListener('wheel', onUserScroll, { passive: true });
|
listEl.addEventListener('wheel', onUserScroll, { passive: true });
|
||||||
listEl.addEventListener('touchmove', onUserScroll, { passive: true });
|
listEl.addEventListener('touchmove', onUserScroll, { passive: true });
|
||||||
ticker = setInterval(tick, 1000);
|
ticker = setInterval(tick, 1000);
|
||||||
},
|
},
|
||||||
stop() {
|
stop() {
|
||||||
|
offViewed?.();
|
||||||
|
offViewed = null;
|
||||||
clearInterval(ticker);
|
clearInterval(ticker);
|
||||||
ticker = null;
|
ticker = null;
|
||||||
stream?.close();
|
stream?.close();
|
||||||
|
|||||||
@@ -5,11 +5,14 @@
|
|||||||
which item is current comes from the ProPresenter module, as ever. */
|
which item is current comes from the ProPresenter module, as ever. */
|
||||||
|
|
||||||
import { parseKey, prettyKey } from "./key-notation.js";
|
import { parseKey, prettyKey } from "./key-notation.js";
|
||||||
|
import { viewOf, onViewedType, addServiceTypeSwitch } from "./viewed-type.js";
|
||||||
|
|
||||||
export default function createKey({ root, moduleApi }) {
|
export default function createKey({ root, moduleApi }) {
|
||||||
let state = null;
|
let state = null;
|
||||||
let stream = null;
|
let stream = null;
|
||||||
let feedOffline = false;
|
let feedOffline = false;
|
||||||
|
let view = viewOf(null);
|
||||||
|
let offViewed = null;
|
||||||
|
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="pk-wrap">
|
<div class="pk-wrap">
|
||||||
@@ -23,17 +26,21 @@ export default function createKey({ root, moduleApi }) {
|
|||||||
const nameEl = root.querySelector('.pk-name');
|
const nameEl = root.querySelector('.pk-name');
|
||||||
const nextEl = root.querySelector('.pk-next');
|
const nextEl = root.querySelector('.pk-next');
|
||||||
|
|
||||||
|
const switcher = addServiceTypeSwitch({ moduleApi, getState: () => state });
|
||||||
|
|
||||||
const cfg = () => moduleApi.instanceSettings || {};
|
const cfg = () => moduleApi.instanceSettings || {};
|
||||||
const on = (key) => cfg()[key] !== false; // unset = shown
|
const on = (key) => cfg()[key] !== false; // unset = shown
|
||||||
|
|
||||||
/* ── the item on screen, and the one after it ───────────────────── */
|
/* ── the item on screen, and the one after it ───────────────────── */
|
||||||
|
|
||||||
function items() {
|
function items() {
|
||||||
return Array.isArray(state?.items) ? state.items : [];
|
return Array.isArray(view.items) ? view.items : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentItem() {
|
function currentItem() {
|
||||||
const id = state?.live?.following ? state.live.currentItemId : '';
|
// Only the followed service type has a live item; on another one there
|
||||||
|
// is nothing current, so the tile says so rather than guessing.
|
||||||
|
const id = view.live && state?.live?.following ? state.live.currentItemId : '';
|
||||||
return id ? items().find((it) => it.id === id) || null : null;
|
return id ? items().find((it) => it.id === id) || null : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +70,7 @@ export default function createKey({ root, moduleApi }) {
|
|||||||
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
|
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
|
||||||
}
|
}
|
||||||
const item = currentItem();
|
const item = currentItem();
|
||||||
|
if (!view.live) return moduleApi.setStatus('ok', `${view.name || 'Another service type'} — not the one live`);
|
||||||
if (!state.live?.following) return moduleApi.setStatus('ok', 'Not following ProPresenter — no item is current');
|
if (!state.live?.following) return moduleApi.setStatus('ok', 'Not following ProPresenter — no item is current');
|
||||||
// The key on screen stays — the band still needs it — but a tile must
|
// The key on screen stays — the band still needs it — but a tile must
|
||||||
// never look confidently live while the feed that chose it is gone.
|
// never look confidently live while the feed that chose it is gone.
|
||||||
@@ -101,7 +109,10 @@ export default function createKey({ root, moduleApi }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
|
view = viewOf(state);
|
||||||
|
switcher.sync();
|
||||||
wrap.classList.toggle('is-offline', feedOffline);
|
wrap.classList.toggle('is-offline', feedOffline);
|
||||||
|
wrap.classList.toggle('is-other-type', Boolean(state) && !view.live);
|
||||||
const live = state?.live;
|
const live = state?.live;
|
||||||
wrap.classList.toggle('is-stale', Boolean(live?.following && (!live.available || !live.ppReachable)));
|
wrap.classList.toggle('is-stale', Boolean(live?.following && (!live.available || !live.ppReachable)));
|
||||||
const color = String(cfg().keyColor || '').trim();
|
const color = String(cfg().keyColor || '').trim();
|
||||||
@@ -120,7 +131,9 @@ export default function createKey({ root, moduleApi }) {
|
|||||||
}
|
}
|
||||||
if (!item) {
|
if (!item) {
|
||||||
drawKey('—');
|
drawKey('—');
|
||||||
nameEl.textContent = state.live?.following ? 'Nothing live' : 'Not following ProPresenter';
|
nameEl.textContent = !view.live
|
||||||
|
? (view.name || 'Another service type')
|
||||||
|
: (state.live?.following ? 'Nothing live' : 'Not following ProPresenter');
|
||||||
nextEl.textContent = '';
|
nextEl.textContent = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -167,9 +180,12 @@ export default function createKey({ root, moduleApi }) {
|
|||||||
.then((body) => { if (body && !state) accept(body); })
|
.then((body) => { if (body && !state) accept(body); })
|
||||||
.catch(() => { /* the stream will cover it */ });
|
.catch(() => { /* the stream will cover it */ });
|
||||||
connect();
|
connect();
|
||||||
|
offViewed = onViewedType(render);
|
||||||
render();
|
render();
|
||||||
},
|
},
|
||||||
stop() {
|
stop() {
|
||||||
|
offViewed?.();
|
||||||
|
offViewed = null;
|
||||||
stream?.close();
|
stream?.close();
|
||||||
stream = null;
|
stream = null;
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"id": "pco-plan",
|
"id": "pco-plan",
|
||||||
"name": "PCO Plan",
|
"name": "PCO Plan",
|
||||||
"version": "1.9.0",
|
"version": "1.10.0",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.12.0",
|
||||||
"description": "Order of service, team assignments and the song key from Planning Center Services",
|
"description": "Order of service, team assignments and the song key from Planning Center Services",
|
||||||
"provides": [
|
"provides": [
|
||||||
"timers"
|
"timers"
|
||||||
@@ -46,11 +46,13 @@
|
|||||||
"default": "",
|
"default": "",
|
||||||
"group": "Planning Center credentials"
|
"group": "Planning Center credentials"
|
||||||
},
|
},
|
||||||
"serviceTypeId": {
|
"serviceTypes": {
|
||||||
"type": "select",
|
"type": "multiselect",
|
||||||
"label": "Service type",
|
"label": "Service types",
|
||||||
"default": "",
|
"orderable": true,
|
||||||
"optionsRoute": "/service-types"
|
"optionsRoute": "/service-types",
|
||||||
|
"help": "Add every service type whose plans this ProdDash should follow. Order is priority: when two of them have a plan at the same date and time, the one higher up wins.",
|
||||||
|
"addLabel": "Add a service type…"
|
||||||
},
|
},
|
||||||
"planId": {
|
"planId": {
|
||||||
"type": "select",
|
"type": "select",
|
||||||
@@ -63,7 +65,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"optionsRoute": "/plans",
|
"optionsRoute": "/plans",
|
||||||
"optionsDependsOn": "serviceTypeId"
|
"optionsDependsOn": "serviceTypes",
|
||||||
|
"help": "Leave on Automatic for services. Pin a specific plan for rehearsals or to review a past service."
|
||||||
},
|
},
|
||||||
"pollSeconds": {
|
"pollSeconds": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
|
|||||||
+235
-54
@@ -73,6 +73,7 @@ const path = require('path');
|
|||||||
const { placeDocument, messageItemOf, playlistMirrorsPlan, similarity, MATCH_THRESHOLD } = require('./matching');
|
const { placeDocument, messageItemOf, playlistMirrorsPlan, similarity, MATCH_THRESHOLD } = require('./matching');
|
||||||
const {
|
const {
|
||||||
MINUTE,
|
MINUTE,
|
||||||
|
DEFAULT_SERVICE_LENGTH_MS,
|
||||||
serviceTimes,
|
serviceTimes,
|
||||||
serviceWindows,
|
serviceWindows,
|
||||||
selectService,
|
selectService,
|
||||||
@@ -111,6 +112,12 @@ const SEEN_DOCUMENTS_MAX = 200;
|
|||||||
* plan changes, and otherwise no more often than this.
|
* plan changes, and otherwise no more often than this.
|
||||||
*/
|
*/
|
||||||
const TEAMS_MIN_MS = 60 * 1000;
|
const TEAMS_MIN_MS = 60 * 1000;
|
||||||
|
/**
|
||||||
|
* The selected service types nobody is following: their plans only matter to a
|
||||||
|
* dashboard that has switched to one, and they never move during a service, so
|
||||||
|
* they ride a lane slower than the plan itself.
|
||||||
|
*/
|
||||||
|
const OTHERS_MIN_MS = 45 * 1000;
|
||||||
/** How long the service type's own team list is believed. */
|
/** How long the service type's own team list is believed. */
|
||||||
const SERVICE_TEAMS_TTL_MS = 10 * MINUTE;
|
const SERVICE_TEAMS_TTL_MS = 10 * MINUTE;
|
||||||
/** Avatars live on Planning Center's CDN; browsers only talk to ProdDash. */
|
/** Avatars live on Planning Center's CDN; browsers only talk to ProdDash. */
|
||||||
@@ -147,6 +154,14 @@ function clearedState() {
|
|||||||
items: [],
|
items: [],
|
||||||
teams: [], // who is scheduled, by team (the Teams tile)
|
teams: [], // who is scheduled, by team (the Teams tile)
|
||||||
teamsError: '', // why the last team read failed, if it did
|
teamsError: '', // why the last team read failed, if it did
|
||||||
|
serviceTypes: [], // every selected service type id, highest priority first
|
||||||
|
/**
|
||||||
|
* The selected service types this ProdDash is NOT following right now,
|
||||||
|
* each with its own current plan. A dashboard can switch to one of these
|
||||||
|
* for itself; the live machinery (ProPresenter, the service clock, the
|
||||||
|
* timers) stays with the one above, which is the service actually running.
|
||||||
|
*/
|
||||||
|
others: {},
|
||||||
live: clearedLive(),
|
live: clearedLive(),
|
||||||
service: clearedService(),
|
service: clearedService(),
|
||||||
timing: null, // the admin's service-timing choices (no secrets)
|
timing: null, // the admin's service-timing choices (no secrets)
|
||||||
@@ -610,7 +625,20 @@ module.exports = {
|
|||||||
const streams = new Set(); // /stream clients (tiles)
|
const streams = new Set(); // /stream clients (tiles)
|
||||||
const timerStreams = new Set(); // /timers/stream clients (the Timers module)
|
const timerStreams = new Set(); // /timers/stream clients (the Timers module)
|
||||||
const pco = createPcoClient(config);
|
const pco = createPcoClient(config);
|
||||||
const serviceTypeId = String(config.serviceTypeId || '').trim();
|
/**
|
||||||
|
* Every service type this ProdDash follows, highest priority first. A
|
||||||
|
* config written before 1.10.0 has the single `serviceTypeId` instead;
|
||||||
|
* it is read as a list of one so a booth keeps working untouched until
|
||||||
|
* somebody opens the admin page.
|
||||||
|
*/
|
||||||
|
const serviceTypes = (Array.isArray(config.serviceTypes) ? config.serviceTypes : [])
|
||||||
|
.map((v) => String(v).trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!serviceTypes.length && String(config.serviceTypeId || '').trim()) {
|
||||||
|
serviceTypes.push(String(config.serviceTypeId).trim());
|
||||||
|
}
|
||||||
|
/** The one whose plan is on screen — everything plan-scoped hangs off this. */
|
||||||
|
let activeTypeId = serviceTypes[0] || '';
|
||||||
const pinnedPlanId = String(config.planId || '').trim();
|
const pinnedPlanId = String(config.planId || '').trim();
|
||||||
const pollMs = Math.max(MIN_POLL_S, Number(config.pollSeconds) || 30) * 1000;
|
const pollMs = Math.max(MIN_POLL_S, Number(config.pollSeconds) || 30) * 1000;
|
||||||
const follow = config.followProPresenter !== false;
|
const follow = config.followProPresenter !== false;
|
||||||
@@ -647,8 +675,9 @@ module.exports = {
|
|||||||
|
|
||||||
let state = clearedState();
|
let state = clearedState();
|
||||||
state.configured = pco.configured;
|
state.configured = pco.configured;
|
||||||
state.serviceTypeSet = Boolean(serviceTypeId);
|
state.serviceTypeSet = Boolean(serviceTypes.length);
|
||||||
state.serviceType = { id: serviceTypeId, name: '' };
|
state.serviceType = { id: activeTypeId, name: '' };
|
||||||
|
state.serviceTypes = serviceTypes.slice();
|
||||||
state.live.following = follow;
|
state.live.following = follow;
|
||||||
state.timing = { ...timing };
|
state.timing = { ...timing };
|
||||||
|
|
||||||
@@ -704,8 +733,37 @@ module.exports = {
|
|||||||
|
|
||||||
/* — plan reading — */
|
/* — plan reading — */
|
||||||
|
|
||||||
function planPath(planId) {
|
function planPath(planId, stId = activeTypeId) {
|
||||||
return `${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(planId)}`;
|
return `${SERVICES}/service_types/${encodeURIComponent(stId)}/plans/${encodeURIComponent(planId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** id → name, asked once each. A service type that has gone is named, not fatal. */
|
||||||
|
const typeNames = new Map();
|
||||||
|
async function typeName(stId) {
|
||||||
|
if (typeNames.has(stId)) return typeNames.get(stId);
|
||||||
|
let name = `Service type ${stId}`;
|
||||||
|
try {
|
||||||
|
const st = await pco.get(`${SERVICES}/service_types/${encodeURIComponent(stId)}`);
|
||||||
|
name = String(st?.data?.attributes?.name || '').trim() || name;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status !== 404) throw err;
|
||||||
|
name = `Service type ${stId} (no longer in Planning Center)`;
|
||||||
|
}
|
||||||
|
typeNames.set(stId, name);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pinned plan, as the admin's Plan select writes it: "<serviceType>/<plan>".
|
||||||
|
* A bare id is a pin saved before 1.10.0, when there was only one service
|
||||||
|
* type to belong to.
|
||||||
|
*/
|
||||||
|
function parsePin(raw) {
|
||||||
|
const text = String(raw || '').trim();
|
||||||
|
if (!text) return null;
|
||||||
|
const slash = text.indexOf('/');
|
||||||
|
if (slash > 0) return { stId: text.slice(0, slash), planId: text.slice(slash + 1) };
|
||||||
|
return { stId: serviceTypes[0] || '', planId: text };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The plans around now for a service type: a few upcoming, a few recent, oldest first. */
|
/** The plans around now for a service type: a few upcoming, a few recent, oldest first. */
|
||||||
@@ -740,8 +798,61 @@ module.exports = {
|
|||||||
return pickPlan(await listPlans(stId));
|
return pickPlan(await listPlans(stId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readPlanTimes(planId) {
|
/**
|
||||||
const body = await pco.getAll(`${planPath(planId)}/plan_times`);
|
* The plan to follow, across every selected service type. Their plans are
|
||||||
|
* ranked together — one happening today beats one still to come, which
|
||||||
|
* beats one already past — and a dead heat on the date is settled by the
|
||||||
|
* order the service types are listed in, which is what that order is for.
|
||||||
|
*
|
||||||
|
* One service type failing (a deleted id, say) must not cost the booth the
|
||||||
|
* others, so it is logged and skipped; a credentials or network failure is
|
||||||
|
* not skippable and is thrown on.
|
||||||
|
*/
|
||||||
|
async function choosePlanAcross() {
|
||||||
|
const now = Date.now();
|
||||||
|
const today = localYmd(new Date(now));
|
||||||
|
const candidates = [];
|
||||||
|
for (let priority = 0; priority < serviceTypes.length; priority += 1) {
|
||||||
|
const stId = serviceTypes[priority];
|
||||||
|
let plans;
|
||||||
|
try {
|
||||||
|
plans = await listPlans(stId);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 401 || err.status === 429 || err.network) throw err;
|
||||||
|
log(`service type ${stId} skipped: ${err?.message || err}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const plan of plans) {
|
||||||
|
const tier = localYmd(new Date(plan.sortDate)) === today ? 0 : (plan.sortDate > now ? 1 : 2);
|
||||||
|
candidates.push({ ...plan, stId, priority, tier });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!candidates.length) return null;
|
||||||
|
candidates.sort((a, b) => (
|
||||||
|
a.tier - b.tier
|
||||||
|
// today and upcoming: soonest first. Past: most recent first.
|
||||||
|
|| (a.tier === 2 ? b.sortDate - a.sortDate : a.sortDate - b.sortDate)
|
||||||
|
|| a.priority - b.priority
|
||||||
|
));
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Point everything plan-scoped at a service type, forgetting the last one's. */
|
||||||
|
async function useServiceType(stId) {
|
||||||
|
if (!stId || stId === activeTypeId) {
|
||||||
|
if (stId && !state.serviceType.name) state.serviceType = { id: stId, name: await typeName(stId) };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeTypeId = stId;
|
||||||
|
// Note categories belong to the service type; the team lists are already
|
||||||
|
// cached per type and look after themselves.
|
||||||
|
noteCategories = { at: 0, options: [] };
|
||||||
|
state.serviceType = { id: stId, name: await typeName(stId) };
|
||||||
|
if (serviceTypes.length > 1) log(`following service type "${state.serviceType.name}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPlanTimes(planId, stId = activeTypeId) {
|
||||||
|
const body = await pco.getAll(`${planPath(planId, stId)}/plan_times`);
|
||||||
return (body.data || [])
|
return (body.data || [])
|
||||||
.map((t) => ({
|
.map((t) => ({
|
||||||
id: String(t.id),
|
id: String(t.id),
|
||||||
@@ -754,8 +865,8 @@ module.exports = {
|
|||||||
.sort((x, y) => x.startsAt - y.startsAt);
|
.sort((x, y) => x.startsAt - y.startsAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readItems(planId) {
|
async function readItems(planId, stId = activeTypeId) {
|
||||||
const target = `${planPath(planId)}/items`;
|
const target = `${planPath(planId, stId)}/items`;
|
||||||
try {
|
try {
|
||||||
return shapeItems(await pco.getAll(target, { include: 'song,arrangement,item_notes' }));
|
return shapeItems(await pco.getAll(target, { include: 'song,arrangement,item_notes' }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -775,18 +886,21 @@ module.exports = {
|
|||||||
* service type, not during a service. A failed read is not fatal — the
|
* service type, not during a service. A failed read is not fatal — the
|
||||||
* plan's own teams still draw, just without the empty ones.
|
* plan's own teams still draw, just without the empty ones.
|
||||||
*/
|
*/
|
||||||
let serviceTeams = { at: 0, list: [] };
|
const serviceTeamsByType = new Map(); // stId -> { at, list }
|
||||||
|
|
||||||
async function readServiceTeams() {
|
async function readServiceTeams(stId = activeTypeId) {
|
||||||
|
let serviceTeams = serviceTeamsByType.get(stId) || { at: 0, list: [] };
|
||||||
|
serviceTeamsByType.set(stId, serviceTeams);
|
||||||
if (Date.now() - serviceTeams.at < SERVICE_TEAMS_TTL_MS) return serviceTeams.list;
|
if (Date.now() - serviceTeams.at < SERVICE_TEAMS_TTL_MS) return serviceTeams.list;
|
||||||
try {
|
try {
|
||||||
const body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/teams`);
|
const body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(stId)}/teams`);
|
||||||
const list = (body.data || []).map((t, i) => ({
|
const list = (body.data || []).map((t, i) => ({
|
||||||
id: String(t.id),
|
id: String(t.id),
|
||||||
name: String(t.attributes?.name || '').trim() || `Team ${t.id}`,
|
name: String(t.attributes?.name || '').trim() || `Team ${t.id}`,
|
||||||
sequence: Number(t.attributes?.sequence) || i + 1,
|
sequence: Number(t.attributes?.sequence) || i + 1,
|
||||||
}));
|
}));
|
||||||
serviceTeams = { at: Date.now(), list };
|
serviceTeams = { at: Date.now(), list };
|
||||||
|
serviceTeamsByType.set(stId, serviceTeams);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`service type team list read failed: ${err?.message || err}`);
|
log(`service type team list read failed: ${err?.message || err}`);
|
||||||
serviceTeams.at = Date.now(); // don't retry every poll
|
serviceTeams.at = Date.now(); // don't retry every poll
|
||||||
@@ -795,7 +909,7 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** planPersonId → photo URL, so a browser never learns the CDN address. */
|
/** planPersonId → photo URL, so a browser never learns the CDN address. */
|
||||||
let teamPhotos = new Map();
|
const teamPhotos = new Map();
|
||||||
let teamsReadAt = 0;
|
let teamsReadAt = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -804,8 +918,8 @@ module.exports = {
|
|||||||
* them, exactly as the items read does: a tile that can name people
|
* them, exactly as the items read does: a tile that can name people
|
||||||
* without photos is far better than a tile that shows nothing.
|
* without photos is far better than a tile that shows nothing.
|
||||||
*/
|
*/
|
||||||
async function readTeams(planId) {
|
async function readTeams(planId, stId = activeTypeId) {
|
||||||
const target = `${planPath(planId)}/team_members`;
|
const target = `${planPath(planId, stId)}/team_members`;
|
||||||
let body;
|
let body;
|
||||||
try {
|
try {
|
||||||
body = await pco.getAll(target, { include: 'team,person' });
|
body = await pco.getAll(target, { include: 'team,person' });
|
||||||
@@ -817,16 +931,16 @@ module.exports = {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const teams = shapeTeams(body, await readServiceTeams());
|
const teams = shapeTeams(body, await readServiceTeams(stId));
|
||||||
const photos = new Map();
|
|
||||||
const included = new Map();
|
const included = new Map();
|
||||||
for (const rec of body.included || []) included.set(`${rec.type}/${rec.id}`, rec);
|
for (const rec of body.included || []) included.set(`${rec.type}/${rec.id}`, rec);
|
||||||
for (const pp of body.data || []) {
|
for (const pp of body.data || []) {
|
||||||
const personRef = pp.relationships?.person?.data;
|
const personRef = pp.relationships?.person?.data;
|
||||||
const url = teamPhotoOf(pp.attributes, personRef ? included.get(`Person/${personRef.id}`) : null);
|
const url = teamPhotoOf(pp.attributes, personRef ? included.get(`Person/${personRef.id}`) : null);
|
||||||
if (url) photos.set(String(pp.id), url);
|
// Photos accumulate: a dashboard looking at another service type still
|
||||||
|
// asks this module for its people's faces.
|
||||||
|
if (url) teamPhotos.set(String(pp.id), url);
|
||||||
}
|
}
|
||||||
teamPhotos = photos;
|
|
||||||
teamsReadAt = Date.now();
|
teamsReadAt = Date.now();
|
||||||
return teams;
|
return teams;
|
||||||
}
|
}
|
||||||
@@ -854,6 +968,57 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The other selected service types, each with its own current plan, for a
|
||||||
|
* dashboard that has switched to one. Read on the slow lane and wrapped
|
||||||
|
* individually: a service type that fails here is reported in its own
|
||||||
|
* entry and costs the booth nothing else.
|
||||||
|
*/
|
||||||
|
let othersReadAt = 0;
|
||||||
|
|
||||||
|
async function readOtherTypes(force) {
|
||||||
|
if (!force && Date.now() - othersReadAt < OTHERS_MIN_MS) return;
|
||||||
|
othersReadAt = Date.now();
|
||||||
|
const others = {};
|
||||||
|
for (const stId of serviceTypes) {
|
||||||
|
if (stId === activeTypeId) continue;
|
||||||
|
const entry = { id: stId, name: '', plan: null, items: [], teams: [], teamsError: '', lastError: '' };
|
||||||
|
others[stId] = entry;
|
||||||
|
try {
|
||||||
|
entry.name = await typeName(stId);
|
||||||
|
const chosen = await choosePlanId(stId);
|
||||||
|
if (!chosen) continue;
|
||||||
|
const [planTimes, items] = await Promise.all([
|
||||||
|
readPlanTimes(chosen.id, stId),
|
||||||
|
readItems(chosen.id, stId),
|
||||||
|
]);
|
||||||
|
const plan = shapePlan(chosen, stId, planTimes);
|
||||||
|
// No service clock runs for a type nobody is following, so take the
|
||||||
|
// service that is current or next purely to lay the items out.
|
||||||
|
const times = serviceTimes(planTimes);
|
||||||
|
const now = Date.now();
|
||||||
|
const service = times.find((t) => (t.endsAt || t.startsAt + DEFAULT_SERVICE_LENGTH_MS) > now) || times[times.length - 1];
|
||||||
|
if (service) {
|
||||||
|
plan.serviceStartsAt = service.startsAt;
|
||||||
|
plan.serviceEndsAt = service.endsAt || 0;
|
||||||
|
plan.serviceName = service.name || '';
|
||||||
|
}
|
||||||
|
scheduleItems(items, plan.serviceStartsAt || plan.sortDate);
|
||||||
|
entry.plan = plan;
|
||||||
|
entry.items = items;
|
||||||
|
try {
|
||||||
|
entry.teams = await readTeams(chosen.id, stId);
|
||||||
|
} catch (err) {
|
||||||
|
entry.teamsError = err?.message || String(err);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
entry.lastError = err?.message || String(err);
|
||||||
|
log(`service type ${stId}: ${entry.lastError}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.others = others;
|
||||||
|
}
|
||||||
|
|
||||||
/** Options for a Teams tile's "Teams" switches — the plan's own teams, in its order. */
|
/** Options for a Teams tile's "Teams" switches — the plan's own teams, in its order. */
|
||||||
function teamOptions() {
|
function teamOptions() {
|
||||||
return { options: state.teams.map((t) => ({ value: t.id, label: t.name })) };
|
return { options: state.teams.map((t) => ({ value: t.id, label: t.name })) };
|
||||||
@@ -861,23 +1026,22 @@ module.exports = {
|
|||||||
|
|
||||||
|
|
||||||
async function readPlan() {
|
async function readPlan() {
|
||||||
if (!pco.configured || !serviceTypeId) return;
|
if (!pco.configured || !serviceTypes.length) return;
|
||||||
let planRec = null;
|
let planRec = null;
|
||||||
if (pinnedPlanId) {
|
const pin = parsePin(pinnedPlanId);
|
||||||
planRec = (await pco.get(planPath(pinnedPlanId)))?.data || null;
|
if (pin && pin.planId && pin.stId) {
|
||||||
|
await useServiceType(pin.stId);
|
||||||
|
planRec = (await pco.get(planPath(pin.planId, pin.stId)))?.data || null;
|
||||||
} else {
|
} else {
|
||||||
const chosen = await choosePlanId(serviceTypeId);
|
const chosen = await choosePlanAcross();
|
||||||
// Re-read the chosen plan by id: the list endpoint's attributes are the
|
if (chosen) {
|
||||||
// same, but this keeps one code path and picks up edits immediately.
|
await useServiceType(chosen.stId);
|
||||||
planRec = chosen ? (await pco.get(planPath(chosen.id)))?.data || chosen : null;
|
// Re-read the chosen plan by id: the list endpoint's attributes are
|
||||||
}
|
// the same, but this keeps one code path and picks up edits at once.
|
||||||
if (!state.serviceType.name) {
|
planRec = (await pco.get(planPath(chosen.rec.id, chosen.stId)))?.data || chosen.rec;
|
||||||
try {
|
} else {
|
||||||
const st = await pco.get(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}`);
|
// Nothing anywhere: still name a service type so the tile can say whose.
|
||||||
state.serviceType = { id: serviceTypeId, name: String(st?.data?.attributes?.name || '') };
|
await useServiceType(serviceTypes[0]);
|
||||||
} catch (err) {
|
|
||||||
if (err.status === 404) throw new Error(`Service type ${serviceTypeId} does not exist — pick another in the setup page`);
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!planRec) {
|
if (!planRec) {
|
||||||
@@ -890,10 +1054,11 @@ module.exports = {
|
|||||||
state.lastError = '';
|
state.lastError = '';
|
||||||
state.lastFetched = Date.now();
|
state.lastFetched = Date.now();
|
||||||
syncServiceSafe(Date.now(), null);
|
syncServiceSafe(Date.now(), null);
|
||||||
|
await readOtherTypes(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [planTimes, items] = await Promise.all([readPlanTimes(planRec.id), readItems(planRec.id)]);
|
const [planTimes, items] = await Promise.all([readPlanTimes(planRec.id), readItems(planRec.id)]);
|
||||||
const plan = shapePlan(planRec, serviceTypeId, planTimes);
|
const plan = shapePlan(planRec, activeTypeId, planTimes);
|
||||||
const planChanged = state.plan?.id !== plan.id;
|
const planChanged = state.plan?.id !== plan.id;
|
||||||
if (planChanged) {
|
if (planChanged) {
|
||||||
if (state.plan) log(`plan changed: ${state.plan.id} → ${plan.id} (${plan.dates} ${plan.title}) — service timing reset`);
|
if (state.plan) log(`plan changed: ${state.plan.id} → ${plan.id} (${plan.dates} ${plan.title}) — service timing reset`);
|
||||||
@@ -916,6 +1081,8 @@ module.exports = {
|
|||||||
// Service selection, item schedule and the running clock all follow
|
// Service selection, item schedule and the running clock all follow
|
||||||
// from the plan's service times — decided in one place.
|
// from the plan's service times — decided in one place.
|
||||||
syncServiceSafe(Date.now(), null);
|
syncServiceSafe(Date.now(), null);
|
||||||
|
// Whatever else is selected, for a dashboard that has switched to it.
|
||||||
|
await readOtherTypes(planChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Say, once per plan, which item a document named like the plan will land on. */
|
/** Say, once per plan, which item a document named like the plan will land on. */
|
||||||
@@ -943,11 +1110,11 @@ module.exports = {
|
|||||||
* list — the gear window then says there is nothing to choose from yet.
|
* list — the gear window then says there is nothing to choose from yet.
|
||||||
*/
|
*/
|
||||||
async function noteCategoryOptions() {
|
async function noteCategoryOptions() {
|
||||||
if (!pco.configured || !serviceTypeId) return [];
|
if (!pco.configured || !activeTypeId) return [];
|
||||||
if (Date.now() - noteCategories.at < NOTE_CATEGORIES_TTL_MS) return noteCategories.options;
|
if (Date.now() - noteCategories.at < NOTE_CATEGORIES_TTL_MS) return noteCategories.options;
|
||||||
if (!noteCategoriesInFlight) {
|
if (!noteCategoriesInFlight) {
|
||||||
noteCategoriesInFlight = (async () => {
|
noteCategoriesInFlight = (async () => {
|
||||||
const body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/item_note_categories`);
|
const body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(activeTypeId)}/item_note_categories`);
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const options = (body.data || [])
|
const options = (body.data || [])
|
||||||
.filter((c) => !c?.attributes?.deleted_at)
|
.filter((c) => !c?.attributes?.deleted_at)
|
||||||
@@ -1713,11 +1880,11 @@ module.exports = {
|
|||||||
if (!pco.configured) {
|
if (!pco.configured) {
|
||||||
state.lastError = 'No Planning Center credentials — add a Client ID + Secret (or OAuth token) in /admin';
|
state.lastError = 'No Planning Center credentials — add a Client ID + Secret (or OAuth token) in /admin';
|
||||||
log(state.lastError);
|
log(state.lastError);
|
||||||
} else if (!serviceTypeId) {
|
} else if (!serviceTypes.length) {
|
||||||
state.lastError = 'No service type selected — choose one under PCO Plan in /admin';
|
state.lastError = 'No service type selected — choose one under PCO Plan in /admin';
|
||||||
log(state.lastError);
|
log(state.lastError);
|
||||||
} else {
|
} else {
|
||||||
log(`reading service type ${serviceTypeId} from ${API_BASE} (${pco.method})${pinnedPlanId ? `, plan ${pinnedPlanId} pinned` : ''}`);
|
log(`reading ${serviceTypes.length} service type${serviceTypes.length === 1 ? '' : 's'} (${serviceTypes.join(', ')}) from ${API_BASE} (${pco.method})${pinnedPlanId ? `, plan ${pinnedPlanId} pinned` : ''}`);
|
||||||
schedulePoll(0);
|
schedulePoll(0);
|
||||||
}
|
}
|
||||||
log(
|
log(
|
||||||
@@ -1777,6 +1944,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
listPlans,
|
listPlans,
|
||||||
pickPlan,
|
pickPlan,
|
||||||
|
typeName,
|
||||||
timersSnapshot,
|
timersSnapshot,
|
||||||
documentOptions,
|
documentOptions,
|
||||||
noteCategoryOptions,
|
noteCategoryOptions,
|
||||||
@@ -1805,11 +1973,11 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
health() {
|
health() {
|
||||||
if (!pco.configured) return { status: 'error', message: 'No Planning Center credentials configured' };
|
if (!pco.configured) return { status: 'error', message: 'No Planning Center credentials configured' };
|
||||||
if (!serviceTypeId) return { status: 'error', message: 'No service type selected — pick one in the Service type list below' };
|
if (!serviceTypes.length) return { status: 'error', message: 'No service type selected — tick one in the Service types list below' };
|
||||||
if (!state.reachable) {
|
if (!state.reachable) {
|
||||||
return { status: state.lastFetched ? 'error' : 'connecting', message: state.lastError || 'Reading the plan…' };
|
return { status: state.lastFetched ? 'error' : 'connecting', message: state.lastError || 'Reading the plan…' };
|
||||||
}
|
}
|
||||||
const name = state.serviceType.name || serviceTypeId;
|
const name = state.serviceType.name || activeTypeId;
|
||||||
const plan = state.plan ? ` · ${state.plan.dates}${state.plan.title ? ` — ${state.plan.title}` : ''}` : ' · no plan found';
|
const plan = state.plan ? ` · ${state.plan.dates}${state.plan.title ? ` — ${state.plan.title}` : ''}` : ' · no plan found';
|
||||||
const service = win ? ` · ${serviceLabel(win)} ${svc.phase}` : '';
|
const service = win ? ` · ${serviceLabel(win)} ${svc.phase}` : '';
|
||||||
const live = wantFeed ? (state.live.available ? '' : ' · ProPresenter feed unavailable') : '';
|
const live = wantFeed ? (state.live.available ? '' : ' · ProPresenter feed unavailable') : '';
|
||||||
@@ -1986,8 +2154,15 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
return names.join(' › ');
|
return names.join(' › ');
|
||||||
};
|
};
|
||||||
|
const live = current.getState().serviceType?.id || '';
|
||||||
const options = serviceTypes
|
const options = serviceTypes
|
||||||
.map((st) => ({ value: st.id, label: st.name, group: pathOf(st.parentId) }))
|
.map((st) => ({
|
||||||
|
value: st.id,
|
||||||
|
// Which one's plan is actually on screen, so the list says so
|
||||||
|
// rather than leaving it to be worked out.
|
||||||
|
label: st.id === live ? `${st.name} — following now` : st.name,
|
||||||
|
group: pathOf(st.parentId),
|
||||||
|
}))
|
||||||
.sort((a, b) => a.group.localeCompare(b.group) || a.label.localeCompare(b.label));
|
.sort((a, b) => a.group.localeCompare(b.group) || a.label.localeCompare(b.label));
|
||||||
json(res, 200, { options });
|
json(res, 200, { options });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1997,23 +2172,28 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for the admin page's "Plan" select, for the service type the
|
* Options for the admin page's "Plan" select, across every service type
|
||||||
* admin currently has picked (?serviceTypeId=…): the upcoming and recent
|
* ticked above (?serviceTypes=a,b,c), grouped by which one each belongs
|
||||||
* plans, with the one Automatic would choose marked.
|
* to. A value is "<serviceType>/<plan>", because a plan id alone does
|
||||||
|
* not say whose it is once there is more than one list.
|
||||||
*/
|
*/
|
||||||
'GET /plans': async (req, res) => {
|
'GET /plans': async (req, res) => {
|
||||||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||||
const { pco } = current;
|
const { pco } = current;
|
||||||
const params = new URLSearchParams(req.search || '');
|
const params = new URLSearchParams(req.search || '');
|
||||||
const stId = String(params.get('serviceTypeId') || '').trim();
|
const asked = String(params.get('serviceTypes') || '').split(',').map((v) => v.trim()).filter(Boolean);
|
||||||
const auto = { value: '', label: 'Automatic — today\'s plan, else the next upcoming' };
|
const auto = { value: '', label: 'Automatic — today\'s plan, else the next upcoming' };
|
||||||
if (!pco.configured || !stId) return json(res, 200, { options: [auto] });
|
if (!pco.configured || !asked.length) return json(res, 200, { options: [auto] });
|
||||||
|
const options = [auto];
|
||||||
|
let failure = '';
|
||||||
|
for (const stId of asked) {
|
||||||
try {
|
try {
|
||||||
const plans = await current.listPlans(stId, { future: 8, past: 6 });
|
const [name, plans] = await Promise.all([
|
||||||
|
current.typeName(stId),
|
||||||
|
current.listPlans(stId, { future: 8, past: 6 }),
|
||||||
|
]);
|
||||||
const picked = current.pickPlan(plans);
|
const picked = current.pickPlan(plans);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const options = [auto];
|
|
||||||
// Upcoming first (soonest at the top), then the recent ones.
|
|
||||||
const ordered = [
|
const ordered = [
|
||||||
...plans.filter((p) => p.sortDate >= now - 12 * 3600000),
|
...plans.filter((p) => p.sortDate >= now - 12 * 3600000),
|
||||||
...plans.filter((p) => p.sortDate < now - 12 * 3600000).reverse(),
|
...plans.filter((p) => p.sortDate < now - 12 * 3600000).reverse(),
|
||||||
@@ -2024,13 +2204,14 @@ module.exports = {
|
|||||||
if (a.series_title) bits.push(String(a.series_title));
|
if (a.series_title) bits.push(String(a.series_title));
|
||||||
if (a.title) bits.push(String(a.title));
|
if (a.title) bits.push(String(a.title));
|
||||||
let label = bits.join(' — ');
|
let label = bits.join(' — ');
|
||||||
if (picked && rec.id === picked.id) label += ' (Automatic picks this)';
|
if (picked && rec.id === picked.id) label += ' (this one, on Automatic)';
|
||||||
options.push({ value: String(rec.id), label, group: sortDate >= now - 12 * 3600000 ? 'Upcoming' : 'Recent' });
|
options.push({ value: `${stId}/${rec.id}`, label, group: name });
|
||||||
}
|
}
|
||||||
json(res, 200, { options });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
json(res, err?.status === 401 ? 401 : 502, { options: [auto], error: err?.message || String(err) });
|
failure = err?.message || String(err);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
json(res, 200, failure && options.length === 1 ? { options, error: failure } : { options });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -744,3 +744,51 @@
|
|||||||
[data-module="pco-plan"] .pk-wrap.is-stale .pk-key { opacity: 0.55; }
|
[data-module="pco-plan"] .pk-wrap.is-stale .pk-key { opacity: 0.55; }
|
||||||
[data-module="pco-plan"] .pk-wrap.is-stale .pk-name,
|
[data-module="pco-plan"] .pk-wrap.is-stale .pk-name,
|
||||||
[data-module="pco-plan"] .pk-wrap.is-stale .pk-next { opacity: 0.6; }
|
[data-module="pco-plan"] .pk-wrap.is-stale .pk-next { opacity: 0.6; }
|
||||||
|
|
||||||
|
/* ── the service-type menu, and a tile looking at another one ────────── */
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 9px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-menu-item:hover { background: var(--border); }
|
||||||
|
[data-module="pco-plan"] .pp-menu-tick {
|
||||||
|
width: 12px;
|
||||||
|
flex: none;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-menu-item.is-on .pp-menu-tick { visibility: visible; }
|
||||||
|
[data-module="pco-plan"] .pp-menu-name { flex: 1; min-width: 0; font-weight: 600; }
|
||||||
|
[data-module="pco-plan"] .pp-menu-note {
|
||||||
|
flex: none;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Looking at a service type other than the one running: the top bar carries
|
||||||
|
a quiet stripe so nobody reads this as the live plan. */
|
||||||
|
[data-module="pco-plan"] .pp-wrap.is-other-type .pp-head,
|
||||||
|
[data-module="pco-plan"] .tm-wrap.is-other-type .tm-head {
|
||||||
|
background: color-mix(in srgb, var(--warn) 9%, transparent);
|
||||||
|
border-bottom-color: color-mix(in srgb, var(--warn) 45%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pk-wrap.is-other-type {
|
||||||
|
box-shadow: inset 0 2px 0 color-mix(in srgb, var(--warn) 45%, transparent);
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ const DEFAULT_TEMPLATE = '{serviceType} • {date} | {people} on {teams}';
|
|||||||
/** Colours for "Tint teams", walked in order so neighbouring teams differ. */
|
/** Colours for "Tint teams", walked in order so neighbouring teams differ. */
|
||||||
const TEAM_HUES = [152, 205, 32, 275, 12, 180, 320, 95, 240, 50];
|
const TEAM_HUES = [152, 205, 32, 275, 12, 180, 320, 95, 240, 50];
|
||||||
|
|
||||||
|
import { viewOf, onViewedType, addServiceTypeSwitch } from "./viewed-type.js";
|
||||||
|
|
||||||
export default function createTeams({ root, moduleApi }) {
|
export default function createTeams({ root, moduleApi }) {
|
||||||
let state = null;
|
let state = null;
|
||||||
let feedOffline = false;
|
let feedOffline = false;
|
||||||
let stream = null;
|
let stream = null;
|
||||||
let lastKey = '';
|
let lastKey = '';
|
||||||
|
let view = viewOf(null);
|
||||||
|
let offViewed = null;
|
||||||
|
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="tm-wrap">
|
<div class="tm-wrap">
|
||||||
@@ -39,6 +43,8 @@ export default function createTeams({ root, moduleApi }) {
|
|||||||
|
|
||||||
/* ── title-bar controls ─────────────────────────────────────────── */
|
/* ── title-bar controls ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const switcher = addServiceTypeSwitch({ moduleApi, getState: () => state });
|
||||||
|
|
||||||
const refreshBtn = moduleApi.header.addButton({
|
const refreshBtn = moduleApi.header.addButton({
|
||||||
icon: REFRESH_SVG,
|
icon: REFRESH_SVG,
|
||||||
title: 'Re-read from Planning Center',
|
title: 'Re-read from Planning Center',
|
||||||
@@ -92,7 +98,7 @@ export default function createTeams({ root, moduleApi }) {
|
|||||||
/* ── which teams and people to show ─────────────────────────────── */
|
/* ── which teams and people to show ─────────────────────────────── */
|
||||||
|
|
||||||
function visibleTeams() {
|
function visibleTeams() {
|
||||||
const teams = Array.isArray(state?.teams) ? state.teams : [];
|
const teams = Array.isArray(view.teams) ? view.teams : [];
|
||||||
const wantTeams = chosen('teams');
|
const wantTeams = chosen('teams');
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const team of teams) {
|
for (const team of teams) {
|
||||||
@@ -118,8 +124,8 @@ export default function createTeams({ root, moduleApi }) {
|
|||||||
if (!state.reachable) {
|
if (!state.reachable) {
|
||||||
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
|
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
|
||||||
}
|
}
|
||||||
if (state.teamsError) return moduleApi.setStatus('error', `Team list: ${state.teamsError}`);
|
if (view.teamsError) return moduleApi.setStatus('error', `Team list: ${view.teamsError}`);
|
||||||
const people = (state.teams || []).reduce((n, t) => n + t.members.length, 0);
|
const people = (view.teams || []).reduce((n, t) => n + t.members.length, 0);
|
||||||
moduleApi.setStatus('ok', `${people} scheduled · read ${new Date(state.lastFetched).toLocaleTimeString()}`);
|
moduleApi.setStatus('ok', `${people} scheduled · read ${new Date(state.lastFetched).toLocaleTimeString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,14 +133,14 @@ export default function createTeams({ root, moduleApi }) {
|
|||||||
|
|
||||||
/** The counts describe what this tile shows, not what the plan holds. */
|
/** The counts describe what this tile shows, not what the plan holds. */
|
||||||
function templateValues() {
|
function templateValues() {
|
||||||
const plan = state?.plan || {};
|
const plan = view.plan || {};
|
||||||
const teams = visibleTeams();
|
const teams = visibleTeams();
|
||||||
const people = teams.reduce((n, t) => n + t.members.length, 0);
|
const people = teams.reduce((n, t) => n + t.members.length, 0);
|
||||||
const confirmed = teams.reduce((n, t) => n + t.counts.confirmed, 0);
|
const confirmed = teams.reduce((n, t) => n + t.counts.confirmed, 0);
|
||||||
return {
|
return {
|
||||||
series: plan.seriesTitle || '',
|
series: plan.seriesTitle || '',
|
||||||
part: plan.title || '',
|
part: plan.title || '',
|
||||||
servicetype: state?.serviceType?.name || '',
|
servicetype: view.name || '',
|
||||||
date: plan.dates || '',
|
date: plan.dates || '',
|
||||||
shortdate: plan.shortDates || plan.dates || '',
|
shortdate: plan.shortDates || plan.dates || '',
|
||||||
time: plan.serviceStartsAt
|
time: plan.serviceStartsAt
|
||||||
@@ -171,11 +177,11 @@ export default function createTeams({ root, moduleApi }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderHead() {
|
function renderHead() {
|
||||||
const show = on('showTopBar') && state?.plan;
|
const show = on('showTopBar') && view.plan;
|
||||||
headEl.hidden = !show;
|
headEl.hidden = !show;
|
||||||
if (!show) return;
|
if (!show) return;
|
||||||
const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
|
const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
|
||||||
titleEl.textContent = line1 || line2 || state.serviceType?.name || 'Teams';
|
titleEl.textContent = line1 || line2 || view.name || 'Teams';
|
||||||
subEl.textContent = line1 ? line2 || '' : '';
|
subEl.textContent = line1 ? line2 || '' : '';
|
||||||
subEl.hidden = !subEl.textContent;
|
subEl.hidden = !subEl.textContent;
|
||||||
}
|
}
|
||||||
@@ -199,19 +205,19 @@ export default function createTeams({ root, moduleApi }) {
|
|||||||
link: { href: '/admin', text: 'Open Admin' },
|
link: { href: '/admin', text: 'Open Admin' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!state.reachable && !state.plan) {
|
if (!state.reachable && !view.plan) {
|
||||||
return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' };
|
return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' };
|
||||||
}
|
}
|
||||||
if (!state.plan) {
|
if (!view.plan) {
|
||||||
return {
|
return {
|
||||||
title: 'No plan found',
|
title: 'No plan found',
|
||||||
hint: `${state.serviceType?.name || 'This service type'} has no upcoming or recent plans.`,
|
hint: `${view.name || 'This service type'} has no upcoming or recent plans.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!(state.teams || []).length) {
|
if (!(view.teams || []).length) {
|
||||||
return {
|
return {
|
||||||
title: 'Nobody scheduled yet',
|
title: 'Nobody scheduled yet',
|
||||||
hint: state.teamsError || 'This plan has no team members in Planning Center.',
|
hint: view.teamsError || 'This plan has no team members in Planning Center.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!visibleTeams().length) {
|
if (!visibleTeams().length) {
|
||||||
@@ -372,7 +378,7 @@ export default function createTeams({ root, moduleApi }) {
|
|||||||
// Rebuild only when the roster or the settings actually changed: the
|
// Rebuild only when the roster or the settings actually changed: the
|
||||||
// avatars are <img>s and a needless rebuild makes them flicker.
|
// avatars are <img>s and a needless rebuild makes them flicker.
|
||||||
const teams = visibleTeams();
|
const teams = visibleTeams();
|
||||||
const key = JSON.stringify([teams, cfg()]);
|
const key = JSON.stringify([view.id, teams, cfg()]);
|
||||||
if (key === lastKey) return;
|
if (key === lastKey) return;
|
||||||
lastKey = key;
|
lastKey = key;
|
||||||
bodyEl.innerHTML = '';
|
bodyEl.innerHTML = '';
|
||||||
@@ -380,7 +386,10 @@ export default function createTeams({ root, moduleApi }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
|
view = viewOf(state);
|
||||||
|
switcher.sync();
|
||||||
wrap.classList.toggle('is-offline', feedOffline);
|
wrap.classList.toggle('is-offline', feedOffline);
|
||||||
|
wrap.classList.toggle('is-other-type', Boolean(state) && !view.live);
|
||||||
wrap.classList.toggle('tint-rows', Boolean(cfg().tintTeams && cfg().tintRows));
|
wrap.classList.toggle('tint-rows', Boolean(cfg().tintTeams && cfg().tintRows));
|
||||||
wrap.classList.toggle('header-gradient', on('headerGradient'));
|
wrap.classList.toggle('header-gradient', on('headerGradient'));
|
||||||
renderStatus();
|
renderStatus();
|
||||||
@@ -421,9 +430,12 @@ export default function createTeams({ root, moduleApi }) {
|
|||||||
.then((body) => { if (body && !state) accept(body); })
|
.then((body) => { if (body && !state) accept(body); })
|
||||||
.catch(() => { /* the stream will cover it */ });
|
.catch(() => { /* the stream will cover it */ });
|
||||||
connect();
|
connect();
|
||||||
|
offViewed = onViewedType(() => { lastKey = ''; render(); });
|
||||||
render();
|
render();
|
||||||
},
|
},
|
||||||
stop() {
|
stop() {
|
||||||
|
offViewed?.();
|
||||||
|
offViewed = null;
|
||||||
stream?.close();
|
stream?.close();
|
||||||
stream = null;
|
stream = null;
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/* PCO Plan — which service type THIS dashboard is looking at.
|
||||||
|
|
||||||
|
The module's server follows one service type at a time: whichever the
|
||||||
|
admin's priority order lands on. A dashboard may want a different one on
|
||||||
|
screen — the other campus, the youth plan — without changing what the
|
||||||
|
server follows or what any other screen sees.
|
||||||
|
|
||||||
|
That choice belongs to the browser, so it lives here: one value for the
|
||||||
|
whole page, kept in localStorage and shared between every PCO Plan tile on
|
||||||
|
it, so switching from one tile moves them all together. */
|
||||||
|
|
||||||
|
const KEY = 'pco-plan:viewed-service-type';
|
||||||
|
|
||||||
|
/** One bus per page, however many tiles import this. */
|
||||||
|
function bus() {
|
||||||
|
if (!window.__pcoPlanViewed) {
|
||||||
|
let value = '';
|
||||||
|
try {
|
||||||
|
value = String(localStorage.getItem(KEY) || '');
|
||||||
|
} catch {
|
||||||
|
value = ''; // private window, storage off: the choice just won't persist
|
||||||
|
}
|
||||||
|
window.__pcoPlanViewed = { value, listeners: new Set() };
|
||||||
|
}
|
||||||
|
return window.__pcoPlanViewed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** '' means "whatever the server is following" — the normal case. */
|
||||||
|
export function viewedType() {
|
||||||
|
return bus().value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setViewedType(id) {
|
||||||
|
const b = bus();
|
||||||
|
const next = String(id || '');
|
||||||
|
if (next === b.value) return;
|
||||||
|
b.value = next;
|
||||||
|
try {
|
||||||
|
if (next) localStorage.setItem(KEY, next);
|
||||||
|
else localStorage.removeItem(KEY);
|
||||||
|
} catch { /* not persisted, still shared for this page */ }
|
||||||
|
for (const fn of [...b.listeners]) {
|
||||||
|
try {
|
||||||
|
fn(next);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[pco-plan] viewed-type listener failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns an unsubscribe — call it from the tile's stop(). */
|
||||||
|
export function onViewedType(fn) {
|
||||||
|
const b = bus();
|
||||||
|
b.listeners.add(fn);
|
||||||
|
return () => b.listeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plan a tile should draw, given the whole state and this page's choice:
|
||||||
|
* the one the server follows, or one of the others it also reads.
|
||||||
|
*
|
||||||
|
* `live` is false for anything but the followed one — the ProPresenter match,
|
||||||
|
* the service clock and the timers belong to the service actually running, so
|
||||||
|
* a tile looking elsewhere must not pretend to have them.
|
||||||
|
*/
|
||||||
|
export function viewOf(state) {
|
||||||
|
const empty = { id: '', name: '', plan: null, items: [], teams: [], teamsError: '', live: true, lastError: '' };
|
||||||
|
if (!state) return empty;
|
||||||
|
const wanted = viewedType();
|
||||||
|
const activeId = state.serviceType?.id || '';
|
||||||
|
if (!wanted || wanted === activeId) {
|
||||||
|
return {
|
||||||
|
id: activeId,
|
||||||
|
name: state.serviceType?.name || '',
|
||||||
|
plan: state.plan,
|
||||||
|
items: state.items || [],
|
||||||
|
teams: state.teams || [],
|
||||||
|
teamsError: state.teamsError || '',
|
||||||
|
live: true,
|
||||||
|
lastError: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const other = (state.others || {})[wanted];
|
||||||
|
if (!other) {
|
||||||
|
// Ticked off in admin since this dashboard chose it, or not read yet.
|
||||||
|
return { ...empty, id: wanted, live: false, lastError: 'That service type is no longer selected' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: other.id,
|
||||||
|
name: other.name,
|
||||||
|
plan: other.plan,
|
||||||
|
items: other.items || [],
|
||||||
|
teams: other.teams || [],
|
||||||
|
teamsError: other.teamsError || '',
|
||||||
|
live: false,
|
||||||
|
lastError: other.lastError || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every service type a dashboard may switch to, in the admin's priority order. */
|
||||||
|
export function choices(state) {
|
||||||
|
const out = [];
|
||||||
|
const activeId = state?.serviceType?.id || '';
|
||||||
|
for (const id of state?.serviceTypes || []) {
|
||||||
|
if (id === activeId) out.push({ id, name: state.serviceType?.name || id, following: true });
|
||||||
|
else out.push({ id, name: (state.others || {})[id]?.name || id, following: false });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SWITCH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 8h13l-3-3"/><path d="M20 16H7l3 3"/></svg>';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The title-bar control that switches this dashboard between the selected
|
||||||
|
* service types. Added by every PCO Plan tile, because switching from any of
|
||||||
|
* them moves all of them; it hides itself whenever there is only one service
|
||||||
|
* type to choose from, which is the usual case.
|
||||||
|
*
|
||||||
|
* Returns { sync } — call it when the state arrives so the control appears,
|
||||||
|
* disappears or re-labels itself.
|
||||||
|
*/
|
||||||
|
export function addServiceTypeSwitch({ moduleApi, getState }) {
|
||||||
|
const menu = moduleApi.header.addMenu({
|
||||||
|
icon: SWITCH_SVG,
|
||||||
|
title: 'Service type shown on this dashboard',
|
||||||
|
build(el) {
|
||||||
|
el.classList.add('pp-menu');
|
||||||
|
const state = getState();
|
||||||
|
const wanted = viewedType();
|
||||||
|
const rows = [
|
||||||
|
{ id: '', name: 'Whatever is live', following: false, auto: true },
|
||||||
|
...choices(state),
|
||||||
|
];
|
||||||
|
for (const row of rows) {
|
||||||
|
const item = document.createElement('button');
|
||||||
|
item.type = 'button';
|
||||||
|
item.className = 'pp-menu-item' + (row.id === wanted ? ' is-on' : '');
|
||||||
|
const tick = document.createElement('span');
|
||||||
|
tick.className = 'pp-menu-tick';
|
||||||
|
tick.textContent = '✓';
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'pp-menu-name';
|
||||||
|
name.textContent = row.name;
|
||||||
|
item.append(tick, name);
|
||||||
|
if (row.following) {
|
||||||
|
const note = document.createElement('span');
|
||||||
|
note.className = 'pp-menu-note';
|
||||||
|
note.textContent = 'live';
|
||||||
|
item.appendChild(note);
|
||||||
|
}
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
setViewedType(row.id);
|
||||||
|
menu.close();
|
||||||
|
});
|
||||||
|
el.appendChild(item);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
sync() {
|
||||||
|
const state = getState();
|
||||||
|
// One service type is no choice at all — rule 11, nothing in the bar
|
||||||
|
// that has nothing to do.
|
||||||
|
const many = (state?.serviceTypes || []).length > 1;
|
||||||
|
menu.button.hidden = !many;
|
||||||
|
const wanted = viewedType();
|
||||||
|
menu.button.classList.toggle('active', Boolean(wanted) && wanted !== (state?.serviceType?.id || ''));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.10.2",
|
"version": "1.12.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",
|
||||||
|
|||||||
@@ -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));
|
||||||
@@ -581,3 +648,55 @@ details.admin-fold > summary.admin-fold-title:hover { color: var(--accent); }
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* an orderable multiselect (manifest `"orderable": true`): not a ticklist but
|
||||||
|
a list built on purpose — the picks, numbered, each with a nudge either way
|
||||||
|
and a way out, over a dropdown holding whatever is left to add. Their
|
||||||
|
position is the setting, so it has to be visible and movable. */
|
||||||
|
.multiselect-row.is-picked {
|
||||||
|
gap: 6px;
|
||||||
|
cursor: default;
|
||||||
|
padding: 4px 7px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
.multiselect-add {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 2px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.multiselect-add:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.multiselect-add:disabled { color: var(--muted); cursor: default; }
|
||||||
|
.multiselect-rank {
|
||||||
|
flex: none;
|
||||||
|
min-width: 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.multiselect-row .switch-label { flex: 1; min-width: 0; }
|
||||||
|
.multiselect-move {
|
||||||
|
flex: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.multiselect-move:hover:not(:disabled) { color: var(--text); border-color: var(--accent); }
|
||||||
|
.multiselect-move:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|||||||
+279
-14
@@ -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();
|
||||||
@@ -328,15 +333,31 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
|
|||||||
span.textContent = label;
|
span.textContent = label;
|
||||||
const list = document.createElement('div');
|
const list = document.createElement('div');
|
||||||
list.className = 'multiselect';
|
list.className = 'multiselect';
|
||||||
const chosen = new Set((Array.isArray(value) ? value : []).map(String).filter(Boolean));
|
// Kept as an ORDER, not a set: with `orderable` the position is the
|
||||||
|
// value (a priority), and even without it the saved order is what the
|
||||||
|
// shell stores, so losing it here would quietly reshuffle a module's list.
|
||||||
|
let chosen = (Array.isArray(value) ? value : []).map(String).filter(Boolean);
|
||||||
|
const orderable = Boolean(spec.orderable);
|
||||||
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
|
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
|
||||||
const fill = (options) => {
|
const optLabel = (opt) => String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
|
||||||
|
const syncChosen = () => {
|
||||||
for (const input of list.querySelectorAll('input')) {
|
for (const input of list.querySelectorAll('input')) {
|
||||||
if (input.checked) chosen.add(input.value);
|
const v = input.value;
|
||||||
else chosen.delete(input.value);
|
const at = chosen.indexOf(v);
|
||||||
|
if (input.checked && at < 0) chosen.push(v); // newly on: lowest priority
|
||||||
|
else if (!input.checked && at >= 0) chosen.splice(at, 1);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
const move = (v, by) => {
|
||||||
|
const at = chosen.indexOf(v);
|
||||||
|
const to = at + by;
|
||||||
|
if (at < 0 || to < 0 || to >= chosen.length) return;
|
||||||
|
chosen.splice(to, 0, chosen.splice(at, 1)[0]);
|
||||||
|
};
|
||||||
|
const fill = (options) => {
|
||||||
|
syncChosen();
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
const missing = [...chosen].filter((v) => !options.some((opt) => optValue(opt) === v));
|
const missing = chosen.filter((v) => !options.some((opt) => optValue(opt) === v));
|
||||||
const all = [...options, ...missing.map((v) => ({ value: v, label: `${v} (saved — not listed now)` }))];
|
const all = [...options, ...missing.map((v) => ({ value: v, label: `${v} (saved — not listed now)` }))];
|
||||||
for (const opt of all) {
|
for (const opt of all) {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
@@ -346,13 +367,13 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
|
|||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'checkbox';
|
input.type = 'checkbox';
|
||||||
input.value = optValue(opt);
|
input.value = optValue(opt);
|
||||||
input.checked = chosen.has(input.value);
|
input.checked = chosen.includes(input.value);
|
||||||
const track = document.createElement('span');
|
const track = document.createElement('span');
|
||||||
track.className = 'track';
|
track.className = 'track';
|
||||||
toggle.append(input, track);
|
toggle.append(input, track);
|
||||||
const text = document.createElement('span');
|
const text = document.createElement('span');
|
||||||
text.className = 'switch-label';
|
text.className = 'switch-label';
|
||||||
text.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
|
text.textContent = optLabel(opt);
|
||||||
row.append(toggle, text);
|
row.append(toggle, text);
|
||||||
row.addEventListener('click', (e) => {
|
row.addEventListener('click', (e) => {
|
||||||
if (e.target === input) return; // the box toggles itself
|
if (e.target === input) return; // the box toggles itself
|
||||||
@@ -368,21 +389,114 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
|
|||||||
list.appendChild(none);
|
list.appendChild(none);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fill(Array.isArray(spec.options) ? spec.options : []);
|
// A list built on purpose, in an order that means something: the picks,
|
||||||
|
// each numbered and movable, and a dropdown holding everything not picked
|
||||||
|
// yet. Switches cannot carry an order — and an account with forty service
|
||||||
|
// types is forty switches to read past before finding the two that matter.
|
||||||
|
const fillPicked = (options) => {
|
||||||
|
list.innerHTML = '';
|
||||||
|
const byValue = new Map(options.map((opt) => [optValue(opt), opt]));
|
||||||
|
chosen.forEach((v, i) => {
|
||||||
|
const opt = byValue.get(v);
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'multiselect-row is-picked';
|
||||||
|
const rank = document.createElement('span');
|
||||||
|
rank.className = 'multiselect-rank';
|
||||||
|
rank.textContent = String(i + 1);
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.className = 'switch-label';
|
||||||
|
text.textContent = opt ? optLabel(opt) : `${v} (saved — not listed now)`;
|
||||||
|
const btn = (glyph, title, on, off) => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.className = 'multiselect-move';
|
||||||
|
b.textContent = glyph;
|
||||||
|
b.title = title;
|
||||||
|
b.disabled = Boolean(off);
|
||||||
|
b.addEventListener('click', (e) => { e.preventDefault(); on(); });
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
row.append(
|
||||||
|
rank,
|
||||||
|
text,
|
||||||
|
btn('▲', 'Higher priority', () => { move(v, -1); changed(); }, i === 0),
|
||||||
|
btn('▼', 'Lower priority', () => { move(v, 1); changed(); }, i === chosen.length - 1),
|
||||||
|
btn('✕', 'Remove', () => { chosen.splice(chosen.indexOf(v), 1); changed(); }),
|
||||||
|
);
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
if (!chosen.length) {
|
||||||
|
const none = document.createElement('div');
|
||||||
|
none.className = 'multiselect-empty';
|
||||||
|
none.textContent = 'Nothing chosen yet.';
|
||||||
|
list.appendChild(none);
|
||||||
|
}
|
||||||
|
// The adder. Its own change bubbles to the field, which is what any
|
||||||
|
// dependent list listens for, so nothing extra is dispatched here.
|
||||||
|
const add = document.createElement('select');
|
||||||
|
add.className = 'multiselect-add';
|
||||||
|
const left = options.filter((opt) => !chosen.includes(optValue(opt)));
|
||||||
|
const head = document.createElement('option');
|
||||||
|
head.value = '';
|
||||||
|
head.textContent = left.length
|
||||||
|
? (spec.addLabel || 'Add one…')
|
||||||
|
: (options.length ? 'Everything is added' : 'Nothing to choose from yet');
|
||||||
|
add.appendChild(head);
|
||||||
|
add.disabled = !left.length;
|
||||||
|
const groups = new Map();
|
||||||
|
for (const opt of left) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = optValue(opt);
|
||||||
|
o.textContent = optLabel(opt);
|
||||||
|
const g = typeof opt === 'object' && opt.group ? String(opt.group) : '';
|
||||||
|
if (!g) { add.appendChild(o); continue; }
|
||||||
|
if (!groups.has(g)) {
|
||||||
|
const og = document.createElement('optgroup');
|
||||||
|
og.label = g;
|
||||||
|
groups.set(g, og);
|
||||||
|
add.appendChild(og);
|
||||||
|
}
|
||||||
|
groups.get(g).appendChild(o);
|
||||||
|
}
|
||||||
|
add.addEventListener('change', () => {
|
||||||
|
if (!add.value) return;
|
||||||
|
chosen.push(add.value); // added last: lowest priority
|
||||||
|
redraw();
|
||||||
|
});
|
||||||
|
list.appendChild(add);
|
||||||
|
};
|
||||||
|
// The options last drawn, so a reorder or a pick can redraw without asking
|
||||||
|
// the module for its list again.
|
||||||
|
let lastOptions = Array.isArray(spec.options) ? spec.options : [];
|
||||||
|
const redraw = () => (orderable ? fillPicked(lastOptions) : fill(lastOptions));
|
||||||
|
// Moving or removing changes the value without any control firing, so say so.
|
||||||
|
const changed = () => {
|
||||||
|
redraw();
|
||||||
|
list.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
};
|
||||||
|
redraw();
|
||||||
if (spec.optionsRoute && moduleId) {
|
if (spec.optionsRoute && moduleId) {
|
||||||
refetch = (query) => {
|
refetch = (query) => {
|
||||||
const qs = query && Object.keys(query).length ? `?${new URLSearchParams(query)}` : '';
|
const qs = query && Object.keys(query).length ? `?${new URLSearchParams(query)}` : '';
|
||||||
return fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}${qs}`)
|
return fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}${qs}`)
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
.then((body) => {
|
.then((body) => {
|
||||||
if (Array.isArray(body?.options)) fill(body.options);
|
if (Array.isArray(body?.options)) {
|
||||||
|
lastOptions = body.options;
|
||||||
|
redraw();
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => { /* keep the static/saved options */ });
|
.catch(() => { /* keep the static/saved options */ });
|
||||||
};
|
};
|
||||||
if (!spec.optionsDependsOn) refetch();
|
if (!spec.optionsDependsOn) refetch();
|
||||||
}
|
}
|
||||||
field.append(span, list);
|
field.append(span, list);
|
||||||
read = () => [...list.querySelectorAll('input:checked')].map((input) => input.value);
|
read = () => {
|
||||||
|
// With `orderable` the list IS the value, in the order it is shown.
|
||||||
|
if (orderable) return [...chosen];
|
||||||
|
syncChosen();
|
||||||
|
return [...list.querySelectorAll('input:checked')].map((input) => input.value);
|
||||||
|
};
|
||||||
} else if (type === 'color') {
|
} else if (type === 'color') {
|
||||||
// A swatch picker; the value is always a #rrggbb string.
|
// A swatch picker; the value is always a #rrggbb string.
|
||||||
field.classList.add('color-field');
|
field.classList.add('color-field');
|
||||||
@@ -655,6 +769,7 @@ function buildModuleCard(mod) {
|
|||||||
const readers = new Map();
|
const readers = new Map();
|
||||||
const controls = new Map(); // key → boolean/switch input (for showWhen)
|
const controls = new Map(); // key → boolean/switch input (for showWhen)
|
||||||
const selects = new Map(); // key → select element (optionsDependsOn sources)
|
const selects = new Map(); // key → select element (optionsDependsOn sources)
|
||||||
|
const sources = new Map(); // key → { el, value() } for optionsDependsOn, whatever the type
|
||||||
const dependents = []; // { field, when } to show/hide by a control's state
|
const dependents = []; // { field, when } to show/hide by a control's state
|
||||||
const optionDeps = []; // { refetch, on } selects whose options follow another field
|
const optionDeps = []; // { refetch, on } selects whose options follow another field
|
||||||
const reloaders = []; // re-run every optionsRoute fetch (after a save changed what the module can list)
|
const reloaders = []; // re-run every optionsRoute fetch (after a save changed what the module can list)
|
||||||
@@ -712,6 +827,15 @@ function buildModuleCard(mod) {
|
|||||||
readers.set(key, read);
|
readers.set(key, read);
|
||||||
if (control) controls.set(key, control);
|
if (control) controls.set(key, control);
|
||||||
if (selectEl) selects.set(key, selectEl);
|
if (selectEl) selects.set(key, selectEl);
|
||||||
|
// Anything can be the source of an optionsDependsOn — a multiselect
|
||||||
|
// hands over its values joined, which is what a module's route reads.
|
||||||
|
sources.set(key, {
|
||||||
|
el: selectEl || control || field,
|
||||||
|
value: () => {
|
||||||
|
const v = read();
|
||||||
|
return Array.isArray(v) ? v.join(',') : (typeof v === 'boolean' ? String(v) : String(v ?? ''));
|
||||||
|
},
|
||||||
|
});
|
||||||
if (refetch && spec?.optionsDependsOn) optionDeps.push({ refetch, on: spec.optionsDependsOn });
|
if (refetch && spec?.optionsDependsOn) optionDeps.push({ refetch, on: spec.optionsDependsOn });
|
||||||
else if (refetch) reloaders.push(() => refetch());
|
else if (refetch) reloaders.push(() => refetch());
|
||||||
if (spec?.showWhen) dependents.push({ field, when: spec.showWhen });
|
if (spec?.showWhen) dependents.push({ field, when: spec.showWhen });
|
||||||
@@ -736,9 +860,10 @@ function buildModuleCard(mod) {
|
|||||||
// value as a query parameter, now and whenever K changes (a plan list
|
// value as a query parameter, now and whenever K changes (a plan list
|
||||||
// that follows the chosen service type).
|
// that follows the chosen service type).
|
||||||
for (const dep of optionDeps) {
|
for (const dep of optionDeps) {
|
||||||
const src = selects.get(dep.on) || controls.get(dep.on);
|
const src = sources.get(dep.on);
|
||||||
const run = () => dep.refetch({ [dep.on]: !src ? '' : (src.type === 'checkbox' ? String(src.checked) : src.value) });
|
const run = () => dep.refetch({ [dep.on]: src ? src.value() : '' });
|
||||||
if (src) src.addEventListener('change', run);
|
// A multiselect's switches are inside the field, so listen where they live.
|
||||||
|
if (src) src.el.addEventListener('change', run);
|
||||||
reloaders.push(run);
|
reloaders.push(run);
|
||||||
run();
|
run();
|
||||||
}
|
}
|
||||||
@@ -971,6 +1096,7 @@ async function loadState() {
|
|||||||
}
|
}
|
||||||
renderPin(state);
|
renderPin(state);
|
||||||
loadTheme();
|
loadTheme();
|
||||||
|
loadAlerts();
|
||||||
loadLayouts();
|
loadLayouts();
|
||||||
loadCatalog(false);
|
loadCatalog(false);
|
||||||
}
|
}
|
||||||
@@ -1072,7 +1198,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 +1349,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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* Mock ProPresenter slide status + mock Overflow Dashboard API, for developing
|
||||||
|
* the overflow-tap module without a live ProPresenter or an Overflow account.
|
||||||
|
*
|
||||||
|
* node tools/overflow-tap-mock.js [--pp=1602] [--overflow=24720] [--step=4000]
|
||||||
|
* [--login=<email>:<password>] [--expire=<seconds>]
|
||||||
|
* [--fail-switch] [--slow] [--no-announcements]
|
||||||
|
*
|
||||||
|
* ProPresenter side (127.0.0.1:1602 by default) — the routes the module uses,
|
||||||
|
* with a service that advances every --step ms:
|
||||||
|
* GET /version reachability probe
|
||||||
|
* GET /v1/status/slide { current: { uuid, text, notes }, next }, cycling
|
||||||
|
* through slides whose notes carry keywords
|
||||||
|
* ("give", "Next steps: connect", "prayer"),
|
||||||
|
* slides with other notes ("forgiveness" —
|
||||||
|
* must NOT fire "give"), a slide whose
|
||||||
|
* keyword is not mapped on the account, and
|
||||||
|
* a stretch with nothing live.
|
||||||
|
* GET /v1/announcement/slide_index the announcement layer: a three-slide
|
||||||
|
* document whose index advances every third
|
||||||
|
* step; its middle slide carries "events".
|
||||||
|
* GET /v1/announcement/active that document, groups → slides → notes.
|
||||||
|
* --no-announcements answers 404 to both,
|
||||||
|
* like an older ProPresenter.
|
||||||
|
*
|
||||||
|
* Overflow side (127.0.0.1:24720 by default; run ProdDash with
|
||||||
|
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720) — the Dashboard API the
|
||||||
|
* module speaks (see modules/overflow-tap/overflow.js):
|
||||||
|
* POST /nonprofit-dashboard/authentication/login/jwt any non-empty email +
|
||||||
|
* password signs in (--login=<email>:<password> demands that pair,
|
||||||
|
* answering 401 otherwise); the token expires after --expire seconds
|
||||||
|
* (3600 by default — set 20 to exercise the sign-in-again path). An
|
||||||
|
* @overflow.co email gets a staff token with no nonprofit of its own.
|
||||||
|
* GET /nonprofit-dashboard/propresenter/nonprofits staff: the nonprofits
|
||||||
|
* POST /nonprofit-dashboard/propresenter/nonprofits/{id}/select
|
||||||
|
* GET /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords four mappings:
|
||||||
|
* give → Give Online (every group), connect → Connect Card (Lobby),
|
||||||
|
* prayer → Prayer Request (every group), events → Events (Lobby, Kids
|
||||||
|
* Wing). "baptism" is deliberately unmapped.
|
||||||
|
* POST /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords?syncNonprofit=true
|
||||||
|
* { keywords } — moves the groups and answers the updated list.
|
||||||
|
* --fail-switch answers 500 instead, to exercise the failure path.
|
||||||
|
* Every signed-in call needs a Bearer token this mock issued and has not
|
||||||
|
* expired, or it answers 401. --slow adds a two-second delay to every Overflow
|
||||||
|
* answer.
|
||||||
|
*
|
||||||
|
* Every incoming request is logged so we can confirm what the module does.
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const opt = (name, dflt) => {
|
||||||
|
const a = args.find((x) => x.startsWith(`--${name}=`));
|
||||||
|
return a ? a.slice(name.length + 3) : dflt;
|
||||||
|
};
|
||||||
|
const PP_PORT = Number.parseInt(opt('pp', '1602'), 10);
|
||||||
|
const OF_PORT = Number.parseInt(opt('overflow', '24720'), 10);
|
||||||
|
const STEP_MS = Number.parseInt(opt('step', '4000'), 10);
|
||||||
|
const LOGIN = opt('login', '');
|
||||||
|
const EXPIRE_S = Number.parseInt(opt('expire', '3600'), 10);
|
||||||
|
const FAIL_SWITCH = args.includes('--fail-switch');
|
||||||
|
const SLOW = args.includes('--slow');
|
||||||
|
const NO_ANN = args.includes('--no-announcements');
|
||||||
|
|
||||||
|
function sendJson(res, code, body, extra = {}) {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json', ...extra });
|
||||||
|
res.end(body === undefined ? '' : JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ProPresenter ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const SLIDES = [
|
||||||
|
{ uuid: 'S1', text: 'Welcome to Waters Church', notes: '' },
|
||||||
|
{ uuid: 'S2', text: 'Give online', notes: 'give' },
|
||||||
|
{ uuid: 'S3', text: 'Amazing grace, how sweet the sound', notes: 'Chorus x2' },
|
||||||
|
{ uuid: 'S4', text: 'Fill out a Connect Card', notes: 'Next steps: connect' },
|
||||||
|
{ uuid: 'S5', text: 'A sermon on forgiveness', notes: 'forgiveness — do not switch on this' },
|
||||||
|
{ uuid: 'S6', text: 'Pray with us', notes: 'prayer' },
|
||||||
|
{ uuid: 'S7', text: 'Baptism Sunday', notes: 'baptism' },
|
||||||
|
null, // nothing live
|
||||||
|
{ uuid: 'S8', text: 'Give online (again)', notes: 'give' },
|
||||||
|
{ uuid: 'S9', text: 'See you next week', notes: '' },
|
||||||
|
];
|
||||||
|
const ANNOUNCEMENT = {
|
||||||
|
id: { uuid: 'ANN-1', name: 'Announcements', index: 0 },
|
||||||
|
presentation_path: 'Announcements',
|
||||||
|
groups: [
|
||||||
|
{ name: 'Loop', slides: [
|
||||||
|
{ enabled: true, notes: '', text: 'Welcome', label: '' },
|
||||||
|
{ enabled: true, notes: 'events', text: 'Upcoming events', label: '' },
|
||||||
|
{ enabled: true, notes: '', text: 'Thanks for coming', label: '' },
|
||||||
|
] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let step = 0;
|
||||||
|
setInterval(() => { step += 1; }, STEP_MS).unref();
|
||||||
|
const slideIdx = () => step % SLIDES.length;
|
||||||
|
const annIdx = () => Math.floor(step / 3) % ANNOUNCEMENT.groups[0].slides.length;
|
||||||
|
|
||||||
|
function slidePayload() {
|
||||||
|
const cur = SLIDES[slideIdx()];
|
||||||
|
const nxt = SLIDES[(slideIdx() + 1) % SLIDES.length];
|
||||||
|
return {
|
||||||
|
current: cur ? { uuid: cur.uuid, text: cur.text, notes: cur.notes } : null,
|
||||||
|
next: nxt ? { uuid: nxt.uuid, text: nxt.text, notes: nxt.notes } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
http.createServer((req, res) => {
|
||||||
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||||
|
const p = url.pathname;
|
||||||
|
console.log(`[pp] ${req.method} ${p}`);
|
||||||
|
if (p === '/version') return sendJson(res, 200, { name: 'Overflow mock', platform: 'mac', host_description: 'ProPresenter 21 (mock)', api_version: 'v1' });
|
||||||
|
if (p === '/v1/status/slide') return sendJson(res, 200, slidePayload());
|
||||||
|
if (p === '/v1/status/layers') return sendJson(res, 200, { slide: Boolean(SLIDES[slideIdx()]), media: false, announcements: !NO_ANN });
|
||||||
|
if (p === '/v1/announcement/slide_index') {
|
||||||
|
if (NO_ANN) return sendJson(res, 404, { error: 'not found' });
|
||||||
|
return sendJson(res, 200, { announcement_index: { index: annIdx(), presentation_id: ANNOUNCEMENT.id } });
|
||||||
|
}
|
||||||
|
if (p === '/v1/announcement/active') {
|
||||||
|
if (NO_ANN) return sendJson(res, 404, { error: 'not found' });
|
||||||
|
return sendJson(res, 200, { announcement: ANNOUNCEMENT });
|
||||||
|
}
|
||||||
|
sendJson(res, 404, { error: 'not found' });
|
||||||
|
}).listen(PP_PORT, '127.0.0.1', () => {
|
||||||
|
console.log(`[pp] mock ProPresenter on http://127.0.0.1:${PP_PORT} (slide every ${STEP_MS} ms${NO_ANN ? ', no announcement layer' : ''})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Overflow Dashboard ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const NONPROFIT = { id: '6710f34fd5061afeec3eab00', name: 'Waters Church (mock)', slug: 'waters-church' };
|
||||||
|
const DESTINATIONS = {
|
||||||
|
give: { id: '6710f34fd5061afeec3eab01', name: 'Give Online' },
|
||||||
|
connect: { id: '6710f34fd5061afeec3eab02', name: 'Connect Card' },
|
||||||
|
prayer: { id: '6710f34fd5061afeec3eab03', name: 'Prayer Request' },
|
||||||
|
events: { id: '6710f34fd5061afeec3eab04', name: 'Events' },
|
||||||
|
};
|
||||||
|
const GROUPS = [
|
||||||
|
{ id: '6710f34fd5061afeec3eab11', tapGroupName: 'Auditorium', destinationId: DESTINATIONS.events.id },
|
||||||
|
{ id: '6710f34fd5061afeec3eab12', tapGroupName: 'Lobby', destinationId: DESTINATIONS.events.id },
|
||||||
|
{ id: '6710f34fd5061afeec3eab13', tapGroupName: 'Kids Wing', destinationId: DESTINATIONS.connect.id },
|
||||||
|
];
|
||||||
|
const groupRef = (g) => ({ id: g.id, tapGroupName: g.tapGroupName });
|
||||||
|
const KEYWORDS = [
|
||||||
|
{ id: 'kw1', keyword: 'give', destinationId: DESTINATIONS.give.id, destinationName: DESTINATIONS.give.name, tapGroups: GROUPS.map(groupRef), active: true },
|
||||||
|
{ id: 'kw2', keyword: 'connect', destinationId: DESTINATIONS.connect.id, destinationName: DESTINATIONS.connect.name, tapGroups: [groupRef(GROUPS[1])], active: true },
|
||||||
|
{ id: 'kw3', keyword: 'prayer', destinationId: DESTINATIONS.prayer.id, destinationName: DESTINATIONS.prayer.name, tapGroups: GROUPS.map(groupRef), active: true },
|
||||||
|
{ id: 'kw4', keyword: 'events', destinationId: DESTINATIONS.events.id, destinationName: DESTINATIONS.events.name, tapGroups: [groupRef(GROUPS[1]), groupRef(GROUPS[2])], active: true },
|
||||||
|
];
|
||||||
|
const stampedKeywords = () => KEYWORDS.map((k) => ({ ...k, nonprofitId: NONPROFIT.id, updatedAt: new Date().toISOString() }));
|
||||||
|
|
||||||
|
const tokens = new Map(); // token → { email, exp, nonprofitId }
|
||||||
|
|
||||||
|
function issueToken(email) {
|
||||||
|
const staff = email.toLowerCase().endsWith('@overflow.co');
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const payload = {
|
||||||
|
id: 'user-1',
|
||||||
|
email,
|
||||||
|
fullName: 'Mock User',
|
||||||
|
role: staff ? 'overflowSupport' : 'orgAdmin',
|
||||||
|
permissions: [{ action: 'manage', subject: 'TAP' }],
|
||||||
|
nonprofits: staff ? [] : [{ id: NONPROFIT.id, name: NONPROFIT.name, slug: NONPROFIT.slug }],
|
||||||
|
iat: now,
|
||||||
|
exp: now + EXPIRE_S,
|
||||||
|
};
|
||||||
|
if (!staff) payload.nonprofitId = NONPROFIT.id;
|
||||||
|
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
||||||
|
const token = `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64(payload)}.${crypto.randomBytes(16).toString('base64url')}`;
|
||||||
|
tokens.set(token, { email, exp: payload.exp, nonprofitId: staff ? '' : NONPROFIT.id });
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bearer(req) {
|
||||||
|
const m = /^Bearer\s+(.+)$/.exec(String(req.headers.authorization || ''));
|
||||||
|
if (!m) return null;
|
||||||
|
const t = tokens.get(m[1]);
|
||||||
|
if (!t) return null;
|
||||||
|
if (Date.now() / 1000 >= t.exp) return null;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (c) => (body += c));
|
||||||
|
req.on('end', () => {
|
||||||
|
try { resolve(JSON.parse(body || '{}')); } catch { resolve(null); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
http.createServer(async (req, res) => {
|
||||||
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||||
|
const p = url.pathname;
|
||||||
|
const answer = (code, body) => {
|
||||||
|
const go = () => sendJson(res, code, body);
|
||||||
|
if (SLOW) setTimeout(go, 2000);
|
||||||
|
else go();
|
||||||
|
};
|
||||||
|
console.log(`[overflow] ${req.method} ${p}${url.search}`);
|
||||||
|
|
||||||
|
if (req.method === 'POST' && p === '/nonprofit-dashboard/authentication/login/jwt') {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const email = String((body && body.email) || '').trim();
|
||||||
|
const password = String((body && body.password) || '');
|
||||||
|
if (!email || !password) return answer(400, { statusCode: 400, message: 'email and password are required' });
|
||||||
|
if (LOGIN && `${email}:${password}` !== LOGIN) return answer(401, { statusCode: 401, message: 'Invalid email or password' });
|
||||||
|
console.log(`[overflow] signed in ${email} (token expires in ${EXPIRE_S} s)`);
|
||||||
|
return answer(200, { token: issueToken(email) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = bearer(req);
|
||||||
|
if (!session) return answer(401, { statusCode: 401, message: 'Unauthorized' });
|
||||||
|
|
||||||
|
if (req.method === 'GET' && p === '/nonprofit-dashboard/propresenter/nonprofits') {
|
||||||
|
return answer(200, { data: { nonprofits: [NONPROFIT] } });
|
||||||
|
}
|
||||||
|
const sel = /^\/nonprofit-dashboard\/propresenter\/nonprofits\/([^/]+)\/select$/.exec(p);
|
||||||
|
if (req.method === 'POST' && sel) {
|
||||||
|
if (sel[1] !== NONPROFIT.id) return answer(404, { statusCode: 404, message: 'Nonprofit not found' });
|
||||||
|
session.nonprofitId = NONPROFIT.id;
|
||||||
|
return answer(204);
|
||||||
|
}
|
||||||
|
const kw = /^\/nonprofit-dashboard\/propresenter\/nonprofits\/([^/]+)\/keywords$/.exec(p);
|
||||||
|
if (kw) {
|
||||||
|
if (kw[1] !== NONPROFIT.id || session.nonprofitId !== NONPROFIT.id) return answer(403, { statusCode: 403, message: 'Forbidden' });
|
||||||
|
if (req.method === 'GET') return answer(200, stampedKeywords());
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const keys = body && Array.isArray(body.keywords) ? body.keywords.map((k) => String(k).toLowerCase().trim()) : [];
|
||||||
|
if (!keys.length) return answer(400, { statusCode: 400, message: 'keywords is required' });
|
||||||
|
if (FAIL_SWITCH) return answer(500, { statusCode: 500, message: 'Internal server error' });
|
||||||
|
for (const k of keys) {
|
||||||
|
const entry = KEYWORDS.find((x) => x.keyword === k);
|
||||||
|
if (!entry) {
|
||||||
|
console.log(`[overflow] "${k}" is not a mapped keyword — ignored`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const g of entry.tapGroups) {
|
||||||
|
const group = GROUPS.find((x) => x.id === g.id);
|
||||||
|
if (group) group.destinationId = entry.destinationId;
|
||||||
|
}
|
||||||
|
console.log(`[overflow] ${entry.tapGroups.map((g) => g.tapGroupName).join(', ')} → ${entry.destinationName} (${k})`);
|
||||||
|
}
|
||||||
|
return answer(200, stampedKeywords());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
answer(404, { statusCode: 404, message: `Cannot ${req.method} ${p}` });
|
||||||
|
}).listen(OF_PORT, '127.0.0.1', () => {
|
||||||
|
console.log(`[overflow] mock Overflow Dashboard API on http://127.0.0.1:${OF_PORT}${FAIL_SWITCH ? ' (every switch answers 500)' : ''}${LOGIN ? ' (one login accepted)' : ''}${EXPIRE_S < 3600 ? ` (tokens expire after ${EXPIRE_S} s)` : ''}`);
|
||||||
|
});
|
||||||
@@ -8,6 +8,9 @@
|
|||||||
* Serves JSON:API shapes matching api.planningcenteronline.com/services/v2:
|
* Serves JSON:API shapes matching api.planningcenteronline.com/services/v2:
|
||||||
* GET /services/v2/folders nested folders (parent rel)
|
* GET /services/v2/folders nested folders (parent rel)
|
||||||
* GET /services/v2/folders/{id}
|
* GET /services/v2/folders/{id}
|
||||||
|
* Run: node tools/pco-mock.js [port] [--types N] (--types pads the service
|
||||||
|
* type list out to N, for trying a picker against an account that has dozens.)
|
||||||
|
*
|
||||||
* GET /services/v2/service_types every service type, with its
|
* GET /services/v2/service_types every service type, with its
|
||||||
* parent folder relationship
|
* parent folder relationship
|
||||||
* GET /services/v2/service_types/{id}
|
* GET /services/v2/service_types/{id}
|
||||||
@@ -49,6 +52,10 @@
|
|||||||
const http = require('http');
|
const http = require('http');
|
||||||
|
|
||||||
const PORT = Number.parseInt(process.argv[2] || '24700', 10);
|
const PORT = Number.parseInt(process.argv[2] || '24700', 10);
|
||||||
|
// --types N pads the list out to N with planless filler, for accounts that
|
||||||
|
// carry dozens of them: enough to see whether a picker still works at that size.
|
||||||
|
const TYPE_COUNT = Number.parseInt((process.argv.find((a) => a.startsWith('--types')) || '').split('=')[1]
|
||||||
|
|| (process.argv[process.argv.indexOf('--types') + 1] || ''), 10);
|
||||||
const bootedAt = Date.now();
|
const bootedAt = Date.now();
|
||||||
|
|
||||||
/* ── data ───────────────────────────────────────────────────────────── */
|
/* ── data ───────────────────────────────────────────────────────────── */
|
||||||
@@ -68,6 +75,18 @@ const SERVICE_TYPES = [
|
|||||||
{ id: '1005', name: 'Youth Night', frequency: 'Every 2 weeks', parentId: '' },
|
{ id: '1005', name: 'Youth Night', frequency: 'Every 2 weeks', parentId: '' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (Number.isFinite(TYPE_COUNT) && TYPE_COUNT > SERVICE_TYPES.length) {
|
||||||
|
FOLDERS.push({ id: '300', name: 'Other Campuses', parentId: '' });
|
||||||
|
for (let i = SERVICE_TYPES.length; i < TYPE_COUNT; i += 1) {
|
||||||
|
SERVICE_TYPES.push({
|
||||||
|
id: String(2000 + i),
|
||||||
|
name: `Campus ${String.fromCharCode(65 + (i % 26))}${i >= 26 + 5 ? Math.floor(i / 26) : ''} Service`,
|
||||||
|
frequency: 'Every week',
|
||||||
|
parentId: i % 3 === 0 ? '300' : (i % 3 === 1 ? '110' : ''),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function iso(ms) {
|
function iso(ms) {
|
||||||
return new Date(ms).toISOString();
|
return new Date(ms).toISOString();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user