Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4383c9e62 | ||
|
|
c9e03398a6 | ||
|
|
7bb1131378 | ||
|
|
f9cd292693 | ||
|
|
5ca0a672fa | ||
|
|
a3500f98b1 | ||
|
|
1274360ee6 | ||
|
|
3b81efcb51 | ||
|
|
409f2c258e | ||
|
|
7f6e5bd151 | ||
|
|
2307770ce9 | ||
|
|
fe3811b03e | ||
|
|
41811ead1c | ||
|
|
0929af46ca | ||
|
|
2817a444c3 | ||
|
|
c11389473f | ||
|
|
c6a44e6ddc | ||
|
|
2f14b30d68 | ||
|
|
b5d39c60dc | ||
|
|
407eaecc23 | ||
|
|
601dc9e8b0 | ||
|
|
b5c539ba05 |
+2
-6
@@ -8,12 +8,8 @@ dist/
|
|||||||
config/modules.json
|
config/modules.json
|
||||||
config/layouts/
|
config/layouts/
|
||||||
|
|
||||||
# compiled per-machine, source sits next to it
|
|
||||||
modules/propresenter-timers/ltc-capture
|
|
||||||
|
|
||||||
# the launcher's build output, and its cache of the Node runtime it embeds
|
# the launcher's build output, and its cache of the Node runtime it embeds
|
||||||
launcher/macos/build/
|
launcher/macos/build/
|
||||||
launcher/macos/.cache/
|
launcher/macos/.cache/
|
||||||
|
launcher/windows/build/
|
||||||
# Compiled per-machine, source sits next to it (see spl-capture.swift).
|
launcher/windows/.cache/
|
||||||
modules/spl-meter/spl-capture
|
|
||||||
|
|||||||
@@ -30,9 +30,19 @@ and a clear degraded state, then recover on their own.
|
|||||||
|
|
||||||
## Run it
|
## Run it
|
||||||
|
|
||||||
|
Ready-made builds are on the
|
||||||
|
[Releases page](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash/releases);
|
||||||
|
the files themselves live in
|
||||||
|
[ProdDash-releases](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash-releases).
|
||||||
|
There is a disk image for Apple silicon Macs, one for Intel Macs, and a
|
||||||
|
bundle for 64-bit Windows. Every build updates itself afterwards from the
|
||||||
|
admin page, so the download only has to be recent, not current.
|
||||||
|
|
||||||
### macOS — the app
|
### macOS — the app
|
||||||
|
|
||||||
Open the disk image and drag **ProdDash** to Applications. That's the whole
|
Download the disk image for your Mac — `ProdDash-<version>-arm64.dmg` for
|
||||||
|
Apple silicon, `ProdDash-<version>-x86_64.dmg` for Intel — open it and drag
|
||||||
|
**ProdDash** to Applications. That's the whole
|
||||||
installation: the app carries the dashboard and its own Node runtime, so
|
installation: the app carries the dashboard and its own Node runtime, so
|
||||||
nothing else has to be installed. It carries no modules — open **Admin →
|
nothing else has to be installed. It carries no modules — open **Admin →
|
||||||
Available modules**, tick the ones this machine needs and press **Install
|
Available modules**, tick the ones this machine needs and press **Install
|
||||||
@@ -45,12 +55,26 @@ a launchd job) simply doesn't have them, and an LTC input goes silently to
|
|||||||
zeroes.
|
zeroes.
|
||||||
|
|
||||||
Build the disk image from a checkout with
|
Build the disk image from a checkout with
|
||||||
[launcher/macos](launcher/macos)/`build.sh`:
|
[launcher/macos](launcher/macos)/`build.sh` (`ARCH=x86_64` in front of it
|
||||||
|
for Intel):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd launcher/macos && ./build.sh --dmg
|
cd launcher/macos && ./build.sh --dmg
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Windows — the bundle
|
||||||
|
|
||||||
|
Download `ProdDash-<version>-win-x64.zip`, unzip it somewhere you can write
|
||||||
|
to — `C:\ProdDash`, say, not Program Files, because updates write into the
|
||||||
|
folder — and double-click **ProdDash.bat**. The window it opens is the
|
||||||
|
dashboard running: it stays open, and it starts the server again when the
|
||||||
|
admin page applies an update. Allow `node.exe` on private networks when the
|
||||||
|
firewall asks, so other screens can reach it. Settings and layouts live in
|
||||||
|
`%APPDATA%\ProdDash`, so a new bundle never loses them. To start with
|
||||||
|
Windows, put a shortcut to `ProdDash.bat` in `shell:startup`. There is no
|
||||||
|
menu-bar-style launcher for Windows yet; the bundle is built with
|
||||||
|
[launcher/windows](launcher/windows)/`build.sh`.
|
||||||
|
|
||||||
### Anywhere else, or from a checkout
|
### Anywhere else, or from a checkout
|
||||||
|
|
||||||
Requires [Node.js](https://nodejs.org) 18 or newer.
|
Requires [Node.js](https://nodejs.org) 18 or newer.
|
||||||
@@ -105,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
|
||||||
@@ -168,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
|
||||||
@@ -285,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.
|
||||||
@@ -294,7 +346,13 @@ below. Nothing is installed without someone clicking.
|
|||||||
- **ProdCom** — live transcript stream with in-place updates of in-progress
|
- **ProdCom** — live transcript stream with in-place updates of in-progress
|
||||||
speech, channel colors, per-tile channel visibility toggles and group
|
speech, channel colors, per-tile channel visibility toggles and group
|
||||||
presets, timestamps, text size, a top-down / bottom-up direction toggle
|
presets, timestamps, text size, a top-down / bottom-up direction toggle
|
||||||
and jump-to-latest in the title bar.
|
and jump-to-latest in the title bar. Text typed into a channel (from a
|
||||||
|
dashboard, or ProdCom's own box) is marked with a keyboard; an automation's
|
||||||
|
with a bolt. The module also offers its channels to **Send Message** as
|
||||||
|
identities: a click anywhere in the transcript points that module's tiles
|
||||||
|
at ProdCom (the tile's bottom edge lights up in the accent color), and the
|
||||||
|
channel the text goes out *as* is picked from their Send as menu — only
|
||||||
|
channels ProdCom lets text be typed onto are offered.
|
||||||
Admin config: ProdCom server (host : port), API key.
|
Admin config: ProdCom server (host : port), API key.
|
||||||
Tile settings: channel icons, direction and timestamps.
|
Tile settings: channel icons, direction and timestamps.
|
||||||
- **ProPresenter** — what's live and what's next, with slide progress,
|
- **ProPresenter** — what's live and what's next, with slide progress,
|
||||||
@@ -345,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.
|
||||||
@@ -382,29 +453,47 @@ below. Nothing is installed without someone clicking.
|
|||||||
has signed up for are hidden until you ask for them — that list comes from
|
has signed up for are hidden until you ask for them — that list comes from
|
||||||
the service type, since Planning Center only returns a team once somebody
|
the service type, since Planning Center only returns a team once somebody
|
||||||
is on it.
|
is on it.
|
||||||
- **Slack** — live transcripts of this location's Slack channels, plus
|
- **Slack** — live transcripts of this location's Slack channels. The +
|
||||||
PIN-protected sending as the location. The + picker lists one transcript
|
picker lists one transcript tile per configured channel, titled
|
||||||
tile per configured channel, titled `#channel`, and two send surfaces (one
|
`#channel`. Messages arrive with avatars, names and times, Slack's inline
|
||||||
of each per dashboard): **Send message**, a free-text box with the admin's
|
formatting and the workspace's custom emoji; text size and jump-to-latest
|
||||||
quick emojis one press away, and **Quick replies**, a grid of preset
|
sit in the title bar. Sending is the **Send Message** module's: a click
|
||||||
buttons. Both post to the channel of whichever transcript was clicked last
|
anywhere in a transcript points its tiles at that channel (the bottom
|
||||||
— its bottom edge lights up in the accent color — and to the first
|
edge lights up in the accent color), and a message then leaves through
|
||||||
configured channel until one is clicked. Messages arrive with avatars,
|
this module's one Slack connection, as the location. A right-click on a
|
||||||
names and times, Slack's inline formatting and the workspace's custom
|
message opens the reaction menu, which rides on the same unlock — with
|
||||||
emoji; a right-click on one opens the reaction menu, and text size and
|
the page locked, or no Send message tile on it, it says so instead of
|
||||||
jump-to-latest sit in the title bar. Nothing leaves the machine until the
|
reacting. This module has no PIN, send tile or quick replies of its own.
|
||||||
PIN is entered: one PIN unlocks every Slack tile on that dashboard page
|
Admin config is in three groups: **Slack account** (the location account's
|
||||||
until the auto-lock idles out, a reload asks again, and the lock button on
|
|
||||||
a send tile's title bar ends it early.
|
|
||||||
Admin config is in four groups: **Slack account** (the location account's
|
|
||||||
User OAuth Token — `xoxp-…`, so messages are literally from the campus —
|
User OAuth Token — `xoxp-…`, so messages are literally from the campus —
|
||||||
or a bot token plus "Send as"), **Channels** (the joined channels to
|
or a bot token plus "Send as"), **Channels** (the joined channels to
|
||||||
follow, one transcript tile each), **Transcript** (refresh interval,
|
follow, one transcript tile and one Send Message destination each) and
|
||||||
messages kept, thread replies) and **Sending** (the switch on the group's
|
**Transcript** (refresh interval, messages kept, thread replies).
|
||||||
title row, a 4–8 digit PIN, auto-lock minutes, quick replies one per line
|
|
||||||
as `Label | Message` — add `| #channel` to bind one to a channel — and up
|
|
||||||
to eight quick emojis).
|
|
||||||
Tile settings: avatars, timestamps, names and channel notices.
|
Tile settings: avatars, timestamps, names and channel notices.
|
||||||
|
- **Send Message** — the one place a dashboard sends from, whatever the
|
||||||
|
platform. Two tiles, one of each per dashboard: **Send message**, a
|
||||||
|
free-text box with the admin's quick emojis one press away, and **Quick
|
||||||
|
replies**, a grid of preset buttons. Both go wherever the operator last
|
||||||
|
pointed them with a click on a Slack or ProdCom transcript. A platform
|
||||||
|
whose destinations are identities rather than recipients — ProdCom, where
|
||||||
|
typed text goes out *as* a channel — also gets a **Send as** menu in the
|
||||||
|
title bar listing those identities; the menu exists only while such a
|
||||||
|
platform is installed, and Slack's channels are never in it. The choice is
|
||||||
|
saved with the tile, so a named layout remembers it; the transcript it
|
||||||
|
points at carries the accent stripe. The module
|
||||||
|
owns no connection of its own — it finds every installed module that
|
||||||
|
offers messaging and hands the message to it over loopback — and it owns
|
||||||
|
the PIN: nothing leaves the machine until it is entered on one of its
|
||||||
|
tiles, one entry unlocks every messaging tile on that dashboard page
|
||||||
|
(Slack's reaction menu included) until the auto-lock idles out, a reload
|
||||||
|
asks again, and the lock button ends it early. The PIN pad takes taps, a
|
||||||
|
keyboard, or a phone's — tapping the dots raises it. The PIN can be
|
||||||
|
switched off for a booth that doesn't need one.
|
||||||
|
Admin config: **PIN** (the switch on the group's title row, 4–8 digits,
|
||||||
|
auto-lock minutes) and **Presets** (quick replies one per line as
|
||||||
|
`Label | Message` — add `| slack:#channel` or `| prodcom:Channel` to bind
|
||||||
|
one to a destination — and up to eight quick emojis).
|
||||||
|
Tile settings: quick emojis on the Send message tile.
|
||||||
- **Resi Cues** — how long until the next Resi cue point, for a campus
|
- **Resi Cues** — how long until the next Resi cue point, for a campus
|
||||||
running video from another site. The tile is one number, as large as it
|
running video from another site. The tile is one number, as large as it
|
||||||
will go: the countdown to the next cue, its name above it, and the playhead
|
will go: the countdown to the next cue, its name above it, and the playhead
|
||||||
@@ -460,7 +549,8 @@ below. Nothing is installed without someone clicking.
|
|||||||
reference for module authors.
|
reference for module authors.
|
||||||
|
|
||||||
To develop modules against mocks instead of real gear:
|
To develop modules against mocks instead of real gear:
|
||||||
`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`),
|
`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`, including the
|
||||||
|
send route the Send Message module uses),
|
||||||
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter slides on
|
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter slides on
|
||||||
`127.0.0.1:1599`), `node tools/pp-timers-mock.js 1600` (ProPresenter
|
`127.0.0.1:1599`), `node tools/pp-timers-mock.js 1600` (ProPresenter
|
||||||
timers + timecode on `127.0.0.1:1600`), `node tools/pco-mock.js`
|
timers + timecode on `127.0.0.1:1600`), `node tools/pco-mock.js`
|
||||||
@@ -504,7 +594,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.
|
||||||
+207
-5
@@ -202,7 +202,7 @@ line, so a Windows booth knows why the feature is off.
|
|||||||
| `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. |
|
| `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. |
|
||||||
| `description` | no | Shown in the admin page / picker. |
|
| `description` | no | Shown in the admin page / picker. |
|
||||||
| `permissions` | no | What macOS must allow before this module can work — see **Permissions macOS has to grant**. Ignored by the shell; read by the macOS launcher. |
|
| `permissions` | no | What macOS must allow before this module can work — see **Permissions macOS has to grant**. Ignored by the shell; read by the macOS launcher. |
|
||||||
| `provides` | no | Capabilities other modules can consume — `["timers"]` today. Forwarded in `/api/modules` so a consumer can find you. See **Working with other modules**. |
|
| `provides` | no | Capabilities other modules can consume — `["timers"]`, `["messaging"]`. Forwarded in `/api/modules` so a consumer can find you. See **Working with other modules**. |
|
||||||
| `client` | yes | Client entry file, loaded as an ES module. |
|
| `client` | yes | Client entry file, loaded as an ES module. |
|
||||||
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
|
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
|
||||||
| `style` | no | A stylesheet the shell injects once per module. |
|
| `style` | no | A stylesheet the shell injects once per module. |
|
||||||
@@ -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). |
|
||||||
|
|
||||||
@@ -461,7 +479,8 @@ const menu = moduleApi.header.addMenu({
|
|||||||
build(menuEl) {
|
build(menuEl) {
|
||||||
// called on every open with an emptied menu element — fill it with
|
// called on every open with an emptied menu element — fill it with
|
||||||
// buttons (give them your scoped classes; the container is styled and
|
// buttons (give them your scoped classes; the container is styled and
|
||||||
// fixed-positioned by the shell, so the tile's overflow can't clip it)
|
// positioned by the shell, hosted on the body under an element carrying
|
||||||
|
// your data-module, so your scoped CSS applies and nothing clips it)
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
menu.setLabel('Channels (2/4) ▾'); // update the button text any time
|
menu.setLabel('Channels (2/4) ▾'); // update the button text any time
|
||||||
@@ -599,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
|
||||||
@@ -703,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
|
||||||
@@ -769,6 +857,108 @@ comment every 15 s); the consumer uses the stream when it exists and polls
|
|||||||
wall — the Timers module lets them hide any of them per tile, but every entry
|
wall — the Timers module lets them hide any of them per tile, but every entry
|
||||||
you add is a decision someone has to make.
|
you add is a decision someone has to make.
|
||||||
|
|
||||||
|
### Offering messaging to the Send Message module
|
||||||
|
|
||||||
|
The Send Message module is the one place a dashboard sends from — a
|
||||||
|
PIN-gated compose box and a grid of quick replies — whatever the platform.
|
||||||
|
It owns nothing upstream: a module that *can* deliver a message (Slack posts
|
||||||
|
to a channel, ProdCom puts typed text on a comms channel) offers that over
|
||||||
|
its own routes, keeps its one upstream connection, and the Send Message
|
||||||
|
module hands messages to it over loopback. A provider has no PIN, no send
|
||||||
|
tile and no quick replies of its own; the shell's Add-tile picker shows the
|
||||||
|
Send message and Quick replies tiles once, under Send Message.
|
||||||
|
|
||||||
|
A module offers messaging by:
|
||||||
|
|
||||||
|
1. Declaring it — `"provides": ["messaging"]` in `module.json`. The Send
|
||||||
|
Message module re-reads `/api/modules` every 30 s, so a provider installed
|
||||||
|
mid-session shows up without a restart.
|
||||||
|
2. Serving `GET /messaging` — what it can send to, from state it already
|
||||||
|
holds (it is polled every few seconds while a send tile is open):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"label": "Slack",
|
||||||
|
"identity": "Apollo Beach",
|
||||||
|
"destinationRole": "recipient",
|
||||||
|
"status": { "state": "ok", "message": "Apollo Beach · #production, #ops" },
|
||||||
|
"destinations": [
|
||||||
|
{ "id": "C01ABC", "label": "#production" },
|
||||||
|
{ "id": "C02DEF", "label": "#ops", "color": "#5c9de0" }
|
||||||
|
],
|
||||||
|
"emptyMessage": "",
|
||||||
|
"maxChars": 4000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `label` | The platform's name, as the sending line and the Send as menu show it. |
|
||||||
|
| `identity` | Who a message appears to come from when that is fixed by the connection, or `''`. Shown as "as Apollo Beach". |
|
||||||
|
| `destinationRole` | What a destination *is*. `recipient` (the default): a message goes **to** it — a Slack channel — and the operator picks one by clicking its transcript tile, so it never appears in the Send as menu. `identity`: a message goes out **as** it — a ProdCom channel — and the Send as menu lists them; the tiles then say "Send message to ProdCom as FOH" and "Sent to ProdCom as FOH". The menu exists only while some installed platform offers identities. |
|
||||||
|
| `status` | `ok`, `connecting` or `error` plus one line — the same thing your `health()` says. A provider in error is still listed, so the operator sees why a send will fail before trying. |
|
||||||
|
| `destinations` | Where a message can go, or who it can go out as: a stable `id` (what `/messaging/send` receives), a `label`, and an optional `color`. Offer only what will work — ProdCom lists just the channels that accept typed text. Order is the order the menu shows. |
|
||||||
|
| `emptyMessage` | Optional: why the list is empty, when you know ("No ProdCom channel accepts typed text — turn on typing for one in ProdCom"). The tiles show it in place of a destination. |
|
||||||
|
| `maxChars` | The longest text you accept; the Send Message module refuses longer ones before asking you. |
|
||||||
|
|
||||||
|
3. Serving `POST /messaging/send` — `{ "destination": "<id>", "text": "…" }`
|
||||||
|
with the browser's unlock token as `Authorization: Bearer …`. **Check the
|
||||||
|
token before touching your upstream**: the Send Message module owns the
|
||||||
|
PIN, so ask it — `GET http://127.0.0.1:${shell.port}/api/modules/send-message/verify`
|
||||||
|
with the same `Authorization` header answers `200 { ok: true, expiresAt }`
|
||||||
|
or `401`. A `404` means no Send Message module is installed or enabled:
|
||||||
|
refuse with a sentence that says so. Without this check, any browser on
|
||||||
|
the network could post as the location by calling your route directly.
|
||||||
|
Answer `200 { "ok": true, "label": "#ops" }` when it landed, otherwise
|
||||||
|
`{ "error": "<one sentence a tech can act on>", "code": "…" }` with `400`
|
||||||
|
(bad destination or text), `401` (the token was refused), `429` (your
|
||||||
|
upstream is rate-limiting) or `502` (your upstream failed). Validate the
|
||||||
|
destination against your own list — never pass an arbitrary id upstream.
|
||||||
|
|
||||||
|
The same verification gates any other outbound action a module offers from
|
||||||
|
its tiles — the Slack transcript's emoji reactions ride on it — so a module
|
||||||
|
never needs a PIN of its own: one unlock serves every messaging tile on the
|
||||||
|
page. The `slack` and `prodcom-transcript` modules are the reference
|
||||||
|
implementations (`verifyUnlock()` in either server entry is the whole check).
|
||||||
|
|
||||||
|
### Messaging on the page
|
||||||
|
|
||||||
|
Where the next message goes is a fact about the whole dashboard, not one
|
||||||
|
tile: the Send message tile, the Quick replies tile and every transcript on
|
||||||
|
the page must agree, and they belong to different modules. Modules don't
|
||||||
|
import each other, so the agreement lives on `window` — one plain object plus
|
||||||
|
`CustomEvent`s on `window`, created by whichever messaging-aware tile mounts
|
||||||
|
first:
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.proddashMessaging = {
|
||||||
|
version: 1,
|
||||||
|
target: { provider: '', destination: '', providerLabel: '', destinationLabel: '', identity: '' },
|
||||||
|
unlock: { token: '', expiresAt: 0, reason: '' },
|
||||||
|
quickEmojis: [], // the Send Message module's admin list, plain strings
|
||||||
|
transcripts: new Map(), // instanceId → { provider, destination }
|
||||||
|
senders: new Set(), // instanceIds of Send message / Quick replies tiles
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Events, all named `proddash:messaging:<name>` with the payload in `detail`:
|
||||||
|
|
||||||
|
| Event | Who sends it | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `select` | a transcript tile, or a Send as menu | `{ provider, destination }` — the operator pointed the senders here. A tile bound to one recipient (a Slack channel transcript) names it; one that shows a whole platform (ProdCom) sends `destination: ''` and the Send Message tile keeps the identity it has chosen for that platform. |
|
||||||
|
| `target` | the Send Message tiles | `window.proddashMessaging.target` changed: where the next message goes, resolved. A transcript compares `provider` (and `destination`, when it has one) and lights its bottom edge. |
|
||||||
|
| `unlock` | the Send Message tiles; a provider's tile on a `401` | `window.proddashMessaging.unlock` changed. Read the token from the object, never cache it; `''` means locked, with `reason` saying why. |
|
||||||
|
| `transcripts` | transcript tiles | the `transcripts` map changed (a tile mounted, unmounted or learned its channel). Register once you know your destination; unregister in `stop()`. This is how a dashboard with one transcript sends there by default. |
|
||||||
|
| `senders` | the Send Message tiles | the `senders` set changed — a transcript's locked note can say "add a Send message tile" rather than "enter the PIN". |
|
||||||
|
|
||||||
|
A transcript tile needs only three things: emit `select` on a click in its
|
||||||
|
body, register in `transcripts`, and toggle a target class on `target`. A
|
||||||
|
tile that performs a gated action itself (a reaction) reads
|
||||||
|
`window.proddashMessaging.unlock.token` and sends it as the Bearer token to
|
||||||
|
its own module's route. The Send Message tiles save the chosen platform and
|
||||||
|
per-platform destination in their instance settings, so a named layout
|
||||||
|
remembers them; the page object is the live copy.
|
||||||
|
|
||||||
## Complete minimal example
|
## Complete minimal example
|
||||||
|
|
||||||
A working clock — copy-paste this as your starter. (A slightly fancier
|
A working clock — copy-paste this as your starter. (A slightly fancier
|
||||||
@@ -858,12 +1048,24 @@ 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):
|
||||||
|
|
||||||
- ProdCom: `node tools/prodcom-mock.js` (port 24480) — channels, groups,
|
- ProdCom: `node tools/prodcom-mock.js` (port 24480) — channels, groups,
|
||||||
history, and a live stream with in-progress speech. Point the
|
history, a live stream with in-progress speech, and
|
||||||
transcript module's ProdCom URL at `http://127.0.0.1:24480`.
|
`POST /api/v1/channels/{id}/send`, which lands typed text in the channel
|
||||||
|
as a `source: "typed"` entry (so a send from the Send Message module can
|
||||||
|
be watched arriving; a text containing `!429` answers RATE_LIMITED, and
|
||||||
|
`PRODCOM_MOCK_KEY=…` makes it demand that API key). Point the transcript
|
||||||
|
module's ProdCom endpoint at `127.0.0.1`, port `24480`.
|
||||||
|
- Send Message: no mock — it talks to the installed modules. Run the Slack
|
||||||
|
and ProdCom mocks, set a PIN under Send Message in `/admin` (or switch the
|
||||||
|
PIN off), add a transcript or two plus Send message and Quick replies from
|
||||||
|
+, and watch a send arrive in the transcript you clicked.
|
||||||
- ProPresenter: `node band-lineup-display/tmp/pp-mock.js 1599` — playlists
|
- ProPresenter: `node band-lineup-display/tmp/pp-mock.js 1599` — playlists
|
||||||
and an advancing slide index (including the arrangement-repeat case).
|
and an advancing slide index (including the arrangement-repeat case).
|
||||||
Point the Now/Next module at host `127.0.0.1`, port `1599`.
|
Point the Now/Next module at host `127.0.0.1`, port `1599`.
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ the cycle rather than spinning forever.
|
|||||||
```bash
|
```bash
|
||||||
./build.sh # build/ProdDash.app — the whole application
|
./build.sh # build/ProdDash.app — the whole application
|
||||||
./build.sh --dmg # …and build/ProdDash-<version>-arm64.dmg
|
./build.sh --dmg # …and build/ProdDash-<version>-arm64.dmg
|
||||||
|
ARCH=x86_64 ./build.sh --dmg # the same for Intel Macs (…-x86_64.dmg)
|
||||||
./build.sh --install # …and copy it to /Applications
|
./build.sh --install # …and copy it to /Applications
|
||||||
./build.sh --run # …and open it
|
./build.sh --run # …and open it
|
||||||
./build.sh --notarize # with --dmg: notarise and staple (Developer ID)
|
./build.sh --notarize # with --dmg: notarise and staple (Developer ID)
|
||||||
|
|||||||
+10
-3
@@ -13,6 +13,8 @@
|
|||||||
# ./build.sh --dev launcher only — no Node, no server payload, so
|
# ./build.sh --dev launcher only — no Node, no server payload, so
|
||||||
# it runs against the checkout. Seconds, not
|
# it runs against the checkout. Seconds, not
|
||||||
# minutes, while working on the launcher itself.
|
# minutes, while working on the launcher itself.
|
||||||
|
# ARCH=x86_64 ./build.sh the same for Intel Macs (default: arm64, Apple
|
||||||
|
# silicon); the Node it embeds follows.
|
||||||
#
|
#
|
||||||
# Signing: a "Developer ID Application" certificate is used when one is
|
# Signing: a "Developer ID Application" certificate is used when one is
|
||||||
# installed (override with CODESIGN_IDENTITY). Without one the app is signed
|
# installed (override with CODESIGN_IDENTITY). Without one the app is signed
|
||||||
@@ -29,8 +31,13 @@ LAUNCHER_VERSION="1.0.0"
|
|||||||
# The Node the app ships. Bump it here; the download is checksum-verified
|
# The Node the app ships. Bump it here; the download is checksum-verified
|
||||||
# against nodejs.org's own SHASUMS256.txt and cached in .cache/.
|
# against nodejs.org's own SHASUMS256.txt and cached in .cache/.
|
||||||
NODE_VERSION="v24.21.0"
|
NODE_VERSION="v24.21.0"
|
||||||
ARCH="arm64"
|
ARCH="${ARCH:-arm64}" # arm64 (Apple silicon) or x86_64 (Intel)
|
||||||
DEPLOYMENT_TARGET="13.0"
|
DEPLOYMENT_TARGET="13.0"
|
||||||
|
case "$ARCH" in
|
||||||
|
arm64) NODE_ARCH="arm64" ;;
|
||||||
|
x86_64) NODE_ARCH="x64" ;; # nodejs.org's name for it
|
||||||
|
*) echo "build.sh: ARCH must be arm64 or x86_64, not $ARCH" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
REPO_ROOT="../.."
|
REPO_ROOT="../.."
|
||||||
OUT_DIR="build"
|
OUT_DIR="build"
|
||||||
@@ -108,7 +115,7 @@ fi
|
|||||||
|
|
||||||
# ── the server, and the Node that runs it ────────────────────────────────
|
# ── the server, and the Node that runs it ────────────────────────────────
|
||||||
if [[ $DEV -eq 0 ]]; then
|
if [[ $DEV -eq 0 ]]; then
|
||||||
TARBALL="node-$NODE_VERSION-darwin-$ARCH.tar.gz"
|
TARBALL="node-$NODE_VERSION-darwin-$NODE_ARCH.tar.gz"
|
||||||
mkdir -p "$CACHE_DIR"
|
mkdir -p "$CACHE_DIR"
|
||||||
if [[ ! -f "$CACHE_DIR/$TARBALL" ]]; then
|
if [[ ! -f "$CACHE_DIR/$TARBALL" ]]; then
|
||||||
echo " fetching Node $NODE_VERSION (~50 MB, cached for next time)"
|
echo " fetching Node $NODE_VERSION (~50 MB, cached for next time)"
|
||||||
@@ -127,7 +134,7 @@ if [[ $DEV -eq 0 ]]; then
|
|||||||
echo " embedding Node $NODE_VERSION"
|
echo " embedding Node $NODE_VERSION"
|
||||||
mkdir -p "$CONTENTS/Resources/node/bin"
|
mkdir -p "$CONTENTS/Resources/node/bin"
|
||||||
tar -xzf "$CACHE_DIR/$TARBALL" -C "$CONTENTS/Resources/node/bin" --strip-components 2 \
|
tar -xzf "$CACHE_DIR/$TARBALL" -C "$CONTENTS/Resources/node/bin" --strip-components 2 \
|
||||||
"node-$NODE_VERSION-darwin-$ARCH/bin/node"
|
"node-$NODE_VERSION-darwin-$NODE_ARCH/bin/node"
|
||||||
|
|
||||||
echo " embedding ProdDash $VERSION (no modules — the catalog installs them)"
|
echo " embedding ProdDash $VERSION (no modules — the catalog installs them)"
|
||||||
mkdir -p "$PAYLOAD/config"
|
mkdir -p "$PAYLOAD/config"
|
||||||
|
|||||||
Executable
+121
@@ -0,0 +1,121 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
# Build the Windows bundle — ProdDash-<version>-win-x64.zip: the dashboard,
|
||||||
|
# its own Node runtime and a start script, so nothing has to be installed on
|
||||||
|
# the Windows machine. Unzip, double-click ProdDash.bat, done. It carries no
|
||||||
|
# modules: those come from the admin page's catalog into %APPDATA%\ProdDash.
|
||||||
|
#
|
||||||
|
# ./build.sh build/ProdDash-<version>-win-x64.zip
|
||||||
|
#
|
||||||
|
# Builds on macOS or Linux (zsh, curl, shasum, unzip, zip). Node for Windows
|
||||||
|
# comes from nodejs.org, checksum-verified against its SHASUMS256.txt and
|
||||||
|
# cached in .cache/. There is no Windows launcher app yet: ProdDash.bat keeps
|
||||||
|
# the server running and starts it again when the admin page applies an
|
||||||
|
# update (the server exits with code 75 to ask for that).
|
||||||
|
set -e -u
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
APP_NAME="ProdDash"
|
||||||
|
# The Node the bundle ships — keep in step with launcher/macos/build.sh.
|
||||||
|
NODE_VERSION="v24.21.0"
|
||||||
|
NODE_DIST="node-$NODE_VERSION-win-x64"
|
||||||
|
|
||||||
|
REPO_ROOT="../.."
|
||||||
|
OUT_DIR="build"
|
||||||
|
CACHE_DIR=".cache"
|
||||||
|
|
||||||
|
for tool in curl shasum unzip zip; do
|
||||||
|
command -v "$tool" >/dev/null || { echo "build.sh: $tool not found" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
VERSION=$(sed -n 's/.*"version" *: *"\([^"]*\)".*/\1/p' "$REPO_ROOT/package.json" | head -1)
|
||||||
|
[[ -n "$VERSION" ]] || { echo "build.sh: can't read the version from package.json" >&2; exit 1; }
|
||||||
|
NAME="$APP_NAME-$VERSION-win-x64"
|
||||||
|
STAGE="$OUT_DIR/$NAME"
|
||||||
|
|
||||||
|
echo "ProdDash $VERSION — building the Windows bundle"
|
||||||
|
rm -rf "$OUT_DIR"
|
||||||
|
mkdir -p "$STAGE/node" "$STAGE/config" "$CACHE_DIR"
|
||||||
|
|
||||||
|
# ── Node for Windows ─────────────────────────────────────────────────────
|
||||||
|
ZIPFILE="$NODE_DIST.zip"
|
||||||
|
if [[ ! -f "$CACHE_DIR/$ZIPFILE" ]]; then
|
||||||
|
echo " fetching Node $NODE_VERSION for Windows (~30 MB, cached for next time)"
|
||||||
|
curl -fsSL --retry 3 -o "$CACHE_DIR/$ZIPFILE.part" "https://nodejs.org/dist/$NODE_VERSION/$ZIPFILE"
|
||||||
|
mv "$CACHE_DIR/$ZIPFILE.part" "$CACHE_DIR/$ZIPFILE"
|
||||||
|
fi
|
||||||
|
SUMS="$CACHE_DIR/SHASUMS256-$NODE_VERSION.txt"
|
||||||
|
[[ -f "$SUMS" ]] || curl -fsSL --retry 3 -o "$SUMS" "https://nodejs.org/dist/$NODE_VERSION/SHASUMS256.txt"
|
||||||
|
# This binary ships under ProdDash's name — check it every build.
|
||||||
|
WANT=$(awk -v f="$ZIPFILE" '$2 == f {print $1}' "$SUMS")
|
||||||
|
GOT=$(shasum -a 256 "$CACHE_DIR/$ZIPFILE" | awk '{print $1}')
|
||||||
|
if [[ -z "$WANT" || "$WANT" != "$GOT" ]]; then
|
||||||
|
echo "build.sh: $ZIPFILE does not match nodejs.org's checksum — refusing to ship it" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " embedding Node $NODE_VERSION (node.exe and its licence)"
|
||||||
|
unzip -q -j "$CACHE_DIR/$ZIPFILE" "$NODE_DIST/node.exe" "$NODE_DIST/LICENSE" -d "$STAGE/node"
|
||||||
|
|
||||||
|
# ── ProdDash ─────────────────────────────────────────────────────────────
|
||||||
|
echo " embedding ProdDash $VERSION (no modules — the catalog installs them)"
|
||||||
|
cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$STAGE/"
|
||||||
|
cp -R "$REPO_ROOT/public" "$STAGE/"
|
||||||
|
# Only the checked-in defaults: config/ also holds this machine's own
|
||||||
|
# modules.json and layouts when ProdDash has ever run from the checkout.
|
||||||
|
cp "$REPO_ROOT/config/proddash.json" "$STAGE/config/"
|
||||||
|
find "$STAGE" -name '.DS_Store' -delete
|
||||||
|
|
||||||
|
# ── the start script and the read-me, with Windows line endings ──────────
|
||||||
|
crlf() { awk '{ printf "%s\r\n", $0 }' > "$1"; }
|
||||||
|
|
||||||
|
crlf "$STAGE/ProdDash.bat" <<'BAT'
|
||||||
|
@echo off
|
||||||
|
rem ProdDash - double-click to start. Keep this window open: closing it stops the dashboard.
|
||||||
|
title ProdDash
|
||||||
|
cd /d "%~dp0"
|
||||||
|
rem The server exits with code 75 after applying an update from the admin page; start it again.
|
||||||
|
set PRODDASH_LAUNCHER=1
|
||||||
|
:run
|
||||||
|
"%~dp0node\node.exe" server.js
|
||||||
|
set CODE=%ERRORLEVEL%
|
||||||
|
if %CODE% EQU 75 goto run
|
||||||
|
echo.
|
||||||
|
echo ProdDash stopped (exit code %CODE%). Press any key to close this window.
|
||||||
|
pause >nul
|
||||||
|
BAT
|
||||||
|
|
||||||
|
crlf "$STAGE/README.txt" <<README
|
||||||
|
ProdDash $VERSION for Windows (64-bit)
|
||||||
|
=======================================
|
||||||
|
|
||||||
|
Nothing to install. This folder holds the dashboard and its own copy of Node.
|
||||||
|
|
||||||
|
1. Move this folder somewhere you can write to, for example C:\ProdDash
|
||||||
|
(not Program Files: updates write into this folder).
|
||||||
|
2. Double-click ProdDash.bat. A window opens and stays open while the
|
||||||
|
dashboard runs; closing it stops the dashboard.
|
||||||
|
- If Windows warns about the file, choose More info, then Run anyway.
|
||||||
|
- When Windows Defender Firewall asks about node.exe, allow it on
|
||||||
|
private networks so other screens can reach the dashboard.
|
||||||
|
3. Open http://localhost:24500 on this PC. Other devices on the network use
|
||||||
|
http://<this PC's IP address>:24500. Settings are on the admin page,
|
||||||
|
http://localhost:24500/admin.
|
||||||
|
4. The bundle ships without modules. On the admin page, tick the ones this
|
||||||
|
PC needs under Available modules and press Install selected; each one
|
||||||
|
updates on its own from then on.
|
||||||
|
|
||||||
|
Your settings and layouts live in %APPDATA%\ProdDash, not in this folder,
|
||||||
|
so unzipping a new version or updating never loses them.
|
||||||
|
|
||||||
|
Start with Windows: press Win+R, type shell:startup, press Enter, and put a
|
||||||
|
shortcut to ProdDash.bat in the folder that opens.
|
||||||
|
|
||||||
|
Updates: the admin page's Update now fetches the new version into this
|
||||||
|
folder and restarts the dashboard; the window stays open. A copy of the
|
||||||
|
version it replaced is kept for a short while in case the new one will not
|
||||||
|
start.
|
||||||
|
README
|
||||||
|
|
||||||
|
# ── zip ──────────────────────────────────────────────────────────────────
|
||||||
|
echo " zipping"
|
||||||
|
(cd "$OUT_DIR" && zip -q -r -X "$NAME.zip" "$NAME")
|
||||||
|
echo "built $OUT_DIR/$NAME.zip ($(du -sh "$OUT_DIR/$NAME.zip" | awk '{print $1}'))"
|
||||||
+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",
|
||||||
|
|||||||
+249
-68
@@ -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,40 +2172,46 @@ 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] });
|
||||||
try {
|
const options = [auto];
|
||||||
const plans = await current.listPlans(stId, { future: 8, past: 6 });
|
let failure = '';
|
||||||
const picked = current.pickPlan(plans);
|
for (const stId of asked) {
|
||||||
const now = Date.now();
|
try {
|
||||||
const options = [auto];
|
const [name, plans] = await Promise.all([
|
||||||
// Upcoming first (soonest at the top), then the recent ones.
|
current.typeName(stId),
|
||||||
const ordered = [
|
current.listPlans(stId, { future: 8, past: 6 }),
|
||||||
...plans.filter((p) => p.sortDate >= now - 12 * 3600000),
|
]);
|
||||||
...plans.filter((p) => p.sortDate < now - 12 * 3600000).reverse(),
|
const picked = current.pickPlan(plans);
|
||||||
];
|
const now = Date.now();
|
||||||
for (const { rec, sortDate } of ordered) {
|
const ordered = [
|
||||||
const a = rec.attributes || {};
|
...plans.filter((p) => p.sortDate >= now - 12 * 3600000),
|
||||||
const bits = [String(a.dates || a.short_dates || new Date(sortDate).toLocaleDateString())];
|
...plans.filter((p) => p.sortDate < now - 12 * 3600000).reverse(),
|
||||||
if (a.series_title) bits.push(String(a.series_title));
|
];
|
||||||
if (a.title) bits.push(String(a.title));
|
for (const { rec, sortDate } of ordered) {
|
||||||
let label = bits.join(' — ');
|
const a = rec.attributes || {};
|
||||||
if (picked && rec.id === picked.id) label += ' (Automatic picks this)';
|
const bits = [String(a.dates || a.short_dates || new Date(sortDate).toLocaleDateString())];
|
||||||
options.push({ value: String(rec.id), label, group: sortDate >= now - 12 * 3600000 ? 'Upcoming' : 'Recent' });
|
if (a.series_title) bits.push(String(a.series_title));
|
||||||
|
if (a.title) bits.push(String(a.title));
|
||||||
|
let label = bits.join(' — ');
|
||||||
|
if (picked && rec.id === picked.id) label += ' (this one, on Automatic)';
|
||||||
|
options.push({ value: `${stId}/${rec.id}`, label, group: name });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
failure = err?.message || String(err);
|
||||||
}
|
}
|
||||||
json(res, 200, { options });
|
|
||||||
} catch (err) {
|
|
||||||
json(res, err?.status === 401 ? 401 : 502, { options: [auto], error: 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 || ''));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,7 +5,45 @@
|
|||||||
(hidden channels, channel icons, timestamps, text size, flow direction)
|
(hidden channels, channel icons, timestamps, text size, flow direction)
|
||||||
persists in instance settings, and the data comes from the module's own
|
persists in instance settings, and the data comes from the module's own
|
||||||
server, which keeps one connection to ProdCom for every dashboard: a
|
server, which keeps one connection to ProdCom for every dashboard: a
|
||||||
snapshot on connect (`state`), then each changed entry (`entry`). */
|
snapshot on connect (`state`), then each changed entry (`entry`).
|
||||||
|
|
||||||
|
Messaging: a click anywhere in the tile makes ProdCom the platform the
|
||||||
|
Send Message module's tiles send through (the channel the text goes out
|
||||||
|
*as* is picked in their Send as menu, among the channels ProdCom lets text
|
||||||
|
be typed onto); while it is, the tile's bottom edge carries the accent
|
||||||
|
stripe.
|
||||||
|
That is shared through the page bus below — see "Messaging on the page"
|
||||||
|
in docs/MODULE-GUIDE.md. Text typed from a dashboard comes back on the
|
||||||
|
stream as an entry with source "typed" and is drawn with a keyboard mark. */
|
||||||
|
|
||||||
|
/* ── the page bus (shared with the Send Message module) ───────────── */
|
||||||
|
|
||||||
|
const PROVIDER_ID = 'prodcom-transcript';
|
||||||
|
const BUS_EVENT = 'proddash:messaging:';
|
||||||
|
|
||||||
|
function messagingBus() {
|
||||||
|
if (!window.proddashMessaging) {
|
||||||
|
window.proddashMessaging = {
|
||||||
|
version: 1,
|
||||||
|
target: { provider: '', destination: '', providerLabel: '', destinationLabel: '', identity: '' },
|
||||||
|
unlock: { token: '', expiresAt: 0, reason: '' },
|
||||||
|
quickEmojis: [],
|
||||||
|
transcripts: new Map(),
|
||||||
|
senders: new Set(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return window.proddashMessaging;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(name, detail = {}) {
|
||||||
|
window.dispatchEvent(new CustomEvent(BUS_EVENT + name, { detail }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function on(name, fn) {
|
||||||
|
const handler = (e) => fn(e.detail || {});
|
||||||
|
window.addEventListener(BUS_EVENT + name, handler);
|
||||||
|
return () => window.removeEventListener(BUS_EVENT + name, handler);
|
||||||
|
}
|
||||||
|
|
||||||
/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */
|
/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */
|
||||||
const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>';
|
const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>';
|
||||||
@@ -21,6 +59,8 @@ export default function create({ root, moduleApi }) {
|
|||||||
let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []);
|
let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []);
|
||||||
let stream = null; // moduleApi.sse handle
|
let stream = null; // moduleApi.sse handle
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
|
let lastStatus = null; // the link's last status, re-reported when the target changes
|
||||||
|
const bus = messagingBus();
|
||||||
|
|
||||||
/* ── DOM skeleton ───────────────────────────────────────────────── */
|
/* ── DOM skeleton ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -39,6 +79,26 @@ export default function create({ root, moduleApi }) {
|
|||||||
const emptyEl = root.querySelector('.pt-empty');
|
const emptyEl = root.querySelector('.pt-empty');
|
||||||
const jumpBtn = root.querySelector('.pt-jump');
|
const jumpBtn = root.querySelector('.pt-jump');
|
||||||
|
|
||||||
|
/* ── the send target: a click anywhere in the body points the senders here ── */
|
||||||
|
|
||||||
|
wrap.addEventListener('click', () => {
|
||||||
|
if (!channels.size) return; // nothing to send to yet
|
||||||
|
emit('select', { provider: PROVIDER_ID, destination: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
function isTarget() {
|
||||||
|
return bus.target.provider === PROVIDER_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintTarget() {
|
||||||
|
if (stopped) return;
|
||||||
|
const on = isTarget();
|
||||||
|
if (on === wrap.classList.contains('is-target')) return;
|
||||||
|
wrap.classList.toggle('is-target', on);
|
||||||
|
if (lastStatus) applyStatus(lastStatus);
|
||||||
|
}
|
||||||
|
const offTarget = on('target', paintTarget);
|
||||||
|
|
||||||
/* ── title-bar controls ─────────────────────────────────────────── */
|
/* ── title-bar controls ─────────────────────────────────────────── */
|
||||||
|
|
||||||
const CLOCK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
|
const CLOCK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
|
||||||
@@ -81,8 +141,8 @@ export default function create({ root, moduleApi }) {
|
|||||||
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
|
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
|
||||||
applyInstanceSettings();
|
applyInstanceSettings();
|
||||||
}
|
}
|
||||||
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
const minusBtn = moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
||||||
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
|
const plusBtn = moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
|
||||||
|
|
||||||
// icon and tooltip track the current direction — applyInstanceSettings sets both
|
// icon and tooltip track the current direction — applyInstanceSettings sets both
|
||||||
const flowBtn = moduleApi.header.addButton({
|
const flowBtn = moduleApi.header.addButton({
|
||||||
@@ -95,7 +155,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
moduleApi.header.addButton({
|
const latestBtn = moduleApi.header.addButton({
|
||||||
icon: JUMP_SVG,
|
icon: JUMP_SVG,
|
||||||
title: 'Latest',
|
title: 'Latest',
|
||||||
onClick() {
|
onClick() {
|
||||||
@@ -112,6 +172,82 @@ export default function create({ root, moduleApi }) {
|
|||||||
});
|
});
|
||||||
clearBtn.classList.add('pt-clear');
|
clearBtn.classList.add('pt-clear');
|
||||||
|
|
||||||
|
/* ── a narrow tile: the bar keeps Channels, the rest fold into ⋯ ──
|
||||||
|
Seven controls need ~175 px, and the shell clips whatever the bar
|
||||||
|
cannot fit — on a 3-column tile among many, or one squeezed by the
|
||||||
|
fullscreen fit, that used to take the channel filter with it. Below
|
||||||
|
COMPACT_PX the six secondary controls hide and a single ⋯ menu offers
|
||||||
|
the same actions, so the filter button is the last thing to go. */
|
||||||
|
const MORE_SVG = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>';
|
||||||
|
const COMPACT_PX = 380; // tile width below which the full bar no longer fits
|
||||||
|
|
||||||
|
let moreMenuEl = null;
|
||||||
|
const moreMenu = moduleApi.header.addMenu({
|
||||||
|
icon: MORE_SVG,
|
||||||
|
title: 'More',
|
||||||
|
build(menu) {
|
||||||
|
menu.classList.add('pt-menu');
|
||||||
|
moreMenuEl = menu;
|
||||||
|
rebuildMoreMenu();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const moreWrap = moreMenu.button.parentElement; // the shell's .tile-dd
|
||||||
|
const secondaryBtns = [timeBtn, minusBtn, plusBtn, flowBtn, latestBtn, clearBtn];
|
||||||
|
|
||||||
|
function addMoreItem(label, { on = null, onClick } = {}) {
|
||||||
|
const item = document.createElement('button');
|
||||||
|
item.className = 'pt-channel-item' + (on ? ' on' : '');
|
||||||
|
if (on !== null) {
|
||||||
|
item.setAttribute('role', 'menuitemcheckbox');
|
||||||
|
item.setAttribute('aria-checked', String(on));
|
||||||
|
} else {
|
||||||
|
item.setAttribute('role', 'menuitem');
|
||||||
|
}
|
||||||
|
const check = document.createElement('span');
|
||||||
|
check.className = 'check';
|
||||||
|
check.textContent = '✓';
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'name';
|
||||||
|
name.textContent = label;
|
||||||
|
item.append(check, name);
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation(); // the rebuild detaches this item — keep the menu open
|
||||||
|
onClick();
|
||||||
|
});
|
||||||
|
moreMenuEl.appendChild(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildMoreMenu() {
|
||||||
|
if (!moreMenuEl) return;
|
||||||
|
moreMenuEl.innerHTML = '';
|
||||||
|
const s = moduleApi.instanceSettings;
|
||||||
|
addMoreItem('Timestamps', { on: Boolean(s.showTimes), onClick: () => timeBtn.click() });
|
||||||
|
addMoreItem('Newest first', { on: newestFirst(), onClick: () => flowBtn.click() });
|
||||||
|
const divider = document.createElement('div');
|
||||||
|
divider.className = 'pt-menu-divider';
|
||||||
|
moreMenuEl.appendChild(divider);
|
||||||
|
addMoreItem('Smaller text', { onClick: () => bumpTextSize(-2) });
|
||||||
|
addMoreItem('Larger text', { onClick: () => bumpTextSize(2) });
|
||||||
|
addMoreItem('Latest', { onClick: () => { moreMenu.close(); latestBtn.click(); } });
|
||||||
|
addMoreItem('Clear', { onClick: () => { moreMenu.close(); clearView(); } });
|
||||||
|
}
|
||||||
|
|
||||||
|
let compact = null; // unknown until the first measurement
|
||||||
|
function applyCompact(width) {
|
||||||
|
const next = width > 0 && width < COMPACT_PX;
|
||||||
|
if (next === compact) return;
|
||||||
|
compact = next;
|
||||||
|
for (const btn of secondaryBtns) btn.hidden = compact;
|
||||||
|
moreWrap.hidden = !compact;
|
||||||
|
if (!compact) moreMenu.close();
|
||||||
|
}
|
||||||
|
// the tile's own body tells the width; onResize only knows grid cells
|
||||||
|
const sizeWatch = new ResizeObserver((entries) => {
|
||||||
|
applyCompact(entries[0]?.contentRect.width || root.clientWidth);
|
||||||
|
});
|
||||||
|
sizeWatch.observe(root);
|
||||||
|
applyCompact(root.clientWidth);
|
||||||
|
|
||||||
function applyInstanceSettings() {
|
function applyInstanceSettings() {
|
||||||
const s = moduleApi.instanceSettings;
|
const s = moduleApi.instanceSettings;
|
||||||
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
|
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
|
||||||
@@ -126,6 +262,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
flowBtn.classList.toggle('active', up);
|
flowBtn.classList.toggle('active', up);
|
||||||
flowBtn.title = up ? 'Newest first' : 'Oldest first';
|
flowBtn.title = up ? 'Newest first' : 'Oldest first';
|
||||||
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
|
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
|
||||||
|
rebuildMoreMenu(); // its checkmarks mirror the buttons
|
||||||
}
|
}
|
||||||
applyInstanceSettings();
|
applyInstanceSettings();
|
||||||
|
|
||||||
@@ -309,10 +446,18 @@ export default function create({ root, moduleApi }) {
|
|||||||
const avatar = document.createElement('span');
|
const avatar = document.createElement('span');
|
||||||
avatar.className = 'avatar';
|
avatar.className = 'avatar';
|
||||||
avatar.style.color = info.color;
|
avatar.style.color = info.color;
|
||||||
avatar.innerHTML =
|
// speech is a person; typed text (from a dashboard, or ProdCom's own
|
||||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">' +
|
// box) a keyboard; an automation a bolt
|
||||||
'<circle cx="12" cy="8.5" r="3.2"/>' +
|
avatar.innerHTML = entry.source === 'typed'
|
||||||
'<path d="M5.5 19c1.6-3.2 3.9-4.8 6.5-4.8s4.9 1.6 6.5 4.8"/></svg>';
|
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
||||||
|
'<rect x="3" y="7" width="18" height="11" rx="2"/><path d="M7 11h1M11 11h1M15 11h1M8 14.5h8"/></svg>'
|
||||||
|
: entry.source === 'automation'
|
||||||
|
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
||||||
|
'<path d="M13 3 5 14h6l-1 7 8-11h-6z"/></svg>'
|
||||||
|
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">' +
|
||||||
|
'<circle cx="12" cy="8.5" r="3.2"/>' +
|
||||||
|
'<path d="M5.5 19c1.6-3.2 3.9-4.8 6.5-4.8s4.9 1.6 6.5 4.8"/></svg>';
|
||||||
|
if (entry.source === 'typed' || entry.source === 'automation') el.classList.add(entry.source);
|
||||||
|
|
||||||
const ch = document.createElement('span');
|
const ch = document.createElement('span');
|
||||||
ch.className = 'channel';
|
ch.className = 'channel';
|
||||||
@@ -357,14 +502,27 @@ export default function create({ root, moduleApi }) {
|
|||||||
|
|
||||||
function applyStatus(st) {
|
function applyStatus(st) {
|
||||||
if (!st || typeof st !== 'object') return;
|
if (!st || typeof st !== 'object') return;
|
||||||
if (st.state === 'ok') moduleApi.setStatus('ok', st.message || 'Live');
|
lastStatus = st;
|
||||||
else moduleApi.setStatus(st.state === 'error' ? 'error' : 'connecting', st.message || 'Connecting to ProdCom…');
|
if (st.state === 'ok') {
|
||||||
|
// the Send Message tiles point here: typed text goes out as the chosen channel
|
||||||
|
const as = !isTarget() ? ''
|
||||||
|
: bus.target.destinationLabel ? ` · sends go out as ${bus.target.destinationLabel}`
|
||||||
|
: ' · selected — no channel accepts typed text';
|
||||||
|
moduleApi.setStatus('ok', (st.message || 'Live') + as);
|
||||||
|
} else moduleApi.setStatus(st.state === 'error' ? 'error' : 'connecting', st.message || 'Connecting to ProdCom…');
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySnapshot(snap) {
|
function applySnapshot(snap) {
|
||||||
if (!snap || typeof snap !== 'object') return;
|
if (!snap || typeof snap !== 'object') return;
|
||||||
channels.clear();
|
channels.clear();
|
||||||
for (const ch of snap.channels || []) channels.set(String(ch.id), { name: ch.name, color: ch.color || '#8b98a5' });
|
for (const ch of snap.channels || []) channels.set(String(ch.id), { name: ch.name, color: ch.color || '#8b98a5' });
|
||||||
|
// On the page and able to send: that is what lets the Send Message tiles
|
||||||
|
// pick ProdCom by default when it is the only transcript showing.
|
||||||
|
if (channels.size && !bus.transcripts.has(moduleApi.instanceId)) {
|
||||||
|
bus.transcripts.set(moduleApi.instanceId, { provider: PROVIDER_ID, destination: '' });
|
||||||
|
emit('transcripts', {});
|
||||||
|
}
|
||||||
|
wrap.classList.toggle('is-target', isTarget());
|
||||||
groups.clear();
|
groups.clear();
|
||||||
for (const g of snap.groups || []) groups.set(String(g.id), { name: g.name, channelIds: g.channelIds || [] });
|
for (const g of snap.groups || []) groups.set(String(g.id), { name: g.name, channelIds: g.channelIds || [] });
|
||||||
rebuildChannelMenu();
|
rebuildChannelMenu();
|
||||||
@@ -415,6 +573,9 @@ export default function create({ root, moduleApi }) {
|
|||||||
},
|
},
|
||||||
stop() {
|
stop() {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
|
sizeWatch.disconnect();
|
||||||
|
offTarget();
|
||||||
|
if (bus.transcripts.delete(moduleApi.instanceId)) emit('transcripts', {});
|
||||||
stream?.close();
|
stream?.close();
|
||||||
root.innerHTML = ''; // header controls are removed by the shell
|
root.innerHTML = ''; // header controls are removed by the shell
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
{
|
{
|
||||||
"id": "prodcom-transcript",
|
"id": "prodcom-transcript",
|
||||||
"name": "ProdCom",
|
"name": "ProdCom",
|
||||||
"version": "1.3.2",
|
"version": "1.4.0",
|
||||||
"proddash": ">=1.1.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Live ProdCom transcript",
|
"description": "Live ProdCom transcript; typed text to a ProdCom channel through the Send Message module",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
{
|
{
|
||||||
"kind": "local-network",
|
"kind": "local-network",
|
||||||
"reason": "Reaching the ProdCom API on this network"
|
"reason": "Reaching the ProdCom API on this network"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"provides": [
|
||||||
|
"messaging"
|
||||||
|
],
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
"style": "style.css",
|
"style": "style.css",
|
||||||
@@ -29,7 +32,7 @@
|
|||||||
"host": "",
|
"host": "",
|
||||||
"port": 24480
|
"port": 24480
|
||||||
},
|
},
|
||||||
"help": "The ProdCom server at THIS location — every campus has its own. Blank until you set it, so a new install never reads another campus's comms."
|
"help": "The ProdCom server at THIS location — every campus has its own. Blank until you set it, so a new install never reads another campus’s comms. The Send Message module sends typed text to these channels through this connection."
|
||||||
},
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"type": "password",
|
"type": "password",
|
||||||
|
|||||||
@@ -11,19 +11,36 @@
|
|||||||
* connections; ProdCom now sees one.)
|
* connections; ProdCom now sees one.)
|
||||||
*
|
*
|
||||||
* Routes (under /api/modules/prodcom-transcript):
|
* Routes (under /api/modules/prodcom-transcript):
|
||||||
* GET /state → { status, channels, groups, entries } (entries by date)
|
* GET /state → { status, channels, groups, entries } (entries by date)
|
||||||
* GET /stream → SSE: `state` (the snapshot, first), then `entry` for each
|
* GET /stream → SSE: `state` (the snapshot, first), then `entry` for each
|
||||||
* changed entry, `clear`, and `status` when the link changes
|
* changed entry, `clear`, and `status` when the link changes
|
||||||
|
* GET /messaging → what the Send Message module may send as: every
|
||||||
|
* channel that accepts typed text (typingEnabled), as
|
||||||
|
* an identity — typed text goes out *as* that channel
|
||||||
|
* (the module guide's contract)
|
||||||
|
* POST /messaging/send → { destination, text } with the Send Message unlock
|
||||||
|
* as a Bearer token — checked with that module over
|
||||||
|
* loopback, then POST /api/v1/channels/{id}/send, so a
|
||||||
|
* dashboard's typed text goes out on a ProdCom channel
|
||||||
|
* over this one connection. This module has no PIN and
|
||||||
|
* no send tile of its own.
|
||||||
*
|
*
|
||||||
* ProdCom's API, as the reference listener speaks it:
|
* ProdCom's API, as the reference listener speaks it:
|
||||||
* GET /api/v1/channels, /api/v1/groups, /api/v1/transcript?limit=N and the
|
* GET /api/v1/channels, /api/v1/groups, /api/v1/transcript?limit=N and the
|
||||||
* SSE /api/v1/transcript/stream, whose frames carry an entry, or a
|
* SSE /api/v1/transcript/stream, whose frames carry an entry, or a
|
||||||
* { type, entry | data } wrapper, in a few historical shapes (see
|
* { type, entry | data } wrapper, in a few historical shapes (see
|
||||||
* extractEntries). A bearer API key is sent when one is configured.
|
* extractEntries). POST /api/v1/channels/{id}/send with { text } puts typed
|
||||||
|
* text on a channel (it comes back on the stream with source "typed"). A
|
||||||
|
* bearer API key is sent when one is configured.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
|
|
||||||
|
/** The module that owns the PIN and the unlock tokens — see verifyUnlock(). */
|
||||||
|
const SENDER_ID = 'send-message';
|
||||||
|
const MAX_TEXT_CHARS = 2000;
|
||||||
|
const BODY_LIMIT_BYTES = 64 * 1024;
|
||||||
|
|
||||||
const HISTORY_LIMIT = 100; // entries asked for on (re)connect
|
const HISTORY_LIMIT = 100; // entries asked for on (re)connect
|
||||||
const ENTRIES_MAX = 300; // entries kept in memory
|
const ENTRIES_MAX = 300; // entries kept in memory
|
||||||
const RETRY_MIN_MS = 2000;
|
const RETRY_MIN_MS = 2000;
|
||||||
@@ -67,13 +84,92 @@ function normalizeEntry(e) {
|
|||||||
text: String(e.text ?? ''),
|
text: String(e.text ?? ''),
|
||||||
date: e.date ? String(e.date) : new Date().toISOString(),
|
date: e.date ? String(e.date) : new Date().toISOString(),
|
||||||
inProgress: Boolean(e.inProgress),
|
inProgress: Boolean(e.inProgress),
|
||||||
|
// audio (speech), typed (a dashboard or ProdCom's own box), automation
|
||||||
|
source: e.source === 'typed' || e.source === 'automation' ? String(e.source) : 'audio',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── small helpers ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function sendJson(res, code, body) {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let data = '';
|
||||||
|
let size = 0;
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > BODY_LIMIT_BYTES) {
|
||||||
|
reject(new Error('Request body too large.'));
|
||||||
|
req.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
if (!data.trim()) return resolve({});
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(data);
|
||||||
|
resolve(body && typeof body === 'object' ? body : {});
|
||||||
|
} catch {
|
||||||
|
reject(new Error('Malformed JSON.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Route handlers are async — the shell only catches synchronous throws. */
|
||||||
|
function guard(fn) {
|
||||||
|
return (req, res) => {
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => fn(req, res))
|
||||||
|
.catch((err) => {
|
||||||
|
if (!res.headersSent) sendJson(res, 500, { error: err?.message || String(err) });
|
||||||
|
else try { res.end(); } catch { /* gone */ }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bearer(req) {
|
||||||
|
const m = /^Bearer\s+([A-Za-z0-9]+)\s*$/.exec(String(req.headers.authorization || ''));
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the unlock token on this request good? Only the Send Message module
|
||||||
|
* knows — it owns the PIN — so ask it over loopback. Answers { ok: true,
|
||||||
|
* expiresAt } or { ok: false, status, error, code } ready to send back.
|
||||||
|
*/
|
||||||
|
async function verifyUnlock(req, shellPort) {
|
||||||
|
const token = bearer(req);
|
||||||
|
if (!token) return { ok: false, status: 401, error: 'Locked — enter the PIN', code: 'locked' };
|
||||||
|
if (!shellPort) return { ok: false, status: 503, error: 'This ProdDash cannot verify a PIN (needs 1.4.0 or newer)', code: 'no_shell_port' };
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(`http://127.0.0.1:${shellPort}/api/modules/${SENDER_ID}/verify`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, status: 502, error: `Cannot reach the Send Message module (${err.cause?.code || err.message})`, code: 'sender_unreachable' };
|
||||||
|
}
|
||||||
|
if (res.status === 404) return { ok: false, status: 503, error: 'Install the Send Message module to send from a dashboard', code: 'no_sender' };
|
||||||
|
let body = {};
|
||||||
|
try { body = await res.json(); } catch { /* a non-JSON refusal */ }
|
||||||
|
if (res.status === 401 || body?.ok === false) return { ok: false, status: 401, error: body?.error || 'Locked — enter the PIN', code: body?.code || 'locked' };
|
||||||
|
if (!res.ok) return { ok: false, status: 502, error: `Send Message module answered HTTP ${res.status}`, code: 'sender_error' };
|
||||||
|
return { ok: true, expiresAt: Number(body?.expiresAt) || 0 };
|
||||||
|
}
|
||||||
|
|
||||||
let current = null; // the running instance, for routes()
|
let current = null; // the running instance, for routes()
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
init({ config, log }) {
|
init({ config, log, shell }) {
|
||||||
|
const shellPort = Number(shell && shell.port) || 0;
|
||||||
const target = resolveTarget(config);
|
const target = resolveTarget(config);
|
||||||
const apiKey = String(config.apiKey || '');
|
const apiKey = String(config.apiKey || '');
|
||||||
const channels = [];
|
const channels = [];
|
||||||
@@ -148,7 +244,14 @@ module.exports = {
|
|||||||
getJson('/api/v1/channels'),
|
getJson('/api/v1/channels'),
|
||||||
getJson('/api/v1/groups').catch(() => ({ data: [] })), // groups are optional in ProdCom
|
getJson('/api/v1/groups').catch(() => ({ data: [] })), // groups are optional in ProdCom
|
||||||
]);
|
]);
|
||||||
channels.splice(0, channels.length, ...(ch.data || []).map((c) => ({ id: String(c.id), name: String(c.name || c.id), color: c.color || '#8b98a5' })));
|
channels.splice(0, channels.length, ...(ch.data || []).map((c) => ({
|
||||||
|
id: String(c.id),
|
||||||
|
name: String(c.name || c.id),
|
||||||
|
color: c.color || '#8b98a5',
|
||||||
|
// whether ProdCom lets text be typed onto this channel (an older
|
||||||
|
// ProdCom that doesn't say is taken at its word when asked)
|
||||||
|
typingEnabled: c.typingEnabled !== false,
|
||||||
|
})));
|
||||||
groups.splice(0, groups.length, ...(gr.data || []).map((g) => ({ id: String(g.id), name: String(g.name || g.id), channelIds: Array.isArray(g.channelIds) ? g.channelIds.map(String) : [] })));
|
groups.splice(0, groups.length, ...(gr.data || []).map((g) => ({ id: String(g.id), name: String(g.name || g.id), channelIds: Array.isArray(g.channelIds) ? g.channelIds.map(String) : [] })));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,9 +367,75 @@ module.exports = {
|
|||||||
log('no ProdCom server configured — set the endpoint in /admin');
|
log('no ProdCom server configured — set the endpoint in /admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A channel by id, or by name (case-insensitive) — ProdCom accepts both, the tiles use ids. */
|
||||||
|
function channelByRef(ref) {
|
||||||
|
const s = String(ref ?? '').trim();
|
||||||
|
if (!s) return null;
|
||||||
|
return channels.find((c) => c.id === s) || channels.find((c) => c.name.toLowerCase() === s.toLowerCase()) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the Send Message module may send as: the channels that accept
|
||||||
|
* typed text. A ProdCom channel is an identity, not a recipient — text
|
||||||
|
* typed onto it goes out *as* that channel — so the destinations are
|
||||||
|
* declared as such, and a channel with typing off is left out.
|
||||||
|
*/
|
||||||
|
function messagingDescriptor() {
|
||||||
|
const h = current ? current.health() : { status: 'connecting', message: 'Restarting…' };
|
||||||
|
return {
|
||||||
|
label: 'ProdCom',
|
||||||
|
identity: '',
|
||||||
|
destinationRole: 'identity',
|
||||||
|
status: { state: h.status, message: h.message },
|
||||||
|
destinations: channels.filter((c) => c.typingEnabled).map((c) => ({ id: c.id, label: c.name, color: c.color || '' })),
|
||||||
|
emptyMessage: channels.length
|
||||||
|
? 'No ProdCom channel accepts typed text — turn on typing for one in ProdCom'
|
||||||
|
: 'No ProdCom channels yet',
|
||||||
|
maxChars: MAX_TEXT_CHARS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Typed text onto a channel: the one ProdCom connection does the talking. */
|
||||||
|
async function sendText(ch, text) {
|
||||||
|
if (!target) return { status: 502, error: 'No ProdCom server configured — set the endpoint in Admin → ProdCom', code: 'no_target' };
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(new URL(`/api/v1/channels/${encodeURIComponent(ch.id)}/send`, target), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json; charset=utf-8', accept: 'application/json', ...authHeaders() },
|
||||||
|
body: JSON.stringify({ text }),
|
||||||
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return { status: 502, error: `ProdCom unreachable (${err.cause?.code || err.code || err.message}) — not sent`, code: 'network' };
|
||||||
|
}
|
||||||
|
if (res.ok) {
|
||||||
|
res.body?.cancel?.().catch?.(() => {});
|
||||||
|
return { status: 200 };
|
||||||
|
}
|
||||||
|
let detail = '';
|
||||||
|
let code = '';
|
||||||
|
try {
|
||||||
|
const body = await res.json();
|
||||||
|
detail = String(body?.error?.message || '');
|
||||||
|
code = String(body?.error?.code || '');
|
||||||
|
} catch { /* no JSON */ }
|
||||||
|
const tail = detail ? ` — ${detail}` : '';
|
||||||
|
if (res.status === 401) return { status: 502, error: `ProdCom rejected the API key (${code || 'UNAUTHORIZED'}) — check Admin → ProdCom`, code: 'unauthorized' };
|
||||||
|
if (res.status === 404) return { status: 404, error: `ProdCom has no channel "${ch.name}" any more (${code || 'NOT_FOUND'}) — pick another in Send as`, code: 'not_found' };
|
||||||
|
if (res.status === 429) return { status: 429, error: `ProdCom is rate-limiting — try again in a moment${tail}`, code: 'ratelimited' };
|
||||||
|
if (res.status === 400 || res.status === 422) return { status: 400, error: `ProdCom refused the message (${code || res.status})${tail}`, code: 'refused' };
|
||||||
|
return { status: 502, error: `ProdCom answered HTTP ${res.status}${tail}`, code: code || `http_${res.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
current = {
|
current = {
|
||||||
snapshot,
|
snapshot,
|
||||||
streams,
|
streams,
|
||||||
|
shellPort,
|
||||||
|
channelByRef,
|
||||||
|
messagingDescriptor,
|
||||||
|
sendText,
|
||||||
|
log,
|
||||||
health() {
|
health() {
|
||||||
if (status.state === 'ok') return { status: 'ok', message: `Live — ${channels.length} channel${channels.length === 1 ? '' : 's'}, ${entries.size} entries` };
|
if (status.state === 'ok') return { status: 'ok', message: `Live — ${channels.length} channel${channels.length === 1 ? '' : 's'}, ${entries.size} entries` };
|
||||||
// Nothing to connect to is a setting that is missing, not a link that
|
// Nothing to connect to is a setting that is missing, not a link that
|
||||||
@@ -295,15 +464,52 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
routes() {
|
routes() {
|
||||||
const sendJson = (res, code, body) => {
|
const unmounted = (res) => sendJson(res, 503, { error: 'ProdCom module is restarting — try again' });
|
||||||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
||||||
res.end(JSON.stringify(body));
|
|
||||||
};
|
|
||||||
return {
|
return {
|
||||||
'GET /state': (req, res) => {
|
'GET /state': (req, res) => {
|
||||||
if (!current) return sendJson(res, 503, { error: 'ProdCom module is restarting — try again' });
|
if (!current) return unmounted(res);
|
||||||
sendJson(res, 200, current.snapshot());
|
sendJson(res, 200, current.snapshot());
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// The Send Message module reads this over loopback: where it may send.
|
||||||
|
'GET /messaging': (req, res) => {
|
||||||
|
if (!current) return unmounted(res);
|
||||||
|
sendJson(res, 200, current.messagingDescriptor());
|
||||||
|
},
|
||||||
|
|
||||||
|
// …and hands a message here, with the browser's unlock token. The token
|
||||||
|
// is checked with the Send Message module first: no PIN entered there,
|
||||||
|
// nothing goes out on a channel.
|
||||||
|
'POST /messaging/send': guard(async (req, res) => {
|
||||||
|
if (!current) return unmounted(res);
|
||||||
|
const v = await verifyUnlock(req, current.shellPort);
|
||||||
|
if (!v.ok) return sendJson(res, v.status, { error: v.error, code: v.code });
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await readJson(req);
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, 400, { error: err.message });
|
||||||
|
}
|
||||||
|
const text = String(body.text ?? '').replace(/\r\n/g, '\n').trim();
|
||||||
|
if (!text) return sendJson(res, 400, { error: 'Nothing to send' });
|
||||||
|
if (text.length > MAX_TEXT_CHARS) return sendJson(res, 400, { error: `Too long — ${MAX_TEXT_CHARS} characters at most` });
|
||||||
|
const ch = current.channelByRef(body.destination);
|
||||||
|
if (!ch) {
|
||||||
|
const s = String(body.destination ?? '').trim();
|
||||||
|
return sendJson(res, 400, { error: s ? `No ProdCom channel "${s}" — pick another in Send as` : 'Which ProdCom channel?', code: 'bad_destination' });
|
||||||
|
}
|
||||||
|
if (!ch.typingEnabled) {
|
||||||
|
return sendJson(res, 400, { error: `${ch.name} doesn't accept typed text — turn on typing for it in ProdCom`, code: 'typing_off' });
|
||||||
|
}
|
||||||
|
const out = await current.sendText(ch, text);
|
||||||
|
if (out.status !== 200) {
|
||||||
|
current.log(`send to ${ch.name} failed: ${out.error}`);
|
||||||
|
return sendJson(res, out.status, { error: out.error, code: out.code });
|
||||||
|
}
|
||||||
|
const preview = text.replace(/\s+/g, ' ').slice(0, 60);
|
||||||
|
current.log(`sent to ${ch.name}: "${preview}${text.length > 60 ? '…' : ''}"`);
|
||||||
|
sendJson(res, 200, { ok: true, label: ch.name, expiresAt: v.expiresAt });
|
||||||
|
}),
|
||||||
'GET /stream': (req, res) => {
|
'GET /stream': (req, res) => {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
|
|||||||
@@ -9,6 +9,28 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── the send target ──
|
||||||
|
A click anywhere in the tile makes ProdCom the platform the Send Message
|
||||||
|
tiles send to. While it is, a 3px accent stripe runs along the bottom edge
|
||||||
|
of the tile body — drawn just inside our root, since the tile chrome
|
||||||
|
around it belongs to the shell. The others hint at it faintly on hover.
|
||||||
|
(The same mark as the Slack transcript's.) */
|
||||||
|
[data-module="prodcom-transcript"] .pt-wrap::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
[data-module="prodcom-transcript"] .pt-wrap:hover::after { opacity: 0.3; }
|
||||||
|
[data-module="prodcom-transcript"] .pt-wrap.is-target::after { opacity: 1; }
|
||||||
|
|
||||||
/* ── channels button ──
|
/* ── channels button ──
|
||||||
Icon-only; the count appears next to it while some channels are hidden. */
|
Icon-only; the count appears next to it while some channels are hidden. */
|
||||||
|
|
||||||
@@ -20,9 +42,13 @@
|
|||||||
}
|
}
|
||||||
[data-module="prodcom-transcript"] .pt-count[hidden] { display: none; }
|
[data-module="prodcom-transcript"] .pt-count[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* narrow tile: the secondary controls fold into the ⋯ menu (client.js) */
|
||||||
|
[data-module="prodcom-transcript"] .tile-btn[hidden],
|
||||||
|
[data-module="prodcom-transcript"] .tile-dd[hidden] { display: none; }
|
||||||
|
|
||||||
/* ── channels menu ──
|
/* ── channels menu ──
|
||||||
The menu container is created and positioned by the shell (.tile-menu);
|
The menu container is created and positioned by the shell (.tile-menu);
|
||||||
it lives inside the tile, so [data-module] scoping still applies. */
|
it hangs off the body under a [data-module] host, so scoping still applies. */
|
||||||
|
|
||||||
[data-module="prodcom-transcript"] .pt-channel-item {
|
[data-module="prodcom-transcript"] .pt-channel-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -145,6 +171,15 @@
|
|||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* typed text (from a dashboard, or ProdCom's own box) and automations carry
|
||||||
|
their own mark in the picture slot; the text reads the same */
|
||||||
|
[data-module="prodcom-transcript"] .pt-entry.typed .avatar,
|
||||||
|
[data-module="prodcom-transcript"] .pt-entry.automation .avatar {
|
||||||
|
background: color-mix(in srgb, var(--ch, var(--muted)) 10%, transparent);
|
||||||
|
outline: 1px dashed color-mix(in srgb, var(--ch, var(--muted)) 55%, transparent);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
[data-module="prodcom-transcript"] .pt-entry.in-progress .text { color: var(--muted); }
|
[data-module="prodcom-transcript"] .pt-entry.in-progress .text { color: var(--muted); }
|
||||||
[data-module="prodcom-transcript"] .pt-entry.in-progress .text::after {
|
[data-module="prodcom-transcript"] .pt-entry.in-progress .text::after {
|
||||||
content: "▍";
|
content: "▍";
|
||||||
|
|||||||
BIN
Binary file not shown.
Binary file not shown.
@@ -3,8 +3,8 @@
|
|||||||
/**
|
/**
|
||||||
* Built-in LTC listener — capture child + in-process decode.
|
* Built-in LTC listener — capture child + in-process decode.
|
||||||
*
|
*
|
||||||
* Spawns the module's native capture tool (ltc-capture, compiled from
|
* Spawns the module's native capture tool (bin/ltc-capture-macos-<arch>,
|
||||||
* ltc-capture.swift beside it) for the admin-configured audio device, reads
|
* shipped prebuilt; source in native/) for the admin-configured audio device, reads
|
||||||
* the interleaved s16le stream, extracts the configured channel, and decodes
|
* the interleaved s16le stream, extracts the configured channel, and decodes
|
||||||
* SMPTE LTC with ltc.js — all inside the module server. Reports upward via
|
* SMPTE LTC with ltc.js — all inside the module server. Reports upward via
|
||||||
* onUpdate roughly 4×/s:
|
* onUpdate roughly 4×/s:
|
||||||
@@ -16,17 +16,13 @@
|
|||||||
* shows (short — "Not configured — see Admin"), `detail` is the full story
|
* shows (short — "Not configured — see Admin"), `detail` is the full story
|
||||||
* for the admin health line and the log.
|
* for the admin health line and the log.
|
||||||
*
|
*
|
||||||
* The capture binary is per-machine (gitignored), and a catalog install or
|
* The capture tool ships prebuilt inside the module, one binary per
|
||||||
* update ships source only — the update even wipes a previously built
|
* platform under bin/ (module guide, "Native helpers"): nothing is ever
|
||||||
* binary. ensureCaptureBinary() therefore rebuilds it with swiftc in the
|
* built on the machine running ProdDash. captureTool() picks the binary for
|
||||||
* background as part of coming up: kicked when the LTC source starts and by
|
* this platform; when there is none, timecode reports itself off and the
|
||||||
* the admin device picker, never on the event loop (a blocking build once
|
* rest of the module carries on. A dead capture child is restarted with
|
||||||
* froze the whole server — see spl-meter's history), built to a temp name
|
* backoff — fast for a crash, slow for config-shaped failures (unknown
|
||||||
* and renamed so a killed build can't leave a half-written "binary", and
|
* device, ambiguity) that never fix themselves.
|
||||||
* rebuilt when a module update leaves the source newer than the binary.
|
|
||||||
* A dead capture child is restarted with backoff — fast for a crash, slow
|
|
||||||
* for config-shaped failures (unknown device, ambiguity) that never fix
|
|
||||||
* themselves.
|
|
||||||
*
|
*
|
||||||
* macOS gotcha this file can't fix: microphone permission. A ProdDash
|
* macOS gotcha this file can't fix: microphone permission. A ProdDash
|
||||||
* server launched over SSH or from a bare launchd job gets SILENT ZEROS
|
* server launched over SSH or from a bare launchd job gets SILENT ZEROS
|
||||||
@@ -51,107 +47,43 @@ const RUNNING_WINDOW_MS = 400;
|
|||||||
/** stderr line the capture prints once the device is open. */
|
/** stderr line the capture prints once the device is open. */
|
||||||
const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/;
|
const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/;
|
||||||
|
|
||||||
/* ── the capture tool: build it when the install didn't bring one ──────── */
|
/* ── the capture tool: prebuilt, one binary per platform ───────────────── */
|
||||||
|
|
||||||
/** Tile-facing words (short); the detail goes to admin health and the log. */
|
/** Tile-facing words (short); the detail goes to admin health and the log. */
|
||||||
const TOOL_BUILDING = 'Capture tool building…';
|
|
||||||
const TOOL_MISSING = 'Capture tool unavailable — see Admin';
|
const TOOL_MISSING = 'Capture tool unavailable — see Admin';
|
||||||
const BUILD_HELP = 'LTC capture tool could not be built here — build it on any Mac of the '
|
|
||||||
+ 'same architecture with `swiftc -O -o ltc-capture ltc-capture.swift` (both files are in '
|
|
||||||
+ 'the module folder) and copy the binary next to ltc-capture.swift';
|
|
||||||
|
|
||||||
let building = false;
|
/** What the module ships in bin/, by platform and architecture. */
|
||||||
let buildFailed = false;
|
const HELPERS = {
|
||||||
|
darwin: { arm64: 'ltc-capture-macos-arm64', x64: 'ltc-capture-macos-x86_64' },
|
||||||
|
win32: { x64: 'ltc-capture-win-x64.exe' },
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make sure the capture tool exists, building it in the background when it
|
* The capture tool for this machine. It is never built here and never
|
||||||
* does not — module scope, so the LTC source can kick it the moment it
|
* looked for anywhere else: the module ships it, or timecode is off on this
|
||||||
* starts and the admin device picker can kick it before any device is
|
* platform and says so. `ready` means a usable binary at `bin`; otherwise
|
||||||
* configured (a listener-only build would deadlock: no tool → no device
|
* `error` is the short tile text and `detail` names the platform.
|
||||||
* list → no device → no listener). A catalog install ships source only and
|
|
||||||
* a catalog update deletes the module folder (old binary included), so this
|
|
||||||
* IS the install step for the tool.
|
|
||||||
*
|
*
|
||||||
* Never blocks (a synchronous build once held the whole server's event loop
|
* @returns {{ready:boolean, bin:string, error:string, detail:string}}
|
||||||
* — every module — for the duration). Builds to a temp name and renames, so
|
|
||||||
* a killed or crashed build can't leave a truncated file that looks like a
|
|
||||||
* ready binary. A binary older than the source (a module update over an
|
|
||||||
* existing folder) keeps serving while a replacement builds behind it.
|
|
||||||
*
|
|
||||||
* @returns {{ready:boolean, error:string, detail:string}} `ready` = a binary
|
|
||||||
* is usable right now; otherwise `error` is the short tile text and
|
|
||||||
* `detail` the full one.
|
|
||||||
*/
|
*/
|
||||||
function ensureCaptureBinary({ moduleDir, log, onReady }) {
|
function captureTool(moduleDir, platform = process.platform, arch = process.arch) {
|
||||||
const bin = path.join(moduleDir, 'ltc-capture');
|
const name = HELPERS[platform]?.[arch] || '';
|
||||||
const src = bin + '.swift';
|
const bin = name ? path.join(moduleDir, 'bin', name) : '';
|
||||||
const ok = { ready: true, error: '', detail: '' };
|
if (bin && fs.existsSync(bin)) return { ready: true, bin, error: '', detail: '' };
|
||||||
let haveBin = false;
|
const here = `${platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform} ${arch}`;
|
||||||
let stale = false;
|
return {
|
||||||
try {
|
|
||||||
haveBin = fs.existsSync(bin);
|
|
||||||
if (haveBin && fs.existsSync(src)) stale = fs.statSync(src).mtimeMs > fs.statSync(bin).mtimeMs;
|
|
||||||
} catch { /* treat as missing */ }
|
|
||||||
if (haveBin && !stale) return ok;
|
|
||||||
if (!fs.existsSync(src)) {
|
|
||||||
return haveBin ? ok : { ready: false, error: TOOL_MISSING, detail: 'ltc-capture.swift is missing from the module folder' };
|
|
||||||
}
|
|
||||||
const notYet = haveBin ? ok : {
|
|
||||||
ready: false,
|
ready: false,
|
||||||
error: building || !buildFailed ? TOOL_BUILDING : TOOL_MISSING,
|
bin: '',
|
||||||
detail: building || !buildFailed ? 'Building the LTC capture tool — ready in a moment' : BUILD_HELP,
|
error: TOOL_MISSING,
|
||||||
|
detail: `The module ships no LTC capture tool for ${here}${name ? ` (bin/${name} is missing)` : ''} — timecode is off on this machine`,
|
||||||
};
|
};
|
||||||
if (building || buildFailed) return notYet;
|
|
||||||
|
|
||||||
building = true;
|
|
||||||
const tmp = bin + '.building';
|
|
||||||
log(stale ? 'ltc-capture is older than its source — rebuilding it in the background'
|
|
||||||
: 'ltc-capture binary missing — building it in the background');
|
|
||||||
const build = spawn('swiftc', ['-O', '-o', tmp, src], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
||||||
let buildErr = '';
|
|
||||||
build.stderr.setEncoding('utf8');
|
|
||||||
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
|
|
||||||
const giveUp = setTimeout(() => {
|
|
||||||
try { build.kill('SIGKILL'); } catch { /* already gone */ }
|
|
||||||
}, 300000);
|
|
||||||
giveUp.unref?.();
|
|
||||||
build.on('error', (err) => {
|
|
||||||
clearTimeout(giveUp);
|
|
||||||
building = false;
|
|
||||||
buildFailed = true;
|
|
||||||
try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
|
|
||||||
log(`swiftc could not be run: ${err.message}`);
|
|
||||||
});
|
|
||||||
build.on('exit', (code) => {
|
|
||||||
clearTimeout(giveUp);
|
|
||||||
building = false;
|
|
||||||
if (code === 0 && fs.existsSync(tmp)) {
|
|
||||||
try {
|
|
||||||
fs.renameSync(tmp, bin);
|
|
||||||
} catch (err) {
|
|
||||||
buildFailed = true;
|
|
||||||
log(`ltc-capture built but could not be moved into place: ${err.message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
buildFailed = false;
|
|
||||||
log('ltc-capture built');
|
|
||||||
if (typeof onReady === 'function') onReady();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
buildFailed = true;
|
|
||||||
try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
|
|
||||||
log(`ltc-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
|
||||||
});
|
|
||||||
return notYet;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{device:string, channel:number, moduleDir:string, log:Function,
|
* @param {{device:string, channel:number, moduleDir:string, log:Function,
|
||||||
* onUpdate:(u:object)=>void}} opts
|
* onUpdate:(u:object)=>void}} opts
|
||||||
* @returns {{stop:()=>void}}
|
* @returns {{stop:()=>void}}
|
||||||
*/
|
*/
|
||||||
function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
||||||
const bin = path.join(moduleDir, 'ltc-capture');
|
|
||||||
let child = null;
|
let child = null;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let restartTimer = null;
|
let restartTimer = null;
|
||||||
@@ -181,22 +113,11 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
|
|
||||||
function start() {
|
function start() {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
const tool = ensureCaptureBinary({
|
const tool = captureTool(moduleDir);
|
||||||
moduleDir,
|
|
||||||
log,
|
|
||||||
onReady() {
|
|
||||||
// The build this start() was waiting on landed — go now.
|
|
||||||
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!tool.ready) {
|
if (!tool.ready) {
|
||||||
|
// No binary for this platform: say so and look again later — a module
|
||||||
|
// update remounts us anyway, this is only belt and braces.
|
||||||
report({ error: tool.error, detail: tool.detail });
|
report({ error: tool.error, detail: tool.detail });
|
||||||
// onReady is only the FAST path (a successful build restarts us at
|
|
||||||
// once). The slow retry must stay armed regardless: a failed build
|
|
||||||
// never calls back, and a binary can also arrive from outside — scp'd
|
|
||||||
// in on a machine whose toolchain can't build (AB's broken CLT).
|
|
||||||
// Waiting on onReady alone deadlocked exactly that way in production:
|
|
||||||
// "Capture tool building…" forever, with a working binary on disk.
|
|
||||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -206,7 +127,7 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
let decoder = null;
|
let decoder = null;
|
||||||
let stderrTail = '';
|
let stderrTail = '';
|
||||||
|
|
||||||
child = spawn(bin, ['--device', device], { stdio: ['ignore', 'pipe', 'pipe'] });
|
child = spawn(tool.bin, ['--device', device], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
|
||||||
child.stderr.setEncoding('utf8');
|
child.stderr.setEncoding('utf8');
|
||||||
child.stderr.on('data', (text) => {
|
child.stderr.on('data', (text) => {
|
||||||
@@ -241,7 +162,14 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
child.on('error', (err) => {
|
child.on('error', (err) => {
|
||||||
|
// A spawn that fails (EACCES on a helper that lost its execute bit,
|
||||||
|
// ENOENT) emits 'error' with no 'exit' to follow. Leaving `child` set
|
||||||
|
// would let the eval loop report "No signal" over a dead child forever
|
||||||
|
// and nothing would ever retry — what parked LTC on AB on 2026-10-04.
|
||||||
|
// Null it and retry on the slow timer, the way spl-listener does.
|
||||||
report({ error: TOOL_MISSING, detail: `LTC capture failed to start: ${err.message}` });
|
report({ error: TOOL_MISSING, detail: `LTC capture failed to start: ${err.message}` });
|
||||||
|
child = null;
|
||||||
|
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on('exit', (code) => {
|
child.on('exit', (code) => {
|
||||||
@@ -298,4 +226,4 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createLtcListener, ensureCaptureBinary };
|
module.exports = { createLtcListener, captureTool };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "propresenter-timers",
|
"id": "propresenter-timers",
|
||||||
"name": "Timers",
|
"name": "Timers",
|
||||||
"version": "1.6.0",
|
"version": "1.7.1",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Timers, Clocks, and LTC From configured Modules",
|
"description": "Timers, Clocks, and LTC From configured Modules",
|
||||||
"provides": [
|
"provides": [
|
||||||
|
|||||||
Executable
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
# Build this module's native helper for every macOS architecture it ships —
|
||||||
|
# ../bin/ltc-capture-macos-arm64 and ../bin/ltc-capture-macos-x86_64 —
|
||||||
|
# from ltc-capture.swift beside this script. Run it on any Mac with the
|
||||||
|
# Xcode command line tools; both architectures cross-compile from either.
|
||||||
|
# The binaries are committed: a machine running ProdDash never builds
|
||||||
|
# anything (docs/MODULE-GUIDE.md, "Native helpers").
|
||||||
|
#
|
||||||
|
# Signed with a Developer ID Application certificate when one is installed
|
||||||
|
# (override with CODESIGN_IDENTITY) — hardened runtime plus the audio-input
|
||||||
|
# entitlement, which a helper that reads a microphone needs under the
|
||||||
|
# hardened runtime — and ad-hoc otherwise, which is fine on your own Macs.
|
||||||
|
#
|
||||||
|
# Windows is not built here: there is no Windows implementation of this
|
||||||
|
# tool yet, so on Windows the module reports timecode off.
|
||||||
|
set -e -u
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
NAME="ltc-capture"
|
||||||
|
DEPLOYMENT_TARGET="13.0"
|
||||||
|
|
||||||
|
command -v swiftc >/dev/null || {
|
||||||
|
echo "build.sh: swiftc not found — install the Xcode command line tools: xcode-select --install" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
IDENTITY="${CODESIGN_IDENTITY:-}"
|
||||||
|
if [[ -z "$IDENTITY" ]]; then
|
||||||
|
IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \
|
||||||
|
| awk -F'"' '/Developer ID Application/ {print $2; exit}')
|
||||||
|
fi
|
||||||
|
if [[ -n "$IDENTITY" ]]; then
|
||||||
|
echo "signing as $IDENTITY"
|
||||||
|
SIGN=(--force --sign "$IDENTITY" --options runtime --timestamp)
|
||||||
|
else
|
||||||
|
echo "signing ad-hoc (no Developer ID installed — fine on your own Macs)"
|
||||||
|
SIGN=(--force --sign - --options runtime)
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p ../bin
|
||||||
|
for ARCH in arm64 x86_64; do
|
||||||
|
OUT="../bin/$NAME-macos-$ARCH"
|
||||||
|
echo " compiling $OUT"
|
||||||
|
swiftc -O -whole-module-optimization -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$OUT" "$NAME.swift"
|
||||||
|
codesign "${SIGN[@]}" --entitlements helper.entitlements "$OUT"
|
||||||
|
codesign --verify --strict "$OUT"
|
||||||
|
echo " built $OUT ($(du -h "$OUT" | awk '{print $1}'))"
|
||||||
|
done
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- The helper reads an audio input; under the hardened runtime that
|
||||||
|
needs saying. The permission itself comes from the launcher. -->
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
+5
-5
@@ -4,12 +4,12 @@
|
|||||||
// The module decodes LTC in-process (ltc.js) and spawns this to move
|
// The module decodes LTC in-process (ltc.js) and spawns this to move
|
||||||
// samples from an input device to a pipe. Production machines rarely have
|
// samples from an input device to a pipe. Production machines rarely have
|
||||||
// ffmpeg/sox, and shipping a third-party binary for that one job is
|
// ffmpeg/sox, and shipping a third-party binary for that one job is
|
||||||
// overkill — this is the whole job, natively. The module compiles it on
|
// overkill — this is the whole job, natively. It ships prebuilt in the
|
||||||
// first use when swiftc is available; otherwise build it once on any
|
// module's bin/ (one binary per platform, committed — a booth never
|
||||||
// same-architecture Mac and copy the binary next to this source:
|
// builds anything). To rebuild after a change here, on any Mac with the
|
||||||
|
// Xcode command line tools:
|
||||||
//
|
//
|
||||||
// swiftc -O -o modules/propresenter-timers/ltc-capture \
|
// modules/propresenter-timers/native/build.sh (both architectures, ~10 s)
|
||||||
// modules/propresenter-timers/ltc-capture.swift (once, ~5 s)
|
|
||||||
//
|
//
|
||||||
// ltc-capture --list
|
// ltc-capture --list
|
||||||
// every input-capable device: name, input channels, sample rate
|
// every input-capable device: name, input channels, sample rate
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
const http = require('http');
|
const http = require('http');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const { createLtcListener, ensureCaptureBinary } = require('./ltc-listener');
|
const { createLtcListener, captureTool } = require('./ltc-listener');
|
||||||
const { createTimerPhase } = require('./phase');
|
const { createTimerPhase } = require('./phase');
|
||||||
const manifest = require('./module.json');
|
const manifest = require('./module.json');
|
||||||
|
|
||||||
@@ -660,11 +660,8 @@ function createLtcSource({ config, log, moduleDir }) {
|
|||||||
kind: 'ltc',
|
kind: 'ltc',
|
||||||
start(e) {
|
start(e) {
|
||||||
emit = e;
|
emit = e;
|
||||||
// Kick the capture-tool build right away: a catalog install ships
|
// The capture tool ships prebuilt in the module; the listener picks
|
||||||
// source only and a catalog update wipes any previously built binary,
|
// it for this platform when it starts and says so when there is none.
|
||||||
// so coming up after an install/update must rebuild it without
|
|
||||||
// waiting for a device to be picked or a listener to need it.
|
|
||||||
ensureCaptureBinary({ moduleDir, log });
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
word = {
|
word = {
|
||||||
error: 'Not configured — see Admin',
|
error: 'Not configured — see Admin',
|
||||||
@@ -1359,23 +1356,21 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// The admin device picker asks this machine what it can hear. The
|
// The admin device picker asks this machine what it can hear. The
|
||||||
// options shape matches the admin select's optionsRoute contract.
|
// options shape matches the admin select's optionsRoute contract. The
|
||||||
// Opening the picker also kicks the capture-tool build when a catalog
|
// capture tool ships prebuilt in the module (bin/); with none for this
|
||||||
// install/update left only the source — without this, no tool meant
|
// platform the list is empty and the error says so.
|
||||||
// no device list, and no device meant nothing would ever build it.
|
|
||||||
'GET /ltc/devices': (req, res) => {
|
'GET /ltc/devices': (req, res) => {
|
||||||
const tool = ensureCaptureBinary({ moduleDir: __dirname, log });
|
const tool = captureTool(__dirname);
|
||||||
if (!tool.ready) {
|
if (!tool.ready) {
|
||||||
sendJson(res, 200, { options: [], error: tool.detail || tool.error });
|
sendJson(res, 200, { options: [], error: tool.detail });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const bin = path.join(__dirname, 'ltc-capture');
|
const child = spawn(tool.bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
|
||||||
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
|
|
||||||
let err = '';
|
let err = '';
|
||||||
child.stderr.setEncoding('utf8');
|
child.stderr.setEncoding('utf8');
|
||||||
child.stderr.on('data', (t) => { err += t; });
|
child.stderr.on('data', (t) => { err += t; });
|
||||||
child.on('error', () => {
|
child.on('error', (e) => {
|
||||||
sendJson(res, 200, { options: [], error: 'LTC capture tool missing — see modules/propresenter-timers/ltc-capture.swift' });
|
sendJson(res, 200, { options: [], error: `LTC capture tool could not be run: ${e.message}` });
|
||||||
});
|
});
|
||||||
child.on('exit', () => {
|
child.on('exit', () => {
|
||||||
if (res.writableEnded) return;
|
if (res.writableEnded) return;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"id": "send-message",
|
||||||
|
"name": "Send Message",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"proddash": ">=1.4.0",
|
||||||
|
"description": "PIN-protected sending to Slack, ProdCom and any other messaging module, from one tile",
|
||||||
|
"client": "client.js",
|
||||||
|
"server": "server.js",
|
||||||
|
"style": "style.css",
|
||||||
|
"minSize": {
|
||||||
|
"w": 2,
|
||||||
|
"h": 2
|
||||||
|
},
|
||||||
|
"defaultSize": {
|
||||||
|
"w": 3,
|
||||||
|
"h": 3
|
||||||
|
},
|
||||||
|
"configGroups": {
|
||||||
|
"PIN": {
|
||||||
|
"toggle": "pinEnabled",
|
||||||
|
"help": "Nothing leaves this machine until the PIN is entered on a Send message or Quick replies tile. One PIN unlocks every messaging tile on that dashboard page — Send message, Quick replies and Slack's reaction menu — until Auto-lock minutes pass without a send; a reload asks again. With the PIN switched off, the tiles send without asking. Sending stays refused while the PIN is on but not yet set."
|
||||||
|
},
|
||||||
|
"Presets": {
|
||||||
|
"help": "Quick replies are one per line, Label | Message. Add | platform:destination to bind one — slack:#production or prodcom:FOH — and that reply always goes there, whichever transcript is selected. Quick emojis are sent as a message of their own with one press from the Send message tile."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configSchema": {
|
||||||
|
"pinEnabled": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "PIN",
|
||||||
|
"default": true,
|
||||||
|
"group": "PIN"
|
||||||
|
},
|
||||||
|
"pin": {
|
||||||
|
"type": "password",
|
||||||
|
"label": "PIN",
|
||||||
|
"default": "",
|
||||||
|
"group": "PIN",
|
||||||
|
"help": "4–8 digits; the pad unlocks by itself once they are all in."
|
||||||
|
},
|
||||||
|
"pinIdleMinutes": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Auto-lock (min)",
|
||||||
|
"default": 10,
|
||||||
|
"group": "PIN"
|
||||||
|
},
|
||||||
|
"quickReplies": {
|
||||||
|
"type": "text",
|
||||||
|
"label": "Quick replies",
|
||||||
|
"default": "",
|
||||||
|
"group": "Presets",
|
||||||
|
"help": "One per line: Label | Message. Add | slack:#channel or | prodcom:Channel to bind one to a destination."
|
||||||
|
},
|
||||||
|
"quickEmojis": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "Quick emojis",
|
||||||
|
"default": "👍 🙏 ✅ 👀 🎉",
|
||||||
|
"group": "Presets",
|
||||||
|
"help": "Emoji separated by spaces, eight at most. Sent as plain text to whatever is selected."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"instanceSchema": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,806 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send Message — server part.
|
||||||
|
*
|
||||||
|
* One place a dashboard sends from, whatever the platform. The module owns
|
||||||
|
* nothing upstream: it finds every module whose manifest says
|
||||||
|
* "provides": ["messaging"] (Slack, ProdCom, whatever comes next) through
|
||||||
|
* the shell's /api/modules over loopback, reads each one's destinations
|
||||||
|
* from its GET /messaging, and hands a message to the provider's
|
||||||
|
* POST /messaging/send — so Slack keeps its one Slack connection, ProdCom
|
||||||
|
* its one ProdCom connection, and this module never opens a second one.
|
||||||
|
*
|
||||||
|
* It also owns the PIN. A browser unlocks here and gets a token; every
|
||||||
|
* gated action anywhere — a send through this module, a reaction in a
|
||||||
|
* Slack transcript — carries that token, and the provider checks it with
|
||||||
|
* GET /verify over loopback before touching its upstream. No provider has
|
||||||
|
* a PIN of its own, and no browser on the network can reach a provider's
|
||||||
|
* send route without having entered the PIN here.
|
||||||
|
*
|
||||||
|
* GET /state providers (destinations, identity, status), pin flags, quick emojis
|
||||||
|
* GET /stream SSE `state` events (change-detected, heartbeated)
|
||||||
|
* POST /unlock { pin } → { token, expiresAt } (constant-time, lockout)
|
||||||
|
* with the PIN switched off: {} → a token, no questions
|
||||||
|
* POST /lock Bearer → forget that token
|
||||||
|
* GET /verify Bearer → { ok, expiresAt }, 401 otherwise — for providers
|
||||||
|
* GET /quick Bearer → preset labels, indexes and bindings (never the text)
|
||||||
|
* POST /send Bearer, { provider, destination, text }
|
||||||
|
* POST /quick Bearer, { index, provider, destination }
|
||||||
|
*
|
||||||
|
* The provider contract is in docs/MODULE-GUIDE.md ("Offering messaging").
|
||||||
|
*/
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const SELF_ID = 'send-message';
|
||||||
|
const CAPABILITY = 'messaging';
|
||||||
|
|
||||||
|
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
||||||
|
const HEARTBEAT_MS = 15000;
|
||||||
|
/** Re-read /api/modules: a newly installed provider shows up without a restart. */
|
||||||
|
const DISCOVER_MS = 30000;
|
||||||
|
const DISCOVER_RETRY_MS = 5000; // …sooner while the shell isn't answering yet (boot)
|
||||||
|
const DISCOVER_FIRST_MS = 1500; // init() runs before the shell listens
|
||||||
|
/** Each provider's /messaging, re-read this often while a tile is watching… */
|
||||||
|
const DESCRIBE_MS = 4000;
|
||||||
|
/** …and this slowly with nobody looking. */
|
||||||
|
const DESCRIBE_IDLE_MS = 30000;
|
||||||
|
/** A tile counts as watching for this long after its last /state or /stream. */
|
||||||
|
const IDLE_AFTER_MS = 30000;
|
||||||
|
const LOOPBACK_TIMEOUT_MS = 5000;
|
||||||
|
/** A provider's send may itself wait on its upstream. */
|
||||||
|
const SEND_TIMEOUT_MS = 15000;
|
||||||
|
const PIN_RE = /^\d{4,8}$/;
|
||||||
|
const PIN_MAX_WRONG = 5;
|
||||||
|
const PIN_LOCKOUT_MS = 30000;
|
||||||
|
/** With the PIN off, a token is minted on request and simply lasts. */
|
||||||
|
const NO_PIN_TOKEN_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
const SEND_MIN_INTERVAL_MS = 1000;
|
||||||
|
const DEFAULT_MAX_CHARS = 4000;
|
||||||
|
const BODY_LIMIT_BYTES = 64 * 1024;
|
||||||
|
const UNLOCK_SWEEP_MS = 30000;
|
||||||
|
|
||||||
|
/** Set by init(), read by routes() — rebuilt together on remount. */
|
||||||
|
let current = null;
|
||||||
|
|
||||||
|
/* ── small helpers ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function sendJson(res, code, body) {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let data = '';
|
||||||
|
let size = 0;
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > BODY_LIMIT_BYTES) {
|
||||||
|
reject(new Error('Request body too large.'));
|
||||||
|
req.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
if (!data.trim()) return resolve({});
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(data);
|
||||||
|
resolve(body && typeof body === 'object' ? body : {});
|
||||||
|
} catch {
|
||||||
|
reject(new Error('Malformed JSON.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Route handlers are async — the shell only catches synchronous throws. */
|
||||||
|
function guard(fn) {
|
||||||
|
return (req, res) => {
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => fn(req, res))
|
||||||
|
.catch((err) => {
|
||||||
|
if (!res.headersSent) sendJson(res, 500, { error: err?.message || String(err) });
|
||||||
|
else try { res.end(); } catch { /* gone */ }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bearer(req) {
|
||||||
|
const m = /^Bearer\s+([A-Za-z0-9]+)\s*$/.exec(String(req.headers.authorization || ''));
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientIp(req) {
|
||||||
|
return String(req.socket?.remoteAddress || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Constant-time PIN compare (lengths are folded in after the timing-safe step). */
|
||||||
|
function pinMatches(given, expected) {
|
||||||
|
const a = Buffer.alloc(16);
|
||||||
|
const b = Buffer.alloc(16);
|
||||||
|
a.write(String(given).slice(0, 16));
|
||||||
|
b.write(String(expected).slice(0, 16));
|
||||||
|
const same = crypto.timingSafeEqual(a, b);
|
||||||
|
return same && given.length === expected.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(err) {
|
||||||
|
return String(err?.cause?.code || err?.code || err?.message || err || 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Label | Message | platform:destination" per line. A line with no "|" is
|
||||||
|
* both its label and its message. The binding (optional) names a provider
|
||||||
|
* — its module id, a prefix of it ("prodcom"), or its label ("Slack") — and
|
||||||
|
* one of its destinations by id or label ("#production", "FOH"); a reply
|
||||||
|
* that carries one always goes there, whatever is selected.
|
||||||
|
*/
|
||||||
|
function parseQuickReplies(text) {
|
||||||
|
const out = [];
|
||||||
|
for (const rawLine of String(text || '').split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line) continue;
|
||||||
|
const parts = line.split('|').map((p) => p.trim());
|
||||||
|
const label = parts[0];
|
||||||
|
const message = parts.length > 1 && parts[1] ? parts[1] : label;
|
||||||
|
const binding = parts.length > 2 ? parts[2] : '';
|
||||||
|
if (!label || !message) continue;
|
||||||
|
out.push({ label, message, binding });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "👍 🙏 ✅" → ['👍', '🙏', '✅'] — plain text tokens, eight at most. */
|
||||||
|
function parseQuickEmojis(text) {
|
||||||
|
return String(text || '').split(/\s+/).filter(Boolean).slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A provider's GET /messaging answer, as the tiles and the send route use it. */
|
||||||
|
function normalizeDescriptor(id, fallbackLabel, body) {
|
||||||
|
const b = body && typeof body === 'object' ? body : {};
|
||||||
|
const st = b.status && typeof b.status === 'object' ? b.status : {};
|
||||||
|
const state = st.state === 'ok' || st.state === 'error' ? st.state : 'connecting';
|
||||||
|
const destinations = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const d of Array.isArray(b.destinations) ? b.destinations : []) {
|
||||||
|
if (!d || typeof d !== 'object') continue;
|
||||||
|
const did = String(d.id ?? '').trim();
|
||||||
|
if (!did || seen.has(did)) continue;
|
||||||
|
seen.add(did);
|
||||||
|
destinations.push({ id: did, label: String(d.label || did), color: typeof d.color === 'string' ? d.color : '' });
|
||||||
|
}
|
||||||
|
const maxChars = Math.trunc(Number(b.maxChars));
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: String(b.label || fallbackLabel || id),
|
||||||
|
identity: String(b.identity || ''),
|
||||||
|
// recipient: a message goes *to* a destination (a Slack channel);
|
||||||
|
// identity: it goes out *as* one (a ProdCom channel) — the Send as menu
|
||||||
|
// lists only the latter, and the wording follows
|
||||||
|
destinationRole: b.destinationRole === 'identity' ? 'identity' : 'recipient',
|
||||||
|
status: { state, message: String(st.message || '') },
|
||||||
|
destinations,
|
||||||
|
// what to say when the list is empty, if the provider knows why
|
||||||
|
emptyMessage: String(b.emptyMessage || ''),
|
||||||
|
maxChars: maxChars > 0 ? Math.min(DEFAULT_MAX_CHARS * 10, maxChars) : DEFAULT_MAX_CHARS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Slack → #ops" / "ProdCom as FOH" — how a provider + destination pair reads. */
|
||||||
|
function pairLabel(descriptor, destination) {
|
||||||
|
return descriptor.destinationRole === 'identity'
|
||||||
|
? `${descriptor.label} as ${destination.label}`
|
||||||
|
: `${descriptor.label} → ${destination.label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the module ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init({ config, log, shell }) {
|
||||||
|
/* ── config ── */
|
||||||
|
const shellPort = Number(shell && shell.port) || 0;
|
||||||
|
const pinEnabled = config.pinEnabled !== false;
|
||||||
|
const pin = String(config.pin || '').trim();
|
||||||
|
const pinOk = PIN_RE.test(pin);
|
||||||
|
const idleMs = Math.min(240, Math.max(1, Number(config.pinIdleMinutes) || 10)) * 60 * 1000;
|
||||||
|
const quickReplies = parseQuickReplies(config.quickReplies);
|
||||||
|
const quickEmojis = parseQuickEmojis(config.quickEmojis);
|
||||||
|
|
||||||
|
/* ── state (all inside this closure so a re-init starts clean) ── */
|
||||||
|
let stopped = false;
|
||||||
|
const providers = new Map(); // module id → { id, label, descriptor | null, error, at }
|
||||||
|
let listError = ''; // the last /api/modules failure ('' while fine)
|
||||||
|
let listedAt = 0;
|
||||||
|
let discoverTimer = null;
|
||||||
|
let describeTimer = null;
|
||||||
|
let describing = null; // one descriptor sweep in flight
|
||||||
|
const streams = new Set();
|
||||||
|
const unlocks = new Map(); // token → { expiresAt, lastSendAt, ip }
|
||||||
|
const pinAttempts = new Map(); // ip → { count, lockedUntil }
|
||||||
|
let lastInterest = Date.now();
|
||||||
|
let lastSig = '';
|
||||||
|
|
||||||
|
const loopback = (path) => `http://127.0.0.1:${shellPort}${path}`;
|
||||||
|
|
||||||
|
function watched() {
|
||||||
|
return streams.size > 0 || Date.now() - lastInterest < IDLE_AFTER_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── discovery: who offers messaging ── */
|
||||||
|
async function discover() {
|
||||||
|
discoverTimer = null;
|
||||||
|
if (stopped) return;
|
||||||
|
try {
|
||||||
|
if (!shellPort) throw new Error('shell port unknown (needs ProdDash 1.4.0)');
|
||||||
|
const res = await fetch(loopback('/api/modules'), {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(LOOPBACK_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const body = await res.json();
|
||||||
|
if (stopped) return;
|
||||||
|
const mods = (Array.isArray(body?.modules) ? body.modules : [])
|
||||||
|
.filter((m) => m && typeof m.id === 'string' && m.id !== SELF_ID && Array.isArray(m.provides) && m.provides.includes(CAPABILITY));
|
||||||
|
const keep = new Set();
|
||||||
|
for (const m of mods) {
|
||||||
|
keep.add(m.id);
|
||||||
|
const have = providers.get(m.id);
|
||||||
|
if (have) have.label = String(m.name || m.id);
|
||||||
|
else {
|
||||||
|
providers.set(m.id, { id: m.id, label: String(m.name || m.id), descriptor: null, error: '', at: 0 });
|
||||||
|
log(`module "${m.id}" offers messaging`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of [...providers.keys()]) {
|
||||||
|
if (keep.has(id)) continue;
|
||||||
|
providers.delete(id);
|
||||||
|
log(`module "${id}" no longer offers messaging`);
|
||||||
|
}
|
||||||
|
listError = '';
|
||||||
|
listedAt = Date.now();
|
||||||
|
await describeAll();
|
||||||
|
} catch (err) {
|
||||||
|
listError = errorText(err);
|
||||||
|
broadcastIfChanged();
|
||||||
|
}
|
||||||
|
if (!stopped) {
|
||||||
|
discoverTimer = setTimeout(discover, listError ? DISCOVER_RETRY_MS : DISCOVER_MS);
|
||||||
|
discoverTimer.unref?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read one provider's /messaging; a failure is kept on the entry, not thrown. */
|
||||||
|
async function describe(p) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(loopback(`/api/modules/${encodeURIComponent(p.id)}/messaging`), {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(LOOPBACK_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (res.status === 404) {
|
||||||
|
// Disabled or uninstalled, most likely: let the module list decide
|
||||||
|
// now rather than at the next 30 s pass, so the tiles drop it at once.
|
||||||
|
if (!discoverTimer && !stopped) {
|
||||||
|
discoverTimer = setTimeout(discover, 250);
|
||||||
|
discoverTimer.unref?.();
|
||||||
|
}
|
||||||
|
throw new Error('not answering (disabled or restarting)');
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const body = await res.json();
|
||||||
|
if (stopped) return;
|
||||||
|
p.descriptor = normalizeDescriptor(p.id, p.label, body);
|
||||||
|
if (p.error) log(`${p.label}: describing itself again`);
|
||||||
|
p.error = '';
|
||||||
|
p.at = Date.now();
|
||||||
|
} catch (err) {
|
||||||
|
const text = errorText(err);
|
||||||
|
if (p.error !== text) log(`${p.label}: cannot read its destinations — ${text}`);
|
||||||
|
p.error = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeAll() {
|
||||||
|
if (!describing) {
|
||||||
|
describing = Promise.all([...providers.values()].map(describe))
|
||||||
|
.then(() => broadcastIfChanged())
|
||||||
|
.finally(() => { describing = null; });
|
||||||
|
}
|
||||||
|
return describing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleDescribe(delay = null) {
|
||||||
|
if (stopped) return;
|
||||||
|
clearTimeout(describeTimer);
|
||||||
|
describeTimer = setTimeout(async () => {
|
||||||
|
describeTimer = null;
|
||||||
|
await describeAll();
|
||||||
|
scheduleDescribe();
|
||||||
|
}, delay ?? (watched() ? DESCRIBE_MS : DESCRIBE_IDLE_MS));
|
||||||
|
describeTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Somebody is looking: catch up at once if the list is stale or empty. */
|
||||||
|
function wake() {
|
||||||
|
lastInterest = Date.now();
|
||||||
|
if (stopped) return;
|
||||||
|
if ((!providers.size || listError) && Date.now() - listedAt > DISCOVER_RETRY_MS && !discoverTimer) {
|
||||||
|
discoverTimer = setTimeout(discover, 50);
|
||||||
|
discoverTimer.unref?.();
|
||||||
|
} else if (describeTimer) {
|
||||||
|
// The idle cadence was in force; move to the watched one now.
|
||||||
|
scheduleDescribe(DESCRIBE_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── what the tiles see ── */
|
||||||
|
function providerView(p) {
|
||||||
|
const d = p.descriptor;
|
||||||
|
if (!d) {
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
label: p.label,
|
||||||
|
identity: '',
|
||||||
|
destinationRole: 'recipient',
|
||||||
|
status: { state: p.error ? 'error' : 'connecting', message: p.error ? `${p.label} module ${p.error}` : 'Reading destinations…' },
|
||||||
|
destinations: [],
|
||||||
|
emptyMessage: '',
|
||||||
|
maxChars: DEFAULT_MAX_CHARS,
|
||||||
|
error: p.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...d, error: p.error, status: p.error ? { state: 'error', message: `${p.label} module ${p.error}` } : d.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
function health() {
|
||||||
|
if (!shellPort) return { status: 'error', message: 'This module needs ProdDash 1.4.0 or newer' };
|
||||||
|
const pinLine = !pinEnabled ? 'PIN off' : pinOk ? 'PIN set' : 'PIN on but not set — sending refused';
|
||||||
|
if (listError) return { status: listedAt ? 'connecting' : 'error', message: `Cannot read the module list (${listError}) · ${pinLine}` };
|
||||||
|
if (!listedAt) return { status: 'connecting', message: 'Looking for messaging modules…' };
|
||||||
|
const list = [...providers.values()];
|
||||||
|
if (!list.length) return { status: 'error', message: `No module offers messaging — install Slack or ProdCom · ${pinLine}` };
|
||||||
|
const parts = list.map((p) => {
|
||||||
|
const v = providerView(p);
|
||||||
|
if (v.error) return `${v.label}: ${v.error}`;
|
||||||
|
const n = v.destinations.length;
|
||||||
|
const who = v.identity ? ` as ${v.identity}` : '';
|
||||||
|
// a provider that is up but unhappy says why, so the admin line does too
|
||||||
|
const why = v.status.state !== 'ok' && v.status.message ? ` — ${v.status.message}`
|
||||||
|
: !n && v.emptyMessage ? ` — ${v.emptyMessage}` : '';
|
||||||
|
const noun = v.destinationRole === 'identity' ? 'identit' + (n === 1 ? 'y' : 'ies') : 'destination' + (n === 1 ? '' : 's');
|
||||||
|
return `${v.label}${who} (${n} ${noun})${why}`;
|
||||||
|
});
|
||||||
|
const bad = list.some((p) => p.error || p.descriptor?.status.state === 'error');
|
||||||
|
const pending = list.some((p) => !p.descriptor);
|
||||||
|
return {
|
||||||
|
status: pinEnabled && !pinOk ? 'error' : bad ? 'error' : pending ? 'connecting' : 'ok',
|
||||||
|
message: `${parts.join(' · ')} · ${pinLine}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot() {
|
||||||
|
const h = health();
|
||||||
|
return {
|
||||||
|
providers: [...providers.values()].map(providerView),
|
||||||
|
pin: {
|
||||||
|
enabled: pinEnabled,
|
||||||
|
set: pinOk,
|
||||||
|
// the pad submits by itself once this many digits are in (never the PIN)
|
||||||
|
length: pinEnabled && pinOk ? pin.length : 0,
|
||||||
|
idleMinutes: idleMs / 60000,
|
||||||
|
},
|
||||||
|
quickEmojis,
|
||||||
|
quickCount: quickReplies.length,
|
||||||
|
status: { state: h.status, message: h.message },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastIfChanged() {
|
||||||
|
const snap = snapshot();
|
||||||
|
const sig = JSON.stringify(snap);
|
||||||
|
if (sig === lastSig) return;
|
||||||
|
lastSig = sig;
|
||||||
|
if (!streams.size) return;
|
||||||
|
const payload = `event: state\ndata: ${JSON.stringify(snap)}\n\n`;
|
||||||
|
for (const res of streams) {
|
||||||
|
try {
|
||||||
|
res.write(payload);
|
||||||
|
} catch {
|
||||||
|
streams.delete(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the PIN and its unlock tokens ── */
|
||||||
|
function sweepUnlocks() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [t, u] of unlocks) if (u.expiresAt <= now) unlocks.delete(t);
|
||||||
|
for (const [ip, a] of pinAttempts) if (a.lockedUntil && a.lockedUntil <= now && !a.count) pinAttempts.delete(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The unlock behind a token, or null; a hit slides its idle expiry along. */
|
||||||
|
function verify(token) {
|
||||||
|
const u = token ? unlocks.get(token) : null;
|
||||||
|
if (!u) return null;
|
||||||
|
const now = Date.now();
|
||||||
|
if (u.expiresAt <= now) {
|
||||||
|
unlocks.delete(token);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (pinEnabled) u.expiresAt = now + idleMs;
|
||||||
|
return { token, ...u };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything a send needs before anyone is asked; answers the refusal itself. */
|
||||||
|
function sendGate(req, res) {
|
||||||
|
if (pinEnabled && !pinOk) {
|
||||||
|
sendJson(res, 403, { error: 'Set a PIN in Admin → Send Message, or switch the PIN off', code: 'no_pin' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const u = verify(bearer(req));
|
||||||
|
if (!u) {
|
||||||
|
sendJson(res, 401, { error: 'Locked — enter the PIN', code: 'locked' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlock(req, res, given) {
|
||||||
|
const ip = clientIp(req);
|
||||||
|
const now = Date.now();
|
||||||
|
if (!pinEnabled) {
|
||||||
|
const t = crypto.randomBytes(32).toString('hex');
|
||||||
|
const expiresAt = now + NO_PIN_TOKEN_MS;
|
||||||
|
unlocks.set(t, { expiresAt, lastSendAt: 0, ip });
|
||||||
|
return sendJson(res, 200, { token: t, expiresAt, idleMs: NO_PIN_TOKEN_MS, pinEnabled: false });
|
||||||
|
}
|
||||||
|
if (!pinOk) return sendJson(res, 403, { error: 'Set a PIN in Admin → Send Message, or switch the PIN off', code: 'no_pin' });
|
||||||
|
let a = pinAttempts.get(ip);
|
||||||
|
if (a && a.lockedUntil > now) {
|
||||||
|
const secs = Math.ceil((a.lockedUntil - now) / 1000);
|
||||||
|
return sendJson(res, 429, { error: `Too many wrong PINs — locked for ${secs} s`, code: 'lockout', retryAfter: secs });
|
||||||
|
}
|
||||||
|
if (a && a.lockedUntil && a.lockedUntil <= now) a = null; // lockout served — clean slate
|
||||||
|
const str = String(given ?? '');
|
||||||
|
if (!PIN_RE.test(str) || !pinMatches(str, pin)) {
|
||||||
|
const count = (a ? a.count : 0) + 1;
|
||||||
|
if (count >= PIN_MAX_WRONG) {
|
||||||
|
pinAttempts.set(ip, { count: 0, lockedUntil: now + PIN_LOCKOUT_MS });
|
||||||
|
log(`wrong PIN ×${PIN_MAX_WRONG} from ${ip} — locked for ${PIN_LOCKOUT_MS / 1000} s`);
|
||||||
|
return sendJson(res, 429, { error: `Wrong PIN — locked for ${PIN_LOCKOUT_MS / 1000} s`, code: 'lockout', retryAfter: PIN_LOCKOUT_MS / 1000 });
|
||||||
|
}
|
||||||
|
pinAttempts.set(ip, { count, lockedUntil: 0 });
|
||||||
|
return sendJson(res, 401, { error: 'Wrong PIN', code: 'wrong_pin', triesLeft: PIN_MAX_WRONG - count });
|
||||||
|
}
|
||||||
|
pinAttempts.delete(ip);
|
||||||
|
const t = crypto.randomBytes(32).toString('hex');
|
||||||
|
const expiresAt = now + idleMs;
|
||||||
|
unlocks.set(t, { expiresAt, lastSendAt: 0, ip });
|
||||||
|
log(`unlocked from ${ip} (auto-lock after ${idleMs / 60000} min idle)`);
|
||||||
|
return sendJson(res, 200, { token: t, expiresAt, idleMs, pinEnabled: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── destinations and bindings ── */
|
||||||
|
function findProvider(ref) {
|
||||||
|
const s = String(ref ?? '').trim().toLowerCase();
|
||||||
|
if (!s) return null;
|
||||||
|
for (const p of providers.values()) if (p.id.toLowerCase() === s) return p;
|
||||||
|
for (const p of providers.values()) {
|
||||||
|
const label = (p.descriptor?.label || p.label).toLowerCase();
|
||||||
|
if (label === s || p.id.toLowerCase().startsWith(s) || label.replace(/\s+/g, '') === s.replace(/\s+/g, '')) return p;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDestination(p, ref) {
|
||||||
|
const s = String(ref ?? '').trim();
|
||||||
|
if (!p?.descriptor || !s) return null;
|
||||||
|
const list = p.descriptor.destinations;
|
||||||
|
const exact = list.find((d) => d.id === s);
|
||||||
|
if (exact) return exact;
|
||||||
|
const name = s.replace(/^#/, '').toLowerCase();
|
||||||
|
return list.find((d) => d.label.replace(/^#/, '').toLowerCase() === name) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A quick reply's "platform:destination" → { provider, destination } or { error }. */
|
||||||
|
function resolveBinding(binding) {
|
||||||
|
const s = String(binding || '').trim();
|
||||||
|
if (!s) return null;
|
||||||
|
const i = s.indexOf(':');
|
||||||
|
if (i > 0) {
|
||||||
|
const p = findProvider(s.slice(0, i));
|
||||||
|
if (!p) return { error: `No messaging module called "${s.slice(0, i).trim()}" for this quick reply` };
|
||||||
|
if (!p.descriptor) return { error: `${p.label} hasn't listed its destinations yet — try again in a moment` };
|
||||||
|
const d = findDestination(p, s.slice(i + 1));
|
||||||
|
if (!d) return { error: `${p.descriptor.label} has no destination "${s.slice(i + 1).trim()}" — check the quick reply in Admin → Send Message` };
|
||||||
|
return { provider: p, destination: d };
|
||||||
|
}
|
||||||
|
for (const p of providers.values()) {
|
||||||
|
const d = findDestination(p, s);
|
||||||
|
if (d) return { provider: p, destination: d };
|
||||||
|
}
|
||||||
|
return { error: `No destination "${s}" on any messaging module — check the quick reply in Admin → Send Message` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The label a tile shows for a binding — resolved when it can be, the raw text otherwise. */
|
||||||
|
function bindingLabel(binding) {
|
||||||
|
const r = resolveBinding(binding);
|
||||||
|
if (!r) return '';
|
||||||
|
if (r.error) return String(binding).trim();
|
||||||
|
return pairLabel(r.provider.descriptor, r.destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provider and destination a browser asked for. Both must be ones
|
||||||
|
* the module has listed — a PIN-unlocked browser chooses among what the
|
||||||
|
* providers offer, never an arbitrary id.
|
||||||
|
*/
|
||||||
|
async function chooseTarget(providerRef, destinationRef) {
|
||||||
|
const p = findProvider(providerRef);
|
||||||
|
if (!p) {
|
||||||
|
const s = String(providerRef ?? '').trim();
|
||||||
|
return { error: s ? `${s} isn't offering messaging right now — is the module enabled?` : 'Pick where to send first' };
|
||||||
|
}
|
||||||
|
if (!p.descriptor) await describe(p);
|
||||||
|
if (!p.descriptor) return { error: `${p.label} module ${p.error || 'is not answering'}` };
|
||||||
|
let d = findDestination(p, destinationRef);
|
||||||
|
if (!d && String(destinationRef ?? '').trim()) {
|
||||||
|
await describe(p); // a channel added a moment ago
|
||||||
|
d = findDestination(p, destinationRef);
|
||||||
|
}
|
||||||
|
if (!d) {
|
||||||
|
const s = String(destinationRef ?? '').trim();
|
||||||
|
return { error: s ? `${p.descriptor.label} has no destination "${s}" — pick another in Send as` : `Pick a ${p.descriptor.label} destination in Send as` };
|
||||||
|
}
|
||||||
|
return { provider: p, destination: d };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── delivery: hand the message to the provider over loopback ── */
|
||||||
|
async function deliver(req, res, unlock, { provider, destination, text, what }) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - unlock.lastSendAt < SEND_MIN_INTERVAL_MS) {
|
||||||
|
return sendJson(res, 429, { error: 'One message per second — try again', code: 'too_fast' });
|
||||||
|
}
|
||||||
|
const u = unlocks.get(unlock.token);
|
||||||
|
if (u) u.lastSendAt = now;
|
||||||
|
const max = provider.descriptor?.maxChars || DEFAULT_MAX_CHARS;
|
||||||
|
if (text.length > max) return sendJson(res, 400, { error: `Too long for ${provider.descriptor.label} — ${max} characters at most`, code: 'too_long' });
|
||||||
|
const label = provider.descriptor.label;
|
||||||
|
let answer;
|
||||||
|
try {
|
||||||
|
const r = await fetch(loopback(`/api/modules/${encodeURIComponent(provider.id)}/messaging/send`), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${unlock.token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ destination: destination.id, text }),
|
||||||
|
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
let body = {};
|
||||||
|
try { body = await r.json(); } catch { /* a non-JSON refusal */ }
|
||||||
|
answer = { status: r.status, body: body && typeof body === 'object' ? body : {} };
|
||||||
|
} catch (err) {
|
||||||
|
const message = `${label} module unreachable (${errorText(err)}) — not sent`;
|
||||||
|
log(`${what} → ${pairLabel(provider.descriptor, destination)} failed: ${message}`);
|
||||||
|
return sendJson(res, 502, { error: message, code: 'provider_unreachable' });
|
||||||
|
}
|
||||||
|
if (answer.status === 404) {
|
||||||
|
return sendJson(res, 502, { error: `${label} module isn't answering (disabled or restarting) — not sent`, code: 'provider_gone' });
|
||||||
|
}
|
||||||
|
if (answer.status < 200 || answer.status >= 300 || answer.body.ok === false) {
|
||||||
|
const message = String(answer.body.error || `${label} answered HTTP ${answer.status}`);
|
||||||
|
log(`${what} → ${pairLabel(provider.descriptor, destination)} failed: ${message}`);
|
||||||
|
const status = answer.status === 401 ? 401 : [400, 404, 429].includes(answer.status) ? answer.status : 502;
|
||||||
|
return sendJson(res, status, { error: message, code: answer.body.code || `http_${answer.status}` });
|
||||||
|
}
|
||||||
|
const preview = text.replace(/\s+/g, ' ').slice(0, 60);
|
||||||
|
log(`${what} → ${pairLabel(provider.descriptor, destination)}: "${preview}${text.length > 60 ? '…' : ''}"`);
|
||||||
|
scheduleDescribe(500); // the provider may have something new to say about itself
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
ok: true,
|
||||||
|
provider: provider.id,
|
||||||
|
providerLabel: label,
|
||||||
|
destination: destination.id,
|
||||||
|
label: String(answer.body.label || destination.label),
|
||||||
|
role: provider.descriptor.destinationRole,
|
||||||
|
expiresAt: u ? u.expiresAt : unlock.expiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── go ── */
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
for (const res of streams) {
|
||||||
|
try {
|
||||||
|
res.write(': ping\n\n');
|
||||||
|
} catch {
|
||||||
|
streams.delete(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_MS);
|
||||||
|
heartbeat.unref?.();
|
||||||
|
const sweeper = setInterval(sweepUnlocks, UNLOCK_SWEEP_MS);
|
||||||
|
sweeper.unref?.();
|
||||||
|
|
||||||
|
if (!shellPort) log('the shell did not say which port it serves on — this module needs ProdDash 1.4.0 or newer');
|
||||||
|
else {
|
||||||
|
if (pinEnabled && !pinOk) log('the PIN is on but not set (4–8 digits) — send tiles stay locked until it is, or until the PIN is switched off');
|
||||||
|
if (!pinEnabled) log('PIN off — send tiles send without asking');
|
||||||
|
discoverTimer = setTimeout(discover, DISCOVER_FIRST_MS);
|
||||||
|
discoverTimer.unref?.();
|
||||||
|
scheduleDescribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
current = {
|
||||||
|
streams,
|
||||||
|
snapshot,
|
||||||
|
health,
|
||||||
|
wake,
|
||||||
|
verify,
|
||||||
|
sendGate,
|
||||||
|
unlock,
|
||||||
|
chooseTarget,
|
||||||
|
deliver,
|
||||||
|
resolveBinding,
|
||||||
|
bindingLabel,
|
||||||
|
quickReplies,
|
||||||
|
lock(req, res) {
|
||||||
|
const t = bearer(req);
|
||||||
|
if (t) unlocks.delete(t);
|
||||||
|
return sendJson(res, 200, { ok: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
clearTimeout(discoverTimer);
|
||||||
|
clearTimeout(describeTimer);
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
clearInterval(sweeper);
|
||||||
|
unlocks.clear(); // an admin Apply locks every send tile
|
||||||
|
for (const res of streams) {
|
||||||
|
try { res.end(); } catch { /* already gone */ }
|
||||||
|
}
|
||||||
|
streams.clear();
|
||||||
|
if (current && current.streams === streams) current = null;
|
||||||
|
},
|
||||||
|
health,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/** The picker: the two send surfaces, one of each per dashboard. */
|
||||||
|
tiles() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'send',
|
||||||
|
name: 'Send message',
|
||||||
|
description: 'A message to the transcript clicked last, on any platform',
|
||||||
|
single: true,
|
||||||
|
defaultSize: { w: 3, h: 3 },
|
||||||
|
minSize: { w: 2, h: 2 },
|
||||||
|
settings: { view: 'send' },
|
||||||
|
instanceSchema: {
|
||||||
|
showEmojis: { type: 'switch', label: 'Quick emojis', default: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'quick',
|
||||||
|
name: 'Quick replies',
|
||||||
|
description: 'Preset buttons, one press each',
|
||||||
|
single: true,
|
||||||
|
defaultSize: { w: 3, h: 2 },
|
||||||
|
minSize: { w: 2, h: 1 },
|
||||||
|
settings: { view: 'quick' },
|
||||||
|
instanceSchema: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
routes() {
|
||||||
|
const unmounted = (res) => sendJson(res, 503, { error: 'Send Message module is restarting — try again' });
|
||||||
|
|
||||||
|
return {
|
||||||
|
'GET /state': (req, res) => {
|
||||||
|
if (!current) return unmounted(res);
|
||||||
|
current.wake();
|
||||||
|
sendJson(res, 200, current.snapshot());
|
||||||
|
},
|
||||||
|
|
||||||
|
'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) {
|
||||||
|
res.write(`event: state\ndata: ${JSON.stringify({ providers: [], pin: { enabled: true, set: false, length: 0 }, quickEmojis: [], status: { state: 'connecting', message: 'Send Message module is restarting…' } })}\n\n`);
|
||||||
|
return void res.end();
|
||||||
|
}
|
||||||
|
res.write(`event: state\ndata: ${JSON.stringify(current.snapshot())}\n\n`);
|
||||||
|
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||||
|
current.streams.add(res);
|
||||||
|
current.wake();
|
||||||
|
req.on('close', () => current && current.streams.delete(res));
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /unlock': guard(async (req, res) => {
|
||||||
|
if (!current) return unmounted(res);
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await readJson(req);
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, 400, { error: err.message });
|
||||||
|
}
|
||||||
|
current.unlock(req, res, body.pin);
|
||||||
|
}),
|
||||||
|
|
||||||
|
'POST /lock': (req, res) => {
|
||||||
|
if (!current) return unmounted(res);
|
||||||
|
current.lock(req, res);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Providers ask this over loopback before an upstream action: is this unlock good?
|
||||||
|
'GET /verify': (req, res) => {
|
||||||
|
if (!current) return unmounted(res);
|
||||||
|
const u = current.verify(bearer(req));
|
||||||
|
if (!u) return sendJson(res, 401, { ok: false, error: 'Locked — enter the PIN', code: 'locked' });
|
||||||
|
sendJson(res, 200, { ok: true, expiresAt: u.expiresAt });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Labels, indexes and bindings only — the message text never leaves the server.
|
||||||
|
'GET /quick': (req, res) => {
|
||||||
|
if (!current) return unmounted(res);
|
||||||
|
const unlock = current.sendGate(req, res);
|
||||||
|
if (!unlock) return;
|
||||||
|
sendJson(res, 200, {
|
||||||
|
presets: current.quickReplies.map((q, index) => ({ index, label: q.label, target: current.bindingLabel(q.binding) })),
|
||||||
|
expiresAt: unlock.expiresAt,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /send': guard(async (req, res) => {
|
||||||
|
if (!current) return unmounted(res);
|
||||||
|
const unlock = current.sendGate(req, res);
|
||||||
|
if (!unlock) return;
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await readJson(req);
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, 400, { error: err.message });
|
||||||
|
}
|
||||||
|
const text = String(body.text ?? '').replace(/\r\n/g, '\n').trim();
|
||||||
|
if (!text) return sendJson(res, 400, { error: 'Nothing to send' });
|
||||||
|
const target = await current.chooseTarget(body.provider, body.destination);
|
||||||
|
if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_target' });
|
||||||
|
await current.deliver(req, res, unlock, { ...target, text, what: 'sent' });
|
||||||
|
}),
|
||||||
|
|
||||||
|
'POST /quick': guard(async (req, res) => {
|
||||||
|
if (!current) return unmounted(res);
|
||||||
|
const unlock = current.sendGate(req, res);
|
||||||
|
if (!unlock) return;
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await readJson(req);
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, 400, { error: err.message });
|
||||||
|
}
|
||||||
|
const index = Math.trunc(Number(body.index));
|
||||||
|
const preset = Number.isInteger(index) ? current.quickReplies[index] : null;
|
||||||
|
if (!preset) return sendJson(res, 404, { error: 'No such quick reply — the list may have changed in Admin' });
|
||||||
|
// A bound reply ignores the selection; an unbound one needs it.
|
||||||
|
let target = current.resolveBinding(preset.binding);
|
||||||
|
if (!target) target = await current.chooseTarget(body.provider, body.destination);
|
||||||
|
if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_target' });
|
||||||
|
await current.deliver(req, res, unlock, { ...target, text: preset.message, what: `quick reply "${preset.label}"` });
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
/* Send Message module — scoped to its tiles, shell variables only.
|
||||||
|
The gate (PIN pad), the compose box and the preset grid were the Slack
|
||||||
|
module's send surfaces until 1.3.0; they live here now, for every platform. */
|
||||||
|
|
||||||
|
[data-module="send-message"] .sm-wrap {
|
||||||
|
--sm-size: 14px;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
container-type: size;
|
||||||
|
font-size: var(--sm-size);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-wrap:focus { outline: none; }
|
||||||
|
[data-module="send-message"] [hidden] { display: none !important; }
|
||||||
|
|
||||||
|
/* ── the gate ── */
|
||||||
|
|
||||||
|
[data-module="send-message"] .sm-gate,
|
||||||
|
[data-module="send-message"] .sm-compose {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 6px 10px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="send-message"] .sm-note {
|
||||||
|
margin: auto;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9em;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-note.is-error { color: var(--danger); }
|
||||||
|
|
||||||
|
/* The pad never shrinks below its content (min-height stays `auto` on it
|
||||||
|
and on the key grid) — a shrunken grid would spill its rows under the
|
||||||
|
message line, which then swallows the taps. A tile too short for it
|
||||||
|
scrolls instead (.sm-gate has overflow: auto). */
|
||||||
|
[data-module="send-message"] .sm-pad {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: 340px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-pad.sm-shake { animation: sm-shake 0.4s ease; }
|
||||||
|
@keyframes sm-shake {
|
||||||
|
10%, 90% { transform: translateX(-2px); }
|
||||||
|
20%, 80% { transform: translateX(3px); }
|
||||||
|
30%, 50%, 70% { transform: translateX(-5px); }
|
||||||
|
40%, 60% { transform: translateX(5px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* one line: "Enter PIN · Slack · as Apollo Beach → #production" */
|
||||||
|
[data-module="send-message"] .sm-pad-head {
|
||||||
|
flex: none;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-pad-head b { font-weight: 700; color: var(--text); }
|
||||||
|
|
||||||
|
/* The dots ride on a real numeric input: tapping them focuses it, which is
|
||||||
|
what raises a phone's keyboard; on a desktop the keyboard types into it
|
||||||
|
directly. The input itself is invisible — the dots are its display. */
|
||||||
|
[data-module="send-message"] .sm-field {
|
||||||
|
flex: none;
|
||||||
|
position: relative;
|
||||||
|
height: 24px;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-dots {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-dot {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--muted);
|
||||||
|
transition: background 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-dot.on { background: var(--accent); border-color: var(--accent); }
|
||||||
|
[data-module="send-message"] .sm-pin-input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: transparent;
|
||||||
|
caret-color: transparent;
|
||||||
|
font: inherit;
|
||||||
|
text-align: center;
|
||||||
|
outline: none;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-pin-input::selection { background: transparent; }
|
||||||
|
[data-module="send-message"] .sm-pin-input:focus-visible { box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 45%, transparent); }
|
||||||
|
|
||||||
|
[data-module="send-message"] .sm-keys {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
grid-template-rows: repeat(4, minmax(24px, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-key {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 24px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--text) 5%, var(--panel));
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: clamp(13px, 9cqh, 26px);
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-key:hover { background: var(--border); }
|
||||||
|
[data-module="send-message"] .sm-key:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); }
|
||||||
|
[data-module="send-message"] .sm-key svg { width: 1.1em; height: 1.1em; }
|
||||||
|
[data-module="send-message"] .sm-key-back { color: var(--muted); }
|
||||||
|
[data-module="send-message"] .sm-key-blank { visibility: hidden; }
|
||||||
|
[data-module="send-message"] .sm-pad.is-busy .sm-keys { opacity: 0.6; pointer-events: none; }
|
||||||
|
|
||||||
|
[data-module="send-message"] .sm-pad-msg {
|
||||||
|
flex: none;
|
||||||
|
min-height: 1.1em;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.78em;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-pad-msg.is-error { color: var(--danger); font-weight: 600; }
|
||||||
|
|
||||||
|
/* A tile too short for the keypad (the smallest Send message / Quick replies
|
||||||
|
tiles) shows the dots and a hint instead — the dots still take a tap and
|
||||||
|
the keyboard, and a taller tile brings the keys back. .sm-wrap is the size
|
||||||
|
container the height is measured against. */
|
||||||
|
[data-module="send-message"] .sm-pad-hint {
|
||||||
|
display: none;
|
||||||
|
flex: none;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.78em;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
@container (max-height: 176px) {
|
||||||
|
[data-module="send-message"] .sm-pad .sm-keys { display: none; }
|
||||||
|
[data-module="send-message"] .sm-pad .sm-pad-hint { display: block; }
|
||||||
|
[data-module="send-message"] .sm-pad { justify-content: center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── unlocked ── */
|
||||||
|
|
||||||
|
[data-module="send-message"] .sm-compose { gap: 6px; }
|
||||||
|
|
||||||
|
[data-module="send-message"] .sm-compose-head {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-sending {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-lock-link {
|
||||||
|
flex: none;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-lock-link:hover { color: var(--text); background: var(--border); }
|
||||||
|
|
||||||
|
/* The box takes whatever the quick emoji rows leave it, down to one line. */
|
||||||
|
[data-module="send-message"] .sm-box {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: calc(1.4em + 16px);
|
||||||
|
width: 100%;
|
||||||
|
resize: none;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-input:focus { outline: none; border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* The grip: a small pill centred on the box's bottom edge, straddling the
|
||||||
|
border. Drag it up for more emoji rows. It is positioned over the box, so
|
||||||
|
it adds no height between the box and the keys. */
|
||||||
|
[data-module="send-message"] .sm-grip {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: -6px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 44px;
|
||||||
|
height: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: ns-resize;
|
||||||
|
touch-action: none;
|
||||||
|
z-index: 1;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-grip::before {
|
||||||
|
content: "";
|
||||||
|
width: 26px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--muted);
|
||||||
|
box-shadow: 0 0 0 1.5px var(--panel);
|
||||||
|
transition: background 0.12s, width 0.12s;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-grip:hover::before,
|
||||||
|
[data-module="send-message"] .sm-grip:focus-visible::before,
|
||||||
|
[data-module="send-message"] .sm-grip.is-dragging::before { background: var(--accent); width: 34px; }
|
||||||
|
[data-module="send-message"] .sm-grip:focus { outline: none; }
|
||||||
|
|
||||||
|
/* The smallest Send message tile (two grid rows, a 110px body): the compose
|
||||||
|
area tightens its padding, gaps and the box's one-line minimum so that two
|
||||||
|
rows of quick emoji keys still fit under the box. .sm-wrap is the size
|
||||||
|
container the height is measured against. */
|
||||||
|
@container (max-height: 150px) {
|
||||||
|
[data-module="send-message"] .sm-compose { padding: 3px 10px; gap: 2px; }
|
||||||
|
[data-module="send-message"] .sm-input { padding: 4px 9px; line-height: 1.25; min-height: calc(1.25em + 10px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The Send button and the outcome icon sit right under the box, level with
|
||||||
|
the first row of keys; further rows of keys run on below them. */
|
||||||
|
[data-module="send-message"] .sm-compose-foot {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 1.6em;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
/* the outcome of a send, as an icon beside the Send button: a check (accent
|
||||||
|
— green on the default theme) or a blocked sign (danger), fading out as
|
||||||
|
its five seconds run down; the sentence is the tooltip. The slot keeps its
|
||||||
|
width while empty so the Send button never shifts. */
|
||||||
|
[data-module="send-message"] .sm-feedback {
|
||||||
|
flex: none;
|
||||||
|
width: 1.45em;
|
||||||
|
min-height: 2.1em; /* the Send button's height, so the icon centres on it */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-countdown { align-self: center; }
|
||||||
|
[data-module="send-message"] .sm-feedback svg {
|
||||||
|
width: 1.45em;
|
||||||
|
height: 1.45em;
|
||||||
|
flex: none;
|
||||||
|
animation: sm-pop 0.18s ease-out, sm-fade 0.6s ease-in 4.4s forwards;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-feedback.is-ok { color: var(--accent); }
|
||||||
|
[data-module="send-message"] .sm-feedback.is-error { color: var(--danger); cursor: help; }
|
||||||
|
/* the Quick replies foot has no Send button: the icon keeps to the right edge */
|
||||||
|
[data-module="send-message"] .sm-compose-foot.is-quick { justify-content: flex-end; }
|
||||||
|
@keyframes sm-pop {
|
||||||
|
from { transform: scale(0.6); opacity: 0; }
|
||||||
|
to { transform: none; opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes sm-fade {
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-countdown {
|
||||||
|
flex: none;
|
||||||
|
font-size: 0.75em;
|
||||||
|
color: var(--warn);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-sendbtn {
|
||||||
|
flex: none;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-sendbtn:hover { background: color-mix(in srgb, var(--accent) 85%, var(--text)); }
|
||||||
|
[data-module="send-message"] .sm-sendbtn:disabled { opacity: 0.6; cursor: default; }
|
||||||
|
|
||||||
|
/* quick emojis: one press each sends it as a message of its own. The keys
|
||||||
|
take whatever room the foot has to the left of the outcome icon and the
|
||||||
|
Send button. As one row (the default) they scroll sideways when the tile
|
||||||
|
is narrow (trackpad, touch, or the wheel — client.js turns a vertical
|
||||||
|
wheel into a horizontal scroll) while Send keeps its place; with more
|
||||||
|
rows allotted from the grip they wrap, --sm-quick-rows rows tall. One key
|
||||||
|
is --sm-key square, so a row's pitch is --sm-key plus the 4px gap. */
|
||||||
|
[data-module="send-message"] .sm-emoji-sendrow {
|
||||||
|
--sm-quick-rows: 1;
|
||||||
|
--sm-key: calc(2.31 * var(--sm-size));
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: var(--sm-key);
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-emoji-sendrow.is-multi {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-content: flex-start;
|
||||||
|
height: calc(var(--sm-quick-rows) * var(--sm-key) + (var(--sm-quick-rows) - 1) * 4px);
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-emoji-sendrow::-webkit-scrollbar { display: none; }
|
||||||
|
/* more keys off one edge or the other: that edge fades out (client.js sets the classes) */
|
||||||
|
[data-module="send-message"] .sm-emoji-sendrow.has-more { mask-image: linear-gradient(to right, #000 calc(100% - 16px), transparent); }
|
||||||
|
[data-module="send-message"] .sm-emoji-sendrow.has-less { mask-image: linear-gradient(to right, transparent, #000 16px); }
|
||||||
|
[data-module="send-message"] .sm-emoji-sendrow.has-less.has-more { mask-image: linear-gradient(to right, transparent, #000 16px, #000 calc(100% - 16px), transparent); }
|
||||||
|
[data-module="send-message"] .sm-emoji-sendrow:empty { display: none; }
|
||||||
|
[data-module="send-message"] .sm-emoji-send {
|
||||||
|
flex: none;
|
||||||
|
width: var(--sm-key);
|
||||||
|
height: var(--sm-key);
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--text) 5%, var(--panel));
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1.1em;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition: background 0.1s, border-color 0.1s;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-emoji-send:hover { border-color: var(--accent); background: var(--border); }
|
||||||
|
[data-module="send-message"] .sm-emoji-send:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); }
|
||||||
|
[data-module="send-message"] .sm-emoji-send.is-busy { opacity: 0.6; }
|
||||||
|
[data-module="send-message"] .sm-emoji-send.is-sent { background: var(--accent); border-color: transparent; }
|
||||||
|
[data-module="send-message"] .sm-emoji-char { font-size: 1em; line-height: 1; }
|
||||||
|
/* a :code: or a word rather than one emoji: a wider pill of small text */
|
||||||
|
[data-module="send-message"] .sm-emoji-send.is-text {
|
||||||
|
width: auto;
|
||||||
|
max-width: 7em;
|
||||||
|
padding: 0 7px;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-emoji-send.is-text .sm-emoji-char {
|
||||||
|
font-size: 0.72em;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the preset grid: big targets, labels wrap */
|
||||||
|
[data-module="send-message"] .sm-grid {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(150px, 100%), 1fr));
|
||||||
|
grid-auto-rows: minmax(44px, auto);
|
||||||
|
gap: 6px;
|
||||||
|
align-content: start;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-grid .sm-note { grid-column: 1 / -1; }
|
||||||
|
[data-module="send-message"] .sm-quick {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, var(--panel));
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-quick:hover { background: color-mix(in srgb, var(--accent) 22%, var(--panel)); }
|
||||||
|
[data-module="send-message"] .sm-quick:active { background: color-mix(in srgb, var(--accent) 40%, var(--panel)); }
|
||||||
|
[data-module="send-message"] .sm-quick.is-busy { opacity: 0.7; }
|
||||||
|
[data-module="send-message"] .sm-quick.is-sent {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--accent-text);
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-quick-label { overflow-wrap: anywhere; }
|
||||||
|
[data-module="send-message"] .sm-quick-chan {
|
||||||
|
font-size: 0.72em;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
/* sent: the words step aside for a check, in the button itself */
|
||||||
|
[data-module="send-message"] .sm-quick { position: relative; }
|
||||||
|
[data-module="send-message"] .sm-quick-check {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-quick-check svg { width: 1.6em; height: 1.6em; animation: sm-pop 0.18s ease-out; }
|
||||||
|
[data-module="send-message"] .sm-quick.is-sent .sm-quick-label,
|
||||||
|
[data-module="send-message"] .sm-quick.is-sent .sm-quick-chan { visibility: hidden; }
|
||||||
|
[data-module="send-message"] .sm-quick.is-sent .sm-quick-check { display: flex; }
|
||||||
|
|
||||||
|
/* ── the Send as menu ──
|
||||||
|
The menu container is created and positioned by the shell (.tile-menu);
|
||||||
|
it hangs off the body under a [data-module] host, so scoping still applies. */
|
||||||
|
|
||||||
|
[data-module="send-message"] .sm-menu { min-width: 180px; max-height: min(60vh, 420px); overflow-y: auto; }
|
||||||
|
[data-module="send-message"] .sm-menu-head {
|
||||||
|
padding: 6px 9px 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-menu-head b { color: var(--text); font-weight: 700; }
|
||||||
|
[data-module="send-message"] .sm-menu-head.is-error b { color: var(--danger); }
|
||||||
|
[data-module="send-message"] .sm-menu-head.is-muted b { color: var(--muted); }
|
||||||
|
[data-module="send-message"] .sm-menu-head + .sm-menu-head,
|
||||||
|
[data-module="send-message"] .sm-menu-item + .sm-menu-head,
|
||||||
|
[data-module="send-message"] .sm-menu-note + .sm-menu-head {
|
||||||
|
margin-top: 4px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-menu-note {
|
||||||
|
padding: 4px 9px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
max-width: 260px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-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="send-message"] .sm-menu-item:hover { background: var(--border); }
|
||||||
|
[data-module="send-message"] .sm-menu-item .check {
|
||||||
|
width: 14px;
|
||||||
|
flex: none;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
[data-module="send-message"] .sm-menu-item.on .check { visibility: visible; }
|
||||||
|
[data-module="send-message"] .sm-menu-item.remembered .check { visibility: visible; color: var(--muted); opacity: 0.6; }
|
||||||
|
[data-module="send-message"] .sm-menu-item .name { font-weight: 600; }
|
||||||
|
[data-module="send-message"] .sm-menu-item.on .name { color: var(--accent); }
|
||||||
+114
-782
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,12 @@
|
|||||||
{
|
{
|
||||||
"id": "slack",
|
"id": "slack",
|
||||||
"name": "Slack",
|
"name": "Slack",
|
||||||
"version": "1.2.1",
|
"version": "1.3.0",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location",
|
"description": "Live transcripts of this location's Slack channels; messages and reactions leave as the location through the Send Message module",
|
||||||
|
"provides": [
|
||||||
|
"messaging"
|
||||||
|
],
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
"style": "style.css",
|
"style": "style.css",
|
||||||
@@ -18,14 +21,10 @@
|
|||||||
"configGroups": {
|
"configGroups": {
|
||||||
"Slack account": {
|
"Slack account": {
|
||||||
"collapsed": "whenSet",
|
"collapsed": "whenSet",
|
||||||
"help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages from this machine are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location."
|
"help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages sent from this machine through the Send Message module are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location."
|
||||||
},
|
},
|
||||||
"Channels": {
|
"Channels": {
|
||||||
"help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and until one is clicked, to the first channel here whose transcript is on that dashboard: with a single transcript up, that one. A send never goes to a channel the dashboard is not showing. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit."
|
"help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name, and a destination the Send Message module's tiles can post to: a click on a transcript points them at its channel (its bottom edge lights up in the accent colour) — that is the only way a Slack channel is chosen; the Send as menu is for platforms that send as an identity. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit."
|
||||||
},
|
|
||||||
"Sending": {
|
|
||||||
"toggle": "sendEnabled",
|
|
||||||
"help": "Messages and emoji reactions leave this machine only after the PIN is entered — on a Send or Quick replies tile, or in the reaction menu a right-click on a transcript message opens. One PIN unlocks every Slack tile on that dashboard page until Auto-lock minutes pass without a send; a reload asks again. Sending stays refused until a 4–8 digit PIN is set here. A message goes to the channel of the transcript clicked last. Quick emojis are sent with one press from the Send message tile and offered first when reacting. Quick replies are one per line, Label | Message, with an optional | #channel-name to bind that one to a channel: a bound reply always goes there, whichever transcript is selected."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
@@ -41,7 +40,7 @@
|
|||||||
"label": "Send as",
|
"label": "Send as",
|
||||||
"default": "",
|
"default": "",
|
||||||
"group": "Slack account",
|
"group": "Slack account",
|
||||||
"help": "Only used with a bot token — a user token always sends as its own account; needs the chat:write.customize scope."
|
"help": "Only used with a bot token — a user token always sends as its own account; needs the chat:write.customize scope. Applies to messages the Send Message module posts through this account."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"type": "multiselect",
|
"type": "multiselect",
|
||||||
@@ -49,7 +48,7 @@
|
|||||||
"default": [],
|
"default": [],
|
||||||
"group": "Channels",
|
"group": "Channels",
|
||||||
"optionsRoute": "/channels",
|
"optionsRoute": "/channels",
|
||||||
"help": "One transcript tile per channel; until a transcript is clicked, sends go to the first channel here that the dashboard is actually showing. Only channels the account has joined are listed — join one in Slack first."
|
"help": "One transcript tile per channel, and one destination each for the Send Message module. Only channels the account has joined are listed — join one in Slack first."
|
||||||
},
|
},
|
||||||
"pollSeconds": {
|
"pollSeconds": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
@@ -69,39 +68,6 @@
|
|||||||
"label": "Thread replies",
|
"label": "Thread replies",
|
||||||
"default": false,
|
"default": false,
|
||||||
"group": "Transcript"
|
"group": "Transcript"
|
||||||
},
|
|
||||||
"sendEnabled": {
|
|
||||||
"type": "switch",
|
|
||||||
"label": "Sending",
|
|
||||||
"default": false,
|
|
||||||
"group": "Sending"
|
|
||||||
},
|
|
||||||
"pin": {
|
|
||||||
"type": "password",
|
|
||||||
"label": "PIN",
|
|
||||||
"default": "",
|
|
||||||
"group": "Sending",
|
|
||||||
"help": "4–8 digits; the pad unlocks by itself once they are all in."
|
|
||||||
},
|
|
||||||
"pinIdleMinutes": {
|
|
||||||
"type": "number",
|
|
||||||
"label": "Auto-lock (min)",
|
|
||||||
"default": 10,
|
|
||||||
"group": "Sending"
|
|
||||||
},
|
|
||||||
"quickReplies": {
|
|
||||||
"type": "text",
|
|
||||||
"label": "Quick replies",
|
|
||||||
"default": "",
|
|
||||||
"group": "Sending",
|
|
||||||
"help": "One per line: Label | Message. Add | #channel-name to bind one to a channel — it then ignores which transcript is selected."
|
|
||||||
},
|
|
||||||
"quickEmojis": {
|
|
||||||
"type": "string",
|
|
||||||
"label": "Quick emojis",
|
|
||||||
"default": "👍 🙏 ✅ 👀 🎉",
|
|
||||||
"group": "Sending",
|
|
||||||
"help": "Emoji or :codes: separated by spaces, eight at most. One press sends one from the Send message tile; they also head the reaction menu."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"instanceSchema": {
|
"instanceSchema": {
|
||||||
|
|||||||
+108
-339
@@ -6,26 +6,29 @@
|
|||||||
* One Slack Web API connection per ProdDash machine — the campus's shared
|
* One Slack Web API connection per ProdDash machine — the campus's shared
|
||||||
* location account (a user token), or a bot — polling the configured
|
* location account (a user token), or a bot — polling the configured
|
||||||
* channels' recent history (one poller per channel, all inside one rate
|
* channels' recent history (one poller per channel, all inside one rate
|
||||||
* budget) and fanning the lot out to every tile over SSE, plus PIN-gated
|
* budget) and fanning the lot out to every tile over SSE. Browsers never
|
||||||
* sending from this machine so a message reads as coming from the location.
|
* talk to Slack; the token stays here.
|
||||||
* Browsers never talk to Slack; the token and the PIN stay here.
|
|
||||||
*
|
*
|
||||||
* GET /state snapshot: identity, channels (keyed by id), sending state
|
* Sending is not this module's: the Send Message module owns the PIN, the
|
||||||
* GET /stream SSE `state` events (change-detected, heartbeated)
|
* send tiles and the quick replies, and hands a message to this module's
|
||||||
* GET /channels admin multiselect options — the channels the account is in
|
* POST /messaging/send over loopback, so it still leaves through this one
|
||||||
* POST /unlock { pin } → { token, expiresAt } (constant-time, lockout)
|
* connection, as the location. Every outbound action — a message, an emoji
|
||||||
* POST /lock Bearer → forget that unlock token
|
* reaction from a transcript — carries that module's unlock token and is
|
||||||
* GET /quick Bearer → preset labels + indexes (never the message text)
|
* checked with it (GET /verify, over loopback) before Slack is asked. This
|
||||||
* POST /send Bearer, { text, channel } → chat.postMessage
|
* module has no PIN of its own and cannot post without one entered there.
|
||||||
* POST /react Bearer, { channel, ts, name, remove? } → reactions.add / .remove
|
*
|
||||||
* GET /emoji { standard: {code: emoji}, custom: {name: {url}|{char}}, version }
|
* GET /state snapshot: identity, channels (keyed by id)
|
||||||
|
* GET /stream SSE `state` events (change-detected, heartbeated)
|
||||||
|
* GET /channels admin multiselect options — the channels the account is in
|
||||||
|
* GET /messaging the configured channels as destinations (the module guide's contract)
|
||||||
|
* POST /messaging/send Bearer (Send Message unlock), { destination, text } → chat.postMessage
|
||||||
|
* POST /react Bearer (Send Message unlock), { channel, ts, name, remove? } → reactions.add / .remove
|
||||||
|
* GET /emoji { standard: {code: emoji}, custom: {name: {url}|{char}}, version }
|
||||||
* GET /emoji-image/<name>, GET /avatar/<u/USER|b/BOT> images proxied from Slack's CDN
|
* GET /emoji-image/<name>, GET /avatar/<u/USER|b/BOT> images proxied from Slack's CDN
|
||||||
* POST /quick Bearer, { index, channel } → post that preset
|
|
||||||
*
|
*
|
||||||
* `channel` on /send and /quick must be one of the configured channel ids
|
* `destination` / `channel` must be one of the configured channel ids (400
|
||||||
* (400 otherwise): a PIN-unlocked browser chooses among the admin's channels,
|
* otherwise): an unlocked browser chooses among the admin's channels, never
|
||||||
* never an arbitrary one. A quick reply with its own "| #channel" ignores the
|
* an arbitrary one.
|
||||||
* request's channel — an explicit binding wins over the selection.
|
|
||||||
*
|
*
|
||||||
* Rate budget. Slack allows an internal app roughly 50+ conversations.history
|
* Rate budget. Slack allows an internal app roughly 50+ conversations.history
|
||||||
* calls a minute per token (Tier 3). The module keeps all its pollers under
|
* calls a minute per token (Tier 3). The module keeps all its pollers under
|
||||||
@@ -46,8 +49,6 @@
|
|||||||
* tools/slack-mock.js for development.
|
* tools/slack-mock.js for development.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const crypto = require('crypto');
|
|
||||||
|
|
||||||
const DEFAULT_API_BASE = 'https://slack.com/api';
|
const DEFAULT_API_BASE = 'https://slack.com/api';
|
||||||
const API_BASE = String(process.env.SLACK_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
const API_BASE = String(process.env.SLACK_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
||||||
|
|
||||||
@@ -92,14 +93,11 @@ const FIRST_READ_STAGGER_MS = 250;
|
|||||||
const MIN_SLOT_GAP_MS = 200;
|
const MIN_SLOT_GAP_MS = 200;
|
||||||
/** tiles() waits at most this long for the first conversations.info answers after boot. */
|
/** tiles() waits at most this long for the first conversations.info answers after boot. */
|
||||||
const NAMES_WAIT_MS = 1500;
|
const NAMES_WAIT_MS = 1500;
|
||||||
const PIN_RE = /^\d{4,8}$/;
|
|
||||||
const PIN_MAX_WRONG = 5;
|
|
||||||
const PIN_LOCKOUT_MS = 30000;
|
|
||||||
const SEND_MIN_INTERVAL_MS = 1000;
|
|
||||||
const MAX_TEXT_CHARS = 4000;
|
const MAX_TEXT_CHARS = 4000;
|
||||||
const BODY_LIMIT_BYTES = 64 * 1024;
|
const BODY_LIMIT_BYTES = 64 * 1024;
|
||||||
const DEFAULT_ICON = ':satellite_antenna:';
|
const DEFAULT_ICON = ':satellite_antenna:';
|
||||||
const UNLOCK_SWEEP_MS = 30000;
|
/** The module that owns the PIN and the unlock tokens — see verifyUnlock(). */
|
||||||
|
const SENDER_ID = 'send-message';
|
||||||
|
|
||||||
/** Set by init(), read by routes() and tiles() — all rebuilt together on remount. */
|
/** Set by init(), read by routes() and tiles() — all rebuilt together on remount. */
|
||||||
let current = null;
|
let current = null;
|
||||||
@@ -238,7 +236,6 @@ const EMOJI_NAMES = require('./emoji.json').names;
|
|||||||
const EMOJI_CODE_RE = /^([a-z0-9_+-]{1,100})(?:::(skin-tone-[2-6]))?$/i;
|
const EMOJI_CODE_RE = /^([a-z0-9_+-]{1,100})(?:::(skin-tone-[2-6]))?$/i;
|
||||||
/** How long the workspace's custom emoji list is believed before emoji.list is asked again. */
|
/** How long the workspace's custom emoji list is believed before emoji.list is asked again. */
|
||||||
const EMOJI_LIST_TTL_MS = 60 * 60 * 1000;
|
const EMOJI_LIST_TTL_MS = 60 * 60 * 1000;
|
||||||
let emojiReverse = null; // character (variation selector stripped) → the name Slack knows it by
|
|
||||||
|
|
||||||
/** The character for a standard code, '' for a custom or unknown one. */
|
/** The character for a standard code, '' for a custom or unknown one. */
|
||||||
function emojiChar(code) {
|
function emojiChar(code) {
|
||||||
@@ -250,45 +247,6 @@ function emojiChar(code) {
|
|||||||
return tone ? base.replace(/\uFE0F/g, '') + tone : base;
|
return tone ? base.replace(/\uFE0F/g, '') + tone : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The short name for a character ('' if Slack has none) — a reaction needs the name, not the glyph. */
|
|
||||||
function emojiName(char) {
|
|
||||||
if (!emojiReverse) {
|
|
||||||
emojiReverse = new Map();
|
|
||||||
for (const [name, ch] of Object.entries(EMOJI_NAMES)) {
|
|
||||||
if (name.startsWith('skin-tone-')) continue;
|
|
||||||
const key = ch.replace(/\uFE0F/g, '');
|
|
||||||
if (!emojiReverse.has(key)) emojiReverse.set(key, name); // keys are sorted: "+1" wins over "thumbsup"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const s = String(char || '').replace(/\uFE0F/g, '');
|
|
||||||
const tone = /[\u{1F3FB}-\u{1F3FF}]$/u.exec(s);
|
|
||||||
if (tone) {
|
|
||||||
const base = emojiReverse.get(s.slice(0, -tone[0].length));
|
|
||||||
const toneName = Object.keys(EMOJI_NAMES).find((n) => n.startsWith('skin-tone-') && EMOJI_NAMES[n] === tone[0]);
|
|
||||||
return base && toneName ? `${base}::${toneName}` : '';
|
|
||||||
}
|
|
||||||
return emojiReverse.get(s) || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The admin's "Quick emojis" — "👍 🙏 :white_check_mark: :party_parrot:" →
|
|
||||||
* [{ name, char }]. A standard emoji gets both; a custom code keeps its name
|
|
||||||
* and no character (the browser draws the image); a glyph Slack has no name
|
|
||||||
* for can be sent but not reacted with (name ''). Eight at most.
|
|
||||||
*/
|
|
||||||
function parseQuickEmojis(text) {
|
|
||||||
const out = [];
|
|
||||||
for (const tok of String(text || '').split(/\s+/).filter(Boolean)) {
|
|
||||||
const code = /^:([^:\s]+(?:::skin-tone-[2-6])?):$/i.exec(tok);
|
|
||||||
if (code) {
|
|
||||||
const name = code[1].toLowerCase();
|
|
||||||
if (EMOJI_CODE_RE.test(name)) out.push({ name, char: emojiChar(name) });
|
|
||||||
} else out.push({ name: emojiName(tok), char: tok });
|
|
||||||
if (out.length >= 8) break;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Avatars and custom emoji are images on Slack's CDN; browsers only talk to
|
/* Avatars and custom emoji are images on Slack's CDN; browsers only talk to
|
||||||
ProdDash, so the module fetches them and keeps them a while. */
|
ProdDash, so the module fetches them and keeps them a while. */
|
||||||
const IMAGE_CACHE_MAX = 400;
|
const IMAGE_CACHE_MAX = 400;
|
||||||
@@ -453,38 +411,30 @@ function bearer(req) {
|
|||||||
return m ? m[1] : '';
|
return m ? m[1] : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function clientIp(req) {
|
|
||||||
return String(req.socket?.remoteAddress || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Constant-time PIN compare (lengths are folded in after the timing-safe step). */
|
|
||||||
function pinMatches(given, expected) {
|
|
||||||
const a = Buffer.alloc(16);
|
|
||||||
const b = Buffer.alloc(16);
|
|
||||||
a.write(String(given).slice(0, 16));
|
|
||||||
b.write(String(expected).slice(0, 16));
|
|
||||||
const same = crypto.timingSafeEqual(a, b);
|
|
||||||
return same && given.length === expected.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Label | Message | #channel" per line. A line with no "|" is both its label
|
* Is the unlock token on this request good? Only the Send Message module
|
||||||
* and its message; the channel (optional) is a name the account can see —
|
* knows — it owns the PIN — so ask it over loopback. Answers { ok: true,
|
||||||
* a reply that carries one always goes there, whatever transcript is selected.
|
* expiresAt } or { ok: false, status, error, code } ready to send back.
|
||||||
*/
|
*/
|
||||||
function parseQuickReplies(text) {
|
async function verifyUnlock(req, shellPort) {
|
||||||
const out = [];
|
const token = bearer(req);
|
||||||
for (const rawLine of String(text || '').split(/\r?\n/)) {
|
if (!token) return { ok: false, status: 401, error: 'Locked — enter the PIN', code: 'locked' };
|
||||||
const line = rawLine.trim();
|
if (!shellPort) return { ok: false, status: 503, error: 'This ProdDash cannot verify a PIN (needs 1.4.0 or newer)', code: 'no_shell_port' };
|
||||||
if (!line) continue;
|
let res;
|
||||||
const parts = line.split('|').map((p) => p.trim());
|
try {
|
||||||
const label = parts[0];
|
res = await fetch(`http://127.0.0.1:${shellPort}/api/modules/${SENDER_ID}/verify`, {
|
||||||
const message = parts.length > 1 && parts[1] ? parts[1] : label;
|
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||||
const channel = parts.length > 2 ? parts[2].replace(/^#/, '') : '';
|
signal: AbortSignal.timeout(5000),
|
||||||
if (!label || !message) continue;
|
});
|
||||||
out.push({ label, message, channel });
|
} catch (err) {
|
||||||
|
return { ok: false, status: 502, error: `Cannot reach the Send Message module (${err.cause?.code || err.message})`, code: 'sender_unreachable' };
|
||||||
}
|
}
|
||||||
return out;
|
if (res.status === 404) return { ok: false, status: 503, error: 'Install the Send Message module to send or react from a dashboard', code: 'no_sender' };
|
||||||
|
let body = {};
|
||||||
|
try { body = await res.json(); } catch { /* a non-JSON refusal */ }
|
||||||
|
if (res.status === 401 || body?.ok === false) return { ok: false, status: 401, error: body?.error || 'Locked — enter the PIN', code: body?.code || 'locked' };
|
||||||
|
if (!res.ok) return { ok: false, status: 502, error: `Send Message module answered HTTP ${res.status}`, code: 'sender_error' };
|
||||||
|
return { ok: true, expiresAt: Number(body?.expiresAt) || 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const SYSTEM_SUBTYPES = new Set([
|
const SYSTEM_SUBTYPES = new Set([
|
||||||
@@ -497,8 +447,9 @@ const SYSTEM_SUBTYPES = new Set([
|
|||||||
/* ── the module ─────────────────────────────────────────────────────── */
|
/* ── the module ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
init({ config, log }) {
|
init({ config, log, shell }) {
|
||||||
/* ── config ── */
|
/* ── config ── */
|
||||||
|
const shellPort = Number(shell && shell.port) || 0;
|
||||||
const token = String(config.token || '').trim();
|
const token = String(config.token || '').trim();
|
||||||
const sendAs = String(config.sendAs || '').trim();
|
const sendAs = String(config.sendAs || '').trim();
|
||||||
const channelIds = configuredChannels(config);
|
const channelIds = configuredChannels(config);
|
||||||
@@ -508,12 +459,6 @@ module.exports = {
|
|||||||
const historyCount = Math.min(HISTORY_MAX, Math.max(HISTORY_MIN, Math.trunc(Number(config.historyCount)) || 40));
|
const historyCount = Math.min(HISTORY_MAX, Math.max(HISTORY_MIN, Math.trunc(Number(config.historyCount)) || 40));
|
||||||
const includeReplies = Boolean(config.includeReplies);
|
const includeReplies = Boolean(config.includeReplies);
|
||||||
const threadFetchesPerPoll = Math.max(1, Math.floor(THREAD_FETCHES_PER_POLL / Math.max(1, channelIds.length)));
|
const threadFetchesPerPoll = Math.max(1, Math.floor(THREAD_FETCHES_PER_POLL / Math.max(1, channelIds.length)));
|
||||||
const sendEnabled = Boolean(config.sendEnabled);
|
|
||||||
const pin = String(config.pin || '').trim();
|
|
||||||
const pinOk = PIN_RE.test(pin);
|
|
||||||
const idleMs = Math.min(240, Math.max(1, Number(config.pinIdleMinutes) || 10)) * 60 * 1000;
|
|
||||||
const quickReplies = parseQuickReplies(config.quickReplies);
|
|
||||||
const quickEmojis = parseQuickEmojis(config.quickEmojis);
|
|
||||||
|
|
||||||
/* ── state (all inside this closure so a re-init starts clean) ── */
|
/* ── state (all inside this closure so a re-init starts clean) ── */
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
@@ -531,8 +476,6 @@ module.exports = {
|
|||||||
let lastInterest = Date.now();
|
let lastInterest = Date.now();
|
||||||
const epoch = Date.now(); // the phase-locked slots count from here
|
const epoch = Date.now(); // the phase-locked slots count from here
|
||||||
const streams = new Set();
|
const streams = new Set();
|
||||||
const unlocks = new Map(); // token -> { expiresAt, lastSendAt, ip }
|
|
||||||
const pinAttempts = new Map(); // ip -> { count, lockedUntil }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One channel's poller: its own window, thread cache, timer and error,
|
* One channel's poller: its own window, thread cache, timer and error,
|
||||||
@@ -724,21 +667,6 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "#name" or a channel id → the channel id, or '' when the account can't see it. */
|
|
||||||
async function resolveChannelRef(ref) {
|
|
||||||
const s = String(ref || '').trim();
|
|
||||||
if (!s) return '';
|
|
||||||
if (/^[CG][A-Z0-9]{6,}$/.test(s)) return s;
|
|
||||||
const name = s.replace(/^#/, '').toLowerCase();
|
|
||||||
let list = await loadChannelList();
|
|
||||||
let hit = list.find((c) => c.name.toLowerCase() === name);
|
|
||||||
if (!hit) {
|
|
||||||
list = await loadChannelList(true);
|
|
||||||
hit = list.find((c) => c.name.toLowerCase() === name);
|
|
||||||
}
|
|
||||||
return hit ? hit.id : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── normalisation ── */
|
/* ── normalisation ── */
|
||||||
function nameFor(m) {
|
function nameFor(m) {
|
||||||
if (m.subtype === 'bot_message' && m.username) return String(m.username);
|
if (m.subtype === 'bot_message' && m.username) return String(m.username);
|
||||||
@@ -845,7 +773,7 @@ module.exports = {
|
|||||||
return { state: 'ok', message: `${labelOf(ch)} · live` };
|
return { state: 'ok', message: `${labelOf(ch)} · live` };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The admin health line: who, which channels, what's wrong, sending. */
|
/** The admin health line: who, which channels, what's wrong. */
|
||||||
function health() {
|
function health() {
|
||||||
if (!token) return { status: 'error', message: 'No Slack token — paste the location account’s User OAuth Token in Admin → Slack' };
|
if (!token) return { status: 'error', message: 'No Slack token — paste the location account’s User OAuth Token in Admin → Slack' };
|
||||||
if (authError) return { status: 'error', message: authError };
|
if (authError) return { status: 'error', message: authError };
|
||||||
@@ -854,9 +782,8 @@ module.exports = {
|
|||||||
const list = [...pollers.values()];
|
const list = [...pollers.values()];
|
||||||
if (!list.length) return { status: 'error', message: `${who} · pick channels in Admin → Slack → Channels` };
|
if (!list.length) return { status: 'error', message: `${who} · pick channels in Admin → Slack → Channels` };
|
||||||
const names = list.map(labelOf).join(', ');
|
const names = list.map(labelOf).join(', ');
|
||||||
const sending = !sendEnabled ? 'sending off' : !pinOk ? 'sending on — set a 4–8 digit PIN' : 'sending on';
|
|
||||||
const problems = [...new Set(list.filter((ch) => ch.error).map((ch) => (ch.error.includes(labelOf(ch)) ? ch.error : `${labelOf(ch)}: ${ch.error}`)))];
|
const problems = [...new Set(list.filter((ch) => ch.error).map((ch) => (ch.error.includes(labelOf(ch)) ? ch.error : `${labelOf(ch)}: ${ch.error}`)))];
|
||||||
if (problems.length) return { status: 'error', message: `${who} · ${names} · ${problems.join(' · ')} · ${sending}` };
|
if (problems.length) return { status: 'error', message: `${who} · ${names} · ${problems.join(' · ')}` };
|
||||||
if (list.some((ch) => !ch.lastPollAt)) return { status: 'connecting', message: `${who} · ${names} · first read…` };
|
if (list.some((ch) => !ch.lastPollAt)) return { status: 'connecting', message: `${who} · ${names} · first read…` };
|
||||||
const stale = list.filter(isStale);
|
const stale = list.filter(isStale);
|
||||||
if (stale.length) {
|
if (stale.length) {
|
||||||
@@ -864,7 +791,7 @@ module.exports = {
|
|||||||
return { status: 'connecting', message: `${who} · ${names} · ${ago}` };
|
return { status: 'connecting', message: `${who} · ${names} · ${ago}` };
|
||||||
}
|
}
|
||||||
const cadence = intervalMs > pollMs ? ` · every ${intervalMs / 1000} s (rate budget)` : '';
|
const cadence = intervalMs > pollMs ? ` · every ${intervalMs / 1000} s (rate budget)` : '';
|
||||||
return { status: 'ok', message: `${who} · ${names}${cadence} · ${sending}` };
|
return { status: 'ok', message: `${who} · ${names}${cadence}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
function channelView(ch) {
|
function channelView(ch) {
|
||||||
@@ -899,10 +826,6 @@ module.exports = {
|
|||||||
includeReplies,
|
includeReplies,
|
||||||
historyCount,
|
historyCount,
|
||||||
pollSeconds: intervalMs / 1000,
|
pollSeconds: intervalMs / 1000,
|
||||||
sending: { enabled: sendEnabled, pinSet: pinOk, idleMinutes: idleMs / 60000 },
|
|
||||||
// the pad submits by itself once this many digits are in (never the PIN)
|
|
||||||
pinLength: pinOk ? pin.length : 0,
|
|
||||||
quickEmojis,
|
|
||||||
emojiVersion,
|
emojiVersion,
|
||||||
status: { state: h.status, message: h.message, lastPollAt },
|
status: { state: h.status, message: h.message, lastPollAt },
|
||||||
apiBase: API_BASE,
|
apiBase: API_BASE,
|
||||||
@@ -1135,57 +1058,17 @@ module.exports = {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── unlock tokens ── */
|
|
||||||
function sweepUnlocks() {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [t, u] of unlocks) if (u.expiresAt <= now) unlocks.delete(t);
|
|
||||||
for (const [ip, a] of pinAttempts) if (a.lockedUntil && a.lockedUntil <= now && !a.count) pinAttempts.delete(ip);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The unlock behind a request, or null (the client re-asks for the PIN). */
|
|
||||||
function unlockFor(req) {
|
|
||||||
const t = bearer(req);
|
|
||||||
const u = t ? unlocks.get(t) : null;
|
|
||||||
if (!u) return null;
|
|
||||||
if (u.expiresAt <= Date.now()) {
|
|
||||||
unlocks.delete(t);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { token: t, ...u };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Everything a send needs before Slack is asked; answers the response itself when refusing. */
|
|
||||||
function sendGate(req, res) {
|
|
||||||
if (!sendEnabled) {
|
|
||||||
sendJson(res, 403, { error: 'Sending is off — turn it on in Admin → Slack → Sending', code: 'sending_off' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!pinOk) {
|
|
||||||
sendJson(res, 403, { error: 'Set a PIN in Admin → Slack → Sending', code: 'no_pin' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const u = unlockFor(req);
|
|
||||||
if (!u) {
|
|
||||||
sendJson(res, 401, { error: 'Locked — enter the PIN', code: 'locked' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return u;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The channel a browser asked to post to. Only a configured channel id is
|
* The channel a browser (or the Send Message module) asked to post to.
|
||||||
* accepted — the selection is a choice among the admin's channels, never
|
* Only a configured channel id is accepted — the selection is a choice
|
||||||
* a way to reach some other one. Nothing asked for → the first configured.
|
* among the admin's channels, never a way to reach some other one.
|
||||||
*/
|
*/
|
||||||
function chooseTarget(requested) {
|
function chooseTarget(requested) {
|
||||||
const s = String(requested ?? '').trim();
|
const s = String(requested ?? '').trim();
|
||||||
if (s) {
|
if (!s) return { error: 'Which channel?' };
|
||||||
if (pollers.has(s)) return { id: s };
|
if (pollers.has(s)) return { id: s };
|
||||||
const name = lookupChannel(s);
|
const name = lookupChannel(s);
|
||||||
return { error: `${name ? `#${name}` : s} isn't one of the configured channels — pick it in Admin → Slack → Channels` };
|
return { error: `${name ? `#${name}` : s} isn't one of the configured channels — pick it in Admin → Slack → Channels` };
|
||||||
}
|
|
||||||
if (!channelIds.length) return { error: 'No channel configured — pick channels in Admin → Slack → Channels' };
|
|
||||||
return { id: channelIds[0] };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postMessage(targetChannelId, text) {
|
async function postMessage(targetChannelId, text) {
|
||||||
@@ -1197,17 +1080,30 @@ module.exports = {
|
|||||||
return slackCall(token, 'chat.postMessage', params, { json: true });
|
return slackCall(token, 'chat.postMessage', params, { json: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the Send Message module may send to: the configured channels, as destinations. */
|
||||||
|
function messagingDescriptor() {
|
||||||
|
const h = health();
|
||||||
|
return {
|
||||||
|
label: 'Slack',
|
||||||
|
identity: sendingAs(),
|
||||||
|
destinationRole: 'recipient', // a channel is where a message goes, not who sends it
|
||||||
|
status: { state: h.status, message: h.message },
|
||||||
|
destinations: channelIds.map((id) => ({ id, label: `#${lookupChannel(id) || id}` })),
|
||||||
|
maxChars: MAX_TEXT_CHARS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add or remove a reaction on behalf of an unlock — the same gate as a
|
* Add or remove a reaction on behalf of a verified unlock — the same
|
||||||
* send, since it is the location's name on it. The message's channel must
|
* gate as a send, since it is the location's name on it. The message's
|
||||||
* be one of the configured ones. `already_reacted` / `no_reaction` count
|
* channel must be one of the configured ones. `already_reacted` /
|
||||||
* as done: Slack already holds the state that was asked for.
|
* `no_reaction` count as done: Slack already holds the state asked for.
|
||||||
*/
|
*/
|
||||||
async function react(req, res, unlock, { channel, ts, name, remove }) {
|
async function react(req, res, verified, { channel, ts, name, remove }) {
|
||||||
if (!identity || authError) {
|
if (!identity || authError) {
|
||||||
return sendJson(res, 502, { error: authError || 'Slack token not checked yet — try again in a moment' });
|
return sendJson(res, 502, { error: authError || 'Slack token not checked yet — try again in a moment' });
|
||||||
}
|
}
|
||||||
const target = String(channel ?? '').trim() ? chooseTarget(channel) : { error: 'Which channel?' };
|
const target = chooseTarget(channel);
|
||||||
if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_channel' });
|
if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_channel' });
|
||||||
const stamp = String(ts ?? '').trim();
|
const stamp = String(ts ?? '').trim();
|
||||||
if (!/^\d{1,16}\.\d{1,9}$/.test(stamp)) return sendJson(res, 400, { error: 'Which message?' });
|
if (!/^\d{1,16}\.\d{1,9}$/.test(stamp)) return sendJson(res, 400, { error: 'Which message?' });
|
||||||
@@ -1225,52 +1121,30 @@ module.exports = {
|
|||||||
return sendJson(res, status, { error: message, code: err.code });
|
return sendJson(res, status, { error: message, code: err.code });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const u = unlocks.get(unlock.token);
|
|
||||||
if (u) u.expiresAt = Date.now() + idleMs;
|
|
||||||
log(`${remove ? 'removed' : 'added'} :${code}: on ${stamp} in #${targetName} as ${sendingAs()}`);
|
log(`${remove ? 'removed' : 'added'} :${code}: on ${stamp} in #${targetName} as ${sendingAs()}`);
|
||||||
if (pollers.has(target.id)) pollSoon(pollers.get(target.id));
|
if (pollers.has(target.id)) pollSoon(pollers.get(target.id));
|
||||||
return sendJson(res, 200, { ok: true, expiresAt: u ? u.expiresAt : unlock.expiresAt, channel: `#${targetName}` });
|
return sendJson(res, 200, { ok: true, expiresAt: verified.expiresAt, channel: `#${targetName}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Post to an already-validated configured channel; answers the response. */
|
||||||
* Post on behalf of an unlock; answers the response. `targetId` is an
|
async function deliver(req, res, verified, { text, targetId, what }) {
|
||||||
* already-validated configured channel; `presetChannel` (a quick reply's
|
|
||||||
* own "| #channel") overrides it.
|
|
||||||
*/
|
|
||||||
async function deliver(req, res, unlock, { text, targetId, presetChannel = '', what }) {
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - unlock.lastSendAt < SEND_MIN_INTERVAL_MS) {
|
|
||||||
return sendJson(res, 429, { error: 'One message per second — try again', code: 'too_fast' });
|
|
||||||
}
|
|
||||||
unlocks.get(unlock.token).lastSendAt = now;
|
|
||||||
if (!identity || authError) {
|
if (!identity || authError) {
|
||||||
return sendJson(res, 502, { error: authError || 'Slack token not checked yet — try again in a moment' });
|
return sendJson(res, 502, { error: authError || 'Slack token not checked yet — try again in a moment' });
|
||||||
}
|
}
|
||||||
let target = targetId || '';
|
const targetName = lookupChannel(targetId) || targetId;
|
||||||
if (presetChannel) {
|
|
||||||
try {
|
|
||||||
target = await resolveChannelRef(presetChannel);
|
|
||||||
} catch (err) {
|
|
||||||
return sendJson(res, 502, { error: describeError(err, { name: identity.name, channel: presetChannel }) });
|
|
||||||
}
|
|
||||||
if (!target) return sendJson(res, 400, { error: `No channel named #${String(presetChannel).replace(/^#/, '')} that ${identity.name} can see` });
|
|
||||||
}
|
|
||||||
if (!target) return sendJson(res, 400, { error: 'No channel configured — pick channels in Admin → Slack → Channels' });
|
|
||||||
const targetName = lookupChannel(target) || target;
|
|
||||||
try {
|
try {
|
||||||
const data = await postMessage(target, text);
|
const data = await postMessage(targetId, text);
|
||||||
const u = unlocks.get(unlock.token);
|
|
||||||
if (u) u.expiresAt = Date.now() + idleMs;
|
|
||||||
const preview = text.replace(/\s+/g, ' ').slice(0, 60);
|
const preview = text.replace(/\s+/g, ' ').slice(0, 60);
|
||||||
log(`${what} → #${targetName} as ${sendingAs()}: "${preview}${text.length > 60 ? '…' : ''}"`);
|
log(`${what} → #${targetName} as ${sendingAs()}: "${preview}${text.length > 60 ? '…' : ''}"`);
|
||||||
if (pollers.has(target)) pollSoon(pollers.get(target));
|
if (pollers.has(targetId)) pollSoon(pollers.get(targetId));
|
||||||
return sendJson(res, 200, {
|
return sendJson(res, 200, {
|
||||||
ok: true,
|
ok: true,
|
||||||
ts: String(data.ts || ''),
|
ts: String(data.ts || ''),
|
||||||
|
label: `#${targetName}`,
|
||||||
channel: `#${targetName}`,
|
channel: `#${targetName}`,
|
||||||
channelId: target,
|
channelId: targetId,
|
||||||
sendingAs: sendingAs(),
|
sendingAs: sendingAs(),
|
||||||
expiresAt: u ? u.expiresAt : unlock.expiresAt,
|
expiresAt: verified.expiresAt,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = describeError(err, { name: sendingAs() || identity.name, channel: targetName });
|
const message = describeError(err, { name: sendingAs() || identity.name, channel: targetName });
|
||||||
@@ -1290,12 +1164,9 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
}, HEARTBEAT_MS);
|
}, HEARTBEAT_MS);
|
||||||
heartbeat.unref?.();
|
heartbeat.unref?.();
|
||||||
const sweeper = setInterval(sweepUnlocks, UNLOCK_SWEEP_MS);
|
|
||||||
sweeper.unref?.();
|
|
||||||
|
|
||||||
if (!token) log('no Slack token configured — paste one in /admin');
|
if (!token) log('no Slack token configured — paste one in /admin');
|
||||||
else {
|
else {
|
||||||
if (sendEnabled && !pinOk) log('sending is on but no valid PIN (4–8 digits) is set — send tiles stay locked');
|
|
||||||
if (!channelIds.length) log('no channels configured — pick some in /admin → Slack → Channels');
|
if (!channelIds.length) log('no channels configured — pick some in /admin → Slack → Channels');
|
||||||
else {
|
else {
|
||||||
if (!Array.isArray(config.channels) || !config.channels.length) {
|
if (!Array.isArray(config.channels) || !config.channels.length) {
|
||||||
@@ -1311,8 +1182,8 @@ module.exports = {
|
|||||||
streams,
|
streams,
|
||||||
snapshot,
|
snapshot,
|
||||||
health,
|
health,
|
||||||
quickReplies,
|
shellPort,
|
||||||
sendGate,
|
messagingDescriptor,
|
||||||
deliver,
|
deliver,
|
||||||
react,
|
react,
|
||||||
/** Everything a tile needs to draw emoji: the shipped table plus the workspace's own. */
|
/** Everything a tile needs to draw emoji: the shipped table plus the workspace's own. */
|
||||||
@@ -1331,7 +1202,6 @@ module.exports = {
|
|||||||
if (kind === 'b') return botIcons.get(id) || '';
|
if (kind === 'b') return botIcons.get(id) || '';
|
||||||
return '';
|
return '';
|
||||||
},
|
},
|
||||||
unlockFor,
|
|
||||||
chooseTarget,
|
chooseTarget,
|
||||||
namesReady,
|
namesReady,
|
||||||
touch() { lastInterest = Date.now(); },
|
touch() { lastInterest = Date.now(); },
|
||||||
@@ -1347,44 +1217,8 @@ module.exports = {
|
|||||||
loadChannelList,
|
loadChannelList,
|
||||||
channelIds,
|
channelIds,
|
||||||
channelName: (id) => lookupChannel(id),
|
channelName: (id) => lookupChannel(id),
|
||||||
pinOk,
|
|
||||||
sendEnabled,
|
|
||||||
identityName: () => identity?.name || '',
|
identityName: () => identity?.name || '',
|
||||||
sendingAs,
|
sendingAs,
|
||||||
unlock(req, res, given) {
|
|
||||||
if (!sendEnabled) return sendJson(res, 403, { error: 'Sending is off — turn it on in Admin → Slack → Sending', code: 'sending_off' });
|
|
||||||
if (!pinOk) return sendJson(res, 403, { error: 'Set a PIN in Admin → Slack → Sending', code: 'no_pin' });
|
|
||||||
const ip = clientIp(req);
|
|
||||||
const now = Date.now();
|
|
||||||
let a = pinAttempts.get(ip);
|
|
||||||
if (a && a.lockedUntil > now) {
|
|
||||||
const secs = Math.ceil((a.lockedUntil - now) / 1000);
|
|
||||||
return sendJson(res, 429, { error: `Too many wrong PINs — locked for ${secs} s`, code: 'lockout', retryAfter: secs });
|
|
||||||
}
|
|
||||||
if (a && a.lockedUntil && a.lockedUntil <= now) a = null; // lockout served — clean slate
|
|
||||||
const str = String(given ?? '');
|
|
||||||
if (!PIN_RE.test(str) || !pinMatches(str, pin)) {
|
|
||||||
const count = (a ? a.count : 0) + 1;
|
|
||||||
if (count >= PIN_MAX_WRONG) {
|
|
||||||
pinAttempts.set(ip, { count: 0, lockedUntil: now + PIN_LOCKOUT_MS });
|
|
||||||
log(`wrong PIN ×${PIN_MAX_WRONG} from ${ip} — locked for ${PIN_LOCKOUT_MS / 1000} s`);
|
|
||||||
return sendJson(res, 429, { error: `Wrong PIN — locked for ${PIN_LOCKOUT_MS / 1000} s`, code: 'lockout', retryAfter: PIN_LOCKOUT_MS / 1000 });
|
|
||||||
}
|
|
||||||
pinAttempts.set(ip, { count, lockedUntil: 0 });
|
|
||||||
return sendJson(res, 401, { error: 'Wrong PIN', code: 'wrong_pin', triesLeft: PIN_MAX_WRONG - count });
|
|
||||||
}
|
|
||||||
pinAttempts.delete(ip);
|
|
||||||
const t = crypto.randomBytes(32).toString('hex');
|
|
||||||
const expiresAt = now + idleMs;
|
|
||||||
unlocks.set(t, { expiresAt, lastSendAt: 0, ip });
|
|
||||||
log(`unlocked from ${ip} (auto-lock after ${idleMs / 60000} min idle)`);
|
|
||||||
return sendJson(res, 200, { token: t, expiresAt, idleMs, sendingAs: sendingAs() });
|
|
||||||
},
|
|
||||||
lock(req, res) {
|
|
||||||
const t = bearer(req);
|
|
||||||
if (t) unlocks.delete(t);
|
|
||||||
return sendJson(res, 200, { ok: true });
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1398,8 +1232,6 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
clearInterval(sweeper);
|
|
||||||
unlocks.clear(); // an admin Apply locks every send tile
|
|
||||||
for (const res of streams) {
|
for (const res of streams) {
|
||||||
try { res.end(); } catch { /* already gone */ }
|
try { res.end(); } catch { /* already gone */ }
|
||||||
}
|
}
|
||||||
@@ -1412,16 +1244,17 @@ module.exports = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The picker: one transcript per configured channel, titled with the
|
* The picker: one transcript per configured channel, titled with the
|
||||||
* channel's name (the shell makes the entry's name the tile's title), then
|
* channel's name (the shell makes the entry's name the tile's title).
|
||||||
* the two send surfaces, one of each per dashboard. Between init() and a
|
* Between init() and a poller's first answer a name may still be unknown
|
||||||
* poller's first answer a name may still be unknown — "#<id>" then.
|
* — "#<id>" then. The send surfaces that used to follow here are the Send
|
||||||
|
* Message module's since 1.3.0; a layout's old Send message / Quick
|
||||||
|
* replies tiles draw nothing until they are removed.
|
||||||
*/
|
*/
|
||||||
async tiles({ config }) {
|
async tiles({ config }) {
|
||||||
const c = current;
|
const c = current;
|
||||||
const ids = c ? c.channelIds : configuredChannels(config || {});
|
const ids = c ? c.channelIds : configuredChannels(config || {});
|
||||||
if (c) await c.namesReady();
|
if (c) await c.namesReady();
|
||||||
const who = c?.sendingAs() ? ` as ${c.sendingAs()}` : '';
|
return ids.map((id) => ({
|
||||||
const entries = ids.map((id) => ({
|
|
||||||
id: `transcript:${id}`,
|
id: `transcript:${id}`,
|
||||||
name: `#${(c && c.channelName(id)) || id}`,
|
name: `#${(c && c.channelName(id)) || id}`,
|
||||||
description: 'Live transcript',
|
description: 'Live transcript',
|
||||||
@@ -1429,27 +1262,6 @@ module.exports = {
|
|||||||
minSize: { w: 2, h: 2 },
|
minSize: { w: 2, h: 2 },
|
||||||
settings: { view: 'transcript', channel: id },
|
settings: { view: 'transcript', channel: id },
|
||||||
}));
|
}));
|
||||||
entries.push(
|
|
||||||
{
|
|
||||||
id: 'send',
|
|
||||||
name: 'Send message',
|
|
||||||
description: `PIN-protected message to the transcript clicked last${who}`,
|
|
||||||
single: true,
|
|
||||||
defaultSize: { w: 3, h: 3 },
|
|
||||||
minSize: { w: 2, h: 2 },
|
|
||||||
settings: { view: 'send' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'quick',
|
|
||||||
name: 'Quick replies',
|
|
||||||
description: `PIN-protected preset buttons${who}`,
|
|
||||||
single: true,
|
|
||||||
defaultSize: { w: 3, h: 2 },
|
|
||||||
minSize: { w: 2, h: 1 },
|
|
||||||
settings: { view: 'quick' },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return entries;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
routes() {
|
routes() {
|
||||||
@@ -1498,22 +1310,6 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
'POST /unlock': guard(async (req, res) => {
|
|
||||||
if (!current) return unmounted(res);
|
|
||||||
let body;
|
|
||||||
try {
|
|
||||||
body = await readJson(req);
|
|
||||||
} catch (err) {
|
|
||||||
return sendJson(res, 400, { error: err.message });
|
|
||||||
}
|
|
||||||
current.unlock(req, res, body.pin);
|
|
||||||
}),
|
|
||||||
|
|
||||||
'POST /lock': (req, res) => {
|
|
||||||
if (!current) return unmounted(res);
|
|
||||||
current.lock(req, res);
|
|
||||||
},
|
|
||||||
|
|
||||||
// The emoji a tile may need to draw — fetched once per page, again when the version bumps.
|
// The emoji a tile may need to draw — fetched once per page, again when the version bumps.
|
||||||
'GET /emoji': (req, res) => {
|
'GET /emoji': (req, res) => {
|
||||||
if (!current) return unmounted(res);
|
if (!current) return unmounted(res);
|
||||||
@@ -1530,35 +1326,19 @@ module.exports = {
|
|||||||
await serveImage(res, current.avatarUrl(decodeURIComponent(String(req.wildcard || '').replace(/^\/+/, ''))));
|
await serveImage(res, current.avatarUrl(decodeURIComponent(String(req.wildcard || '').replace(/^\/+/, ''))));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// A reaction as the location — PIN-gated like a send.
|
// The Send Message module reads this over loopback: where it may send.
|
||||||
'POST /react': guard(async (req, res) => {
|
'GET /messaging': (req, res) => {
|
||||||
if (!current) return unmounted(res);
|
if (!current) return unmounted(res);
|
||||||
const unlock = current.sendGate(req, res);
|
sendJson(res, 200, current.messagingDescriptor());
|
||||||
if (!unlock) return;
|
|
||||||
let body;
|
|
||||||
try {
|
|
||||||
body = await readJson(req);
|
|
||||||
} catch (err) {
|
|
||||||
return sendJson(res, 400, { error: err.message });
|
|
||||||
}
|
|
||||||
await current.react(req, res, unlock, { channel: body.channel, ts: body.ts, name: body.name, remove: Boolean(body.remove) });
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Labels and indexes only — the message text never leaves the server.
|
|
||||||
'GET /quick': (req, res) => {
|
|
||||||
if (!current) return unmounted(res);
|
|
||||||
const unlock = current.sendGate(req, res);
|
|
||||||
if (!unlock) return;
|
|
||||||
sendJson(res, 200, {
|
|
||||||
presets: current.quickReplies.map((q, index) => ({ index, label: q.label, channel: q.channel ? `#${q.channel}` : '' })),
|
|
||||||
expiresAt: unlock.expiresAt,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
'POST /send': guard(async (req, res) => {
|
// …and hands a message here, with the browser's unlock token. The token
|
||||||
|
// is checked with the Send Message module first: no PIN entered there,
|
||||||
|
// nothing is posted.
|
||||||
|
'POST /messaging/send': guard(async (req, res) => {
|
||||||
if (!current) return unmounted(res);
|
if (!current) return unmounted(res);
|
||||||
const unlock = current.sendGate(req, res);
|
const v = await verifyUnlock(req, current.shellPort);
|
||||||
if (!unlock) return;
|
if (!v.ok) return sendJson(res, v.status, { error: v.error, code: v.code });
|
||||||
let body;
|
let body;
|
||||||
try {
|
try {
|
||||||
body = await readJson(req);
|
body = await readJson(req);
|
||||||
@@ -1568,34 +1348,23 @@ module.exports = {
|
|||||||
const text = String(body.text ?? '').replace(/\r\n/g, '\n').trim();
|
const text = String(body.text ?? '').replace(/\r\n/g, '\n').trim();
|
||||||
if (!text) return sendJson(res, 400, { error: 'Nothing to send' });
|
if (!text) return sendJson(res, 400, { error: 'Nothing to send' });
|
||||||
if (text.length > MAX_TEXT_CHARS) return sendJson(res, 400, { error: `Too long — ${MAX_TEXT_CHARS} characters at most` });
|
if (text.length > MAX_TEXT_CHARS) return sendJson(res, 400, { error: `Too long — ${MAX_TEXT_CHARS} characters at most` });
|
||||||
const target = current.chooseTarget(body.channel);
|
const target = current.chooseTarget(body.destination ?? body.channel);
|
||||||
if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_channel' });
|
if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_destination' });
|
||||||
await current.deliver(req, res, unlock, { text, targetId: target.id, what: 'sent' });
|
await current.deliver(req, res, v, { text, targetId: target.id, what: 'sent' });
|
||||||
}),
|
}),
|
||||||
|
|
||||||
'POST /quick': guard(async (req, res) => {
|
// A reaction as the location — the same unlock as a send.
|
||||||
|
'POST /react': guard(async (req, res) => {
|
||||||
if (!current) return unmounted(res);
|
if (!current) return unmounted(res);
|
||||||
const unlock = current.sendGate(req, res);
|
const v = await verifyUnlock(req, current.shellPort);
|
||||||
if (!unlock) return;
|
if (!v.ok) return sendJson(res, v.status, { error: v.error, code: v.code });
|
||||||
let body;
|
let body;
|
||||||
try {
|
try {
|
||||||
body = await readJson(req);
|
body = await readJson(req);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return sendJson(res, 400, { error: err.message });
|
return sendJson(res, 400, { error: err.message });
|
||||||
}
|
}
|
||||||
const index = Math.trunc(Number(body.index));
|
await current.react(req, res, v, { channel: body.channel, ts: body.ts, name: body.name, remove: Boolean(body.remove) });
|
||||||
const preset = Number.isInteger(index) ? current.quickReplies[index] : null;
|
|
||||||
if (!preset) return sendJson(res, 404, { error: 'No such quick reply — the list may have changed in Admin' });
|
|
||||||
// A channel the browser names is checked even when the preset's own
|
|
||||||
// binding is about to override it — an unconfigured id is never accepted.
|
|
||||||
const target = current.chooseTarget(body.channel);
|
|
||||||
if (target.error && (body.channel || !preset.channel)) return sendJson(res, 400, { error: target.error, code: 'bad_channel' });
|
|
||||||
await current.deliver(req, res, unlock, {
|
|
||||||
text: preset.message,
|
|
||||||
targetId: target.id || '',
|
|
||||||
presetChannel: preset.channel,
|
|
||||||
what: `quick reply "${preset.label}"`,
|
|
||||||
});
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
+18
-308
@@ -1,7 +1,8 @@
|
|||||||
/* Slack module — scoped to its tiles, shell variables only.
|
/* Slack module — scoped to its tiles, shell variables only.
|
||||||
--sl-size (per tile, from the header's A− / A+) drives every font size.
|
--sl-size (per tile, from the header's A− / A+) drives every font size.
|
||||||
Transcript rows follow the ProdCom transcript's layout: a square picture,
|
Transcript rows follow the ProdCom transcript's layout: a square picture,
|
||||||
then a name line with the time, the text, the reactions. */
|
then a name line with the time, the text, the reactions. The send tiles
|
||||||
|
and the PIN pad moved to the Send Message module in 1.3.0. */
|
||||||
|
|
||||||
[data-module="slack"] .sl-wrap {
|
[data-module="slack"] .sl-wrap {
|
||||||
--sl-size: 14px;
|
--sl-size: 14px;
|
||||||
@@ -34,8 +35,8 @@
|
|||||||
|
|
||||||
/* ── the send target ── */
|
/* ── the send target ── */
|
||||||
|
|
||||||
/* A click anywhere in a transcript's body makes its channel the one Send
|
/* A click anywhere in a transcript's body makes its channel the one the
|
||||||
message and Quick replies post to. The chosen one carries a 3px accent
|
Send Message module's tiles post to. The chosen one carries a 3px accent
|
||||||
stripe along the bottom edge of the tile body — drawn just inside our
|
stripe along the bottom edge of the tile body — drawn just inside our
|
||||||
root, since the tile chrome around it belongs to the shell. The others
|
root, since the tile chrome around it belongs to the shell. The others
|
||||||
hint at it faintly on hover. */
|
hint at it faintly on hover. */
|
||||||
@@ -279,307 +280,6 @@
|
|||||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── send / quick replies: the gate ── */
|
|
||||||
|
|
||||||
[data-module="slack"] .sl-gate,
|
|
||||||
[data-module="slack"] .sl-compose {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 6px 10px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="slack"] .sl-note {
|
|
||||||
margin: auto;
|
|
||||||
padding: 10px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.9em;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
[data-module="slack"] .sl-note.is-error { color: var(--danger); }
|
|
||||||
|
|
||||||
/* The pad never shrinks below its content (min-height stays `auto` on it
|
|
||||||
and on the key grid) — a shrunken grid would spill its rows under the
|
|
||||||
message line, which then swallows the taps. A tile too short for it
|
|
||||||
scrolls instead (.sl-gate has overflow: auto). */
|
|
||||||
[data-module="slack"] .sl-pad {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
max-width: 340px;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
[data-module="slack"] .sl-pad.sl-shake { animation: sl-shake 0.4s ease; }
|
|
||||||
@keyframes sl-shake {
|
|
||||||
10%, 90% { transform: translateX(-2px); }
|
|
||||||
20%, 80% { transform: translateX(3px); }
|
|
||||||
30%, 50%, 70% { transform: translateX(-5px); }
|
|
||||||
40%, 60% { transform: translateX(5px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* one line: "Enter PIN · sending as Apollo Beach → #production" */
|
|
||||||
[data-module="slack"] .sl-pad-head {
|
|
||||||
flex: none;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.8em;
|
|
||||||
color: var(--muted);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
[data-module="slack"] .sl-pad-head b { font-weight: 700; color: var(--text); }
|
|
||||||
|
|
||||||
[data-module="slack"] .sl-dots {
|
|
||||||
flex: none;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
height: 14px;
|
|
||||||
}
|
|
||||||
[data-module="slack"] .sl-dot {
|
|
||||||
width: 11px;
|
|
||||||
height: 11px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 2px solid var(--muted);
|
|
||||||
transition: background 0.12s, border-color 0.12s;
|
|
||||||
}
|
|
||||||
[data-module="slack"] .sl-dot.on { background: var(--accent); border-color: var(--accent); }
|
|
||||||
|
|
||||||
[data-module="slack"] .sl-keys {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
grid-template-rows: repeat(4, minmax(24px, 1fr));
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
[data-module="slack"] .sl-key {
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 24px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 10px;
|
|
||||||
background: color-mix(in srgb, var(--text) 5%, var(--panel));
|
|
||||||
color: var(--text);
|
|
||||||
font: inherit;
|
|
||||||
font-size: clamp(13px, 9cqh, 26px);
|
|
||||||
font-weight: 600;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
touch-action: manipulation;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
[data-module="slack"] .sl-key:hover { background: var(--border); }
|
|
||||||
[data-module="slack"] .sl-key:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); }
|
|
||||||
[data-module="slack"] .sl-key svg { width: 1.1em; height: 1.1em; }
|
|
||||||
[data-module="slack"] .sl-key-back { color: var(--muted); }
|
|
||||||
[data-module="slack"] .sl-key-blank { visibility: hidden; }
|
|
||||||
[data-module="slack"] .sl-pad.is-busy .sl-keys { opacity: 0.6; pointer-events: none; }
|
|
||||||
|
|
||||||
[data-module="slack"] .sl-pad-msg {
|
|
||||||
flex: none;
|
|
||||||
min-height: 1.1em;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.78em;
|
|
||||||
line-height: 1.1;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
[data-module="slack"] .sl-pad-msg.is-error { color: var(--danger); font-weight: 600; }
|
|
||||||
|
|
||||||
/* A tile too short for the keypad (the smallest Send message / Quick replies
|
|
||||||
tiles) shows the dots and a hint instead — digits typed on the keyboard
|
|
||||||
still land, and a taller tile brings the keys back. .sl-wrap is the size
|
|
||||||
container the height is measured against. */
|
|
||||||
[data-module="slack"] .sl-pad-hint {
|
|
||||||
display: none;
|
|
||||||
flex: none;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.78em;
|
|
||||||
line-height: 1.3;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
@container (max-height: 176px) {
|
|
||||||
[data-module="slack"] .sl-pad .sl-keys { display: none; }
|
|
||||||
[data-module="slack"] .sl-pad .sl-pad-hint { display: block; }
|
|
||||||
[data-module="slack"] .sl-pad { justify-content: center; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── send / quick replies: unlocked ── */
|
|
||||||
|
|
||||||
[data-module="slack"] .sl-compose { gap: 6px; }
|
|
||||||
|
|
||||||
[data-module="slack"] .sl-compose-head {
|
|
||||||
flex: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 0.8em;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
[data-module="slack"] .sl-sending {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
[data-module="slack"] .sl-lock-link {
|
|
||||||
flex: none;
|
|
||||||
padding: 1px 8px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: none;
|
|
||||||
color: var(--muted);
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.9em;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
[data-module="slack"] .sl-lock-link:hover { color: var(--text); background: var(--border); }
|
|
||||||
|
|
||||||
[data-module="slack"] .sl-input {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 2.6em;
|
|
||||||
width: 100%;
|
|
||||||
resize: none;
|
|
||||||
padding: 7px 9px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font: inherit;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
[data-module="slack"] .sl-input:focus { outline: none; border-color: var(--accent); }
|
|
||||||
|
|
||||||
[data-module="slack"] .sl-compose-foot {
|
|
||||||
flex: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
min-height: 1.6em;
|
|
||||||
}
|
|
||||||
[data-module="slack"] .sl-msg {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
font-size: 0.8em;
|
|
||||||
color: var(--muted);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
[data-module="slack"] .sl-msg.is-flash { color: var(--accent); font-weight: 700; }
|
|
||||||
[data-module="slack"] .sl-msg.is-error { color: var(--danger); white-space: normal; font-weight: 600; }
|
|
||||||
[data-module="slack"] .sl-countdown {
|
|
||||||
flex: none;
|
|
||||||
font-size: 0.75em;
|
|
||||||
color: var(--warn);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
[data-module="slack"] .sl-sendbtn {
|
|
||||||
flex: none;
|
|
||||||
padding: 6px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--accent-text);
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
[data-module="slack"] .sl-sendbtn:hover { background: color-mix(in srgb, var(--accent) 85%, var(--text)); }
|
|
||||||
[data-module="slack"] .sl-sendbtn:disabled { opacity: 0.6; cursor: default; }
|
|
||||||
|
|
||||||
/* quick emojis: one press each sends it as a message of its own */
|
|
||||||
[data-module="slack"] .sl-emoji-sendrow {
|
|
||||||
flex: none;
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
[data-module="slack"] .sl-emoji-send {
|
|
||||||
flex: none;
|
|
||||||
width: 2.1em;
|
|
||||||
height: 2.1em;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: color-mix(in srgb, var(--text) 5%, var(--panel));
|
|
||||||
color: var(--text);
|
|
||||||
font: inherit;
|
|
||||||
font-size: 1.1em;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
touch-action: manipulation;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
transition: background 0.1s, border-color 0.1s;
|
|
||||||
}
|
|
||||||
[data-module="slack"] .sl-emoji-send:hover { border-color: var(--accent); background: var(--border); }
|
|
||||||
[data-module="slack"] .sl-emoji-send:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); }
|
|
||||||
[data-module="slack"] .sl-emoji-send.is-busy { opacity: 0.6; }
|
|
||||||
[data-module="slack"] .sl-emoji-send.is-sent { background: var(--accent); border-color: transparent; }
|
|
||||||
[data-module="slack"] .sl-emoji-send .sl-emoji { width: 1.2em; height: 1.2em; vertical-align: middle; }
|
|
||||||
|
|
||||||
/* the preset grid: big targets, labels wrap */
|
|
||||||
[data-module="slack"] .sl-grid {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(min(150px, 100%), 1fr));
|
|
||||||
grid-auto-rows: minmax(44px, auto);
|
|
||||||
gap: 6px;
|
|
||||||
align-content: start;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
[data-module="slack"] .sl-grid .sl-note { grid-column: 1 / -1; }
|
|
||||||
[data-module="slack"] .sl-quick {
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 44px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 10px;
|
|
||||||
background: color-mix(in srgb, var(--accent) 12%, var(--panel));
|
|
||||||
color: var(--text);
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.25;
|
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
touch-action: manipulation;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
[data-module="slack"] .sl-quick:hover { background: color-mix(in srgb, var(--accent) 22%, var(--panel)); }
|
|
||||||
[data-module="slack"] .sl-quick:active { background: color-mix(in srgb, var(--accent) 40%, var(--panel)); }
|
|
||||||
[data-module="slack"] .sl-quick.is-busy { opacity: 0.7; }
|
|
||||||
[data-module="slack"] .sl-quick.is-sent {
|
|
||||||
background: var(--accent);
|
|
||||||
border-color: transparent;
|
|
||||||
color: var(--accent-text);
|
|
||||||
}
|
|
||||||
[data-module="slack"] .sl-quick-label { overflow-wrap: anywhere; }
|
|
||||||
[data-module="slack"] .sl-quick-chan {
|
|
||||||
font-size: 0.72em;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
[data-module="slack"] .sl-quick.is-sent .sl-quick-chan { color: inherit; }
|
|
||||||
|
|
||||||
/* ── the reaction menu (on document.body, so nothing clips it) ── */
|
/* ── the reaction menu (on document.body, so nothing clips it) ── */
|
||||||
|
|
||||||
[data-module="slack"].sl-emoji-menu {
|
[data-module="slack"].sl-emoji-menu {
|
||||||
@@ -671,7 +371,17 @@
|
|||||||
[data-module="slack"] .sl-emoji-msg:empty { display: none; }
|
[data-module="slack"] .sl-emoji-msg:empty { display: none; }
|
||||||
[data-module="slack"] .sl-emoji-msg.is-error { color: var(--danger); font-weight: 600; }
|
[data-module="slack"] .sl-emoji-msg.is-error { color: var(--danger); font-weight: 600; }
|
||||||
|
|
||||||
/* the PIN pad inside the menu: fixed sizes, no tile to scale with */
|
/* locked: the PIN is entered in the Send Message module's tile, not here */
|
||||||
[data-module="slack"] .sl-emoji-menu .sl-pad { max-width: 100%; gap: 6px; }
|
[data-module="slack"] .sl-emoji-locked {
|
||||||
[data-module="slack"] .sl-emoji-menu .sl-keys { grid-template-rows: repeat(4, 40px); }
|
padding: 4px 2px 2px;
|
||||||
[data-module="slack"] .sl-emoji-menu .sl-key { font-size: 17px; }
|
font-size: 0.86em;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
[data-module="slack"] .sl-emoji-locked b { color: var(--text); font-weight: 700; }
|
||||||
|
[data-module="slack"] .sl-emoji-reason {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--warn);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "spl-meter",
|
"id": "spl-meter",
|
||||||
"name": "SPL Meter",
|
"name": "SPL Meter",
|
||||||
"version": "0.4.3",
|
"version": "0.5.0",
|
||||||
"proddash": ">=1.5.0",
|
"proddash": ">=1.5.0",
|
||||||
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
|||||||
Executable
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
# Build this module's native helper for every macOS architecture it ships —
|
||||||
|
# ../bin/spl-capture-macos-arm64 and ../bin/spl-capture-macos-x86_64 —
|
||||||
|
# from spl-capture.swift beside this script. Run it on any Mac with the
|
||||||
|
# Xcode command line tools; both architectures cross-compile from either.
|
||||||
|
# The binaries are committed: a machine running ProdDash never builds
|
||||||
|
# anything (docs/MODULE-GUIDE.md, "Native helpers").
|
||||||
|
#
|
||||||
|
# Signed with a Developer ID Application certificate when one is installed
|
||||||
|
# (override with CODESIGN_IDENTITY) — hardened runtime plus the audio-input
|
||||||
|
# entitlement, which a helper that reads a microphone needs under the
|
||||||
|
# hardened runtime — and ad-hoc otherwise, which is fine on your own Macs.
|
||||||
|
#
|
||||||
|
# Windows is not built here: there is no Windows implementation of this
|
||||||
|
# tool yet, so on Windows the module reports measurement off.
|
||||||
|
set -e -u
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
NAME="spl-capture"
|
||||||
|
DEPLOYMENT_TARGET="13.0"
|
||||||
|
|
||||||
|
command -v swiftc >/dev/null || {
|
||||||
|
echo "build.sh: swiftc not found — install the Xcode command line tools: xcode-select --install" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
IDENTITY="${CODESIGN_IDENTITY:-}"
|
||||||
|
if [[ -z "$IDENTITY" ]]; then
|
||||||
|
IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \
|
||||||
|
| awk -F'"' '/Developer ID Application/ {print $2; exit}')
|
||||||
|
fi
|
||||||
|
if [[ -n "$IDENTITY" ]]; then
|
||||||
|
echo "signing as $IDENTITY"
|
||||||
|
SIGN=(--force --sign "$IDENTITY" --options runtime --timestamp)
|
||||||
|
else
|
||||||
|
echo "signing ad-hoc (no Developer ID installed — fine on your own Macs)"
|
||||||
|
SIGN=(--force --sign - --options runtime)
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p ../bin
|
||||||
|
for ARCH in arm64 x86_64; do
|
||||||
|
OUT="../bin/$NAME-macos-$ARCH"
|
||||||
|
echo " compiling $OUT"
|
||||||
|
swiftc -O -whole-module-optimization -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$OUT" "$NAME.swift"
|
||||||
|
codesign "${SIGN[@]}" --entitlements helper.entitlements "$OUT"
|
||||||
|
codesign --verify --strict "$OUT"
|
||||||
|
echo " built $OUT ($(du -h "$OUT" | awk '{print $1}'))"
|
||||||
|
done
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- The helper reads an audio input; under the hardened runtime that
|
||||||
|
needs saying. The permission itself comes from the launcher. -->
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -8,10 +8,11 @@
|
|||||||
// measurement is done here and only the answers cross the pipe.
|
// measurement is done here and only the answers cross the pipe.
|
||||||
//
|
//
|
||||||
// Everything below uses Accelerate, which ships with macOS. No third-party
|
// Everything below uses Accelerate, which ships with macOS. No third-party
|
||||||
// code, no package manager, ~2 s to build:
|
// code, no package manager. It ships prebuilt in the module's bin/ (one
|
||||||
|
// binary per platform, committed — a booth never builds anything). To
|
||||||
|
// rebuild after a change here, on any Mac with the Xcode command line tools:
|
||||||
//
|
//
|
||||||
// swiftc -O -o modules/spl-meter/spl-capture \
|
// modules/spl-meter/native/build.sh (both architectures)
|
||||||
// modules/spl-meter/spl-capture.swift
|
|
||||||
//
|
//
|
||||||
// spl-capture --list
|
// spl-capture --list
|
||||||
// every input-capable device: name, input channels, sample rate
|
// every input-capable device: name, input channels, sample rate
|
||||||
+10
-20
@@ -20,7 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { createSplListener, ensureCaptureBinary } = require('./spl-listener');
|
const { createSplListener, captureTool } = require('./spl-listener');
|
||||||
|
|
||||||
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
|
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
|
||||||
const CALIBRATOR_DB = 94;
|
const CALIBRATOR_DB = 94;
|
||||||
@@ -120,12 +120,9 @@ module.exports = {
|
|||||||
pinger: null,
|
pinger: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Before anything else: the device picker shells out to this tool, so a
|
// The capture tool ships prebuilt in the module (bin/); with none for
|
||||||
// machine that installed the module from the catalog (source only — the
|
// this platform every reading is off and the health line says so.
|
||||||
// binary is gitignored) needs the build kicked off here. Waiting for a
|
state.toolMissing = captureTool(moduleDir).missing;
|
||||||
// listener would never happen: no tool means no device list, and no
|
|
||||||
// device means no listener.
|
|
||||||
state.toolMissing = ensureCaptureBinary({ moduleDir, log });
|
|
||||||
|
|
||||||
if (!config.enabled) {
|
if (!config.enabled) {
|
||||||
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
|
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
|
||||||
@@ -185,9 +182,7 @@ module.exports = {
|
|||||||
return {
|
return {
|
||||||
stop() { teardown(); },
|
stop() { teardown(); },
|
||||||
health() {
|
health() {
|
||||||
if (state.toolMissing && !fsExists(path.join(moduleDir, 'spl-capture'))) {
|
if (state.toolMissing) return { status: 'error', message: state.toolMissing };
|
||||||
return { status: 'error', message: state.toolMissing };
|
|
||||||
}
|
|
||||||
if (state.error) return { status: 'error', message: state.error };
|
if (state.error) return { status: 'error', message: state.error };
|
||||||
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
|
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
|
||||||
const quiet = silentMs();
|
const quiet = silentMs();
|
||||||
@@ -311,11 +306,10 @@ module.exports = {
|
|||||||
/** Device list for the admin page. Runs the capture tool's --list,
|
/** Device list for the admin page. Runs the capture tool's --list,
|
||||||
which is the only thing that knows what CoreAudio can see. */
|
which is the only thing that knows what CoreAudio can see. */
|
||||||
'GET /devices': async (req, res) => {
|
'GET /devices': async (req, res) => {
|
||||||
const missing = ensureCaptureBinary({ moduleDir: __dirname, log });
|
const tool = captureTool(__dirname);
|
||||||
if (missing) {
|
if (tool.missing) {
|
||||||
if (state) state.toolMissing = missing;
|
log(`device list unavailable — ${tool.missing}`);
|
||||||
log(`device list unavailable — ${missing}`);
|
json(res, 200, { options: [], error: tool.missing });
|
||||||
json(res, 200, { options: [] });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -447,7 +441,7 @@ function teardown() {
|
|||||||
function listDevices() {
|
function listDevices() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const { execFile } = require('child_process');
|
const { execFile } = require('child_process');
|
||||||
execFile(path.join(__dirname, 'spl-capture'), ['--list'], { timeout: 10000 },
|
execFile(captureTool(__dirname).bin, ['--list'], { timeout: 10000 },
|
||||||
(err, stdout) => {
|
(err, stdout) => {
|
||||||
if (err && !stdout) { reject(err); return; }
|
if (err && !stdout) { reject(err); return; }
|
||||||
try { resolve(JSON.parse(stdout).devices || []); }
|
try { resolve(JSON.parse(stdout).devices || []); }
|
||||||
@@ -456,10 +450,6 @@ function listDevices() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function fsExists(p) {
|
|
||||||
try { return require('fs').existsSync(p); } catch { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function json(res, code, body) {
|
function json(res, code, body) {
|
||||||
const text = JSON.stringify(body);
|
const text = JSON.stringify(body);
|
||||||
res.writeHead(code, {
|
res.writeHead(code, {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
/**
|
/**
|
||||||
* Sound level listener — owns the native capture child.
|
* Sound level listener — owns the native capture child.
|
||||||
*
|
*
|
||||||
* Spawns spl-capture (compiled from spl-capture.swift beside it) for the
|
* Spawns spl-capture (bin/spl-capture-macos-<arch>, shipped prebuilt;
|
||||||
|
* source in native/) for the
|
||||||
* admin-configured device and channels, reads its JSON lines, and hands the
|
* admin-configured device and channels, reads its JSON lines, and hands the
|
||||||
* latest frame upward. All measurement happens in the child; this file is
|
* latest frame upward. All measurement happens in the child; this file is
|
||||||
* plumbing and supervision, nothing more.
|
* plumbing and supervision, nothing more.
|
||||||
@@ -13,12 +14,11 @@
|
|||||||
* { frame: {...}, meta: {...} } measuring
|
* { frame: {...}, meta: {...} } measuring
|
||||||
* { error: '<what is wrong and how to fix it>' } not measuring
|
* { error: '<what is wrong and how to fix it>' } not measuring
|
||||||
*
|
*
|
||||||
* The capture binary is per-machine and gitignored, exactly like the Timers
|
* The capture tool ships prebuilt inside the module, one binary per
|
||||||
* module's ltc-capture. It normally arrives already built — compiled beside
|
* platform under bin/ (module guide, "Native helpers"), exactly like the
|
||||||
* the app, or copied from any Mac of the same architecture. When it is
|
* Timers module's ltc-capture: nothing is ever built on the machine running
|
||||||
* missing this will build it, but in the background and never on the event
|
* ProdDash. captureTool() picks the binary for this platform; when there is
|
||||||
* loop, and from module scope so the device picker can trigger it too — see
|
* none, measurement reports itself off and the rest of the module carries on.
|
||||||
* ensureCaptureBinary for what both of those cost the first time round.
|
|
||||||
*
|
*
|
||||||
* The same macOS gotcha applies as for LTC, and is worth repeating because
|
* The same macOS gotcha applies as for LTC, and is worth repeating because
|
||||||
* it costs an afternoon every time: a ProdDash launched over SSH or from a
|
* it costs an afternoon every time: a ProdDash launched over SSH or from a
|
||||||
@@ -31,72 +31,31 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
|
/* ── the capture tool: prebuilt, one binary per platform ───────────────── */
|
||||||
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
|
|
||||||
+ 'and copy it next to spl-capture.swift';
|
/** What the module ships in bin/, by platform and architecture. */
|
||||||
const BUILDING = 'Building the capture tool — this takes a minute or two';
|
const HELPERS = {
|
||||||
|
darwin: { arm64: 'spl-capture-macos-arm64', x64: 'spl-capture-macos-x86_64' },
|
||||||
|
win32: { x64: 'spl-capture-win-x64.exe' },
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the capture tool if it is missing, WITHOUT blocking, and shared by
|
* The capture tool for this machine. It is never built here and never
|
||||||
* everything that needs it.
|
* looked for anywhere else: the module ships it, or measurement is off on
|
||||||
|
* this platform and says so. `bin` is the usable binary; otherwise
|
||||||
|
* `missing` is what to tell the admin page and the tiles.
|
||||||
*
|
*
|
||||||
* This lives at module scope rather than inside a listener because of a trap
|
* @returns {{bin:string, missing:string}}
|
||||||
* the first version walked into: the admin page's device picker runs the tool
|
|
||||||
* with --list, but the build only ran when a listener started, and a listener
|
|
||||||
* only starts once a device has been picked. On a machine that got the module
|
|
||||||
* from the catalog — source only, the binary being gitignored — the picker
|
|
||||||
* was empty, and nothing would ever fill it.
|
|
||||||
*
|
|
||||||
* It also must never block. It was a spawnSync once, copied from the LTC
|
|
||||||
* listener where the source is a third the size and builds in seconds. Here
|
|
||||||
* it took nearly two minutes on the production Mac and held the Node event
|
|
||||||
* loop for all of it, freezing every other module, then did it again on the
|
|
||||||
* next retry.
|
|
||||||
*
|
|
||||||
* The binary should normally already be there: built beside the app, or
|
|
||||||
* copied from any Mac of the same architecture. This is the fallback.
|
|
||||||
*
|
|
||||||
* @returns {string} '' when the tool is ready, otherwise what to say.
|
|
||||||
*/
|
*/
|
||||||
let building = false;
|
function captureTool(moduleDir, platform = process.platform, arch = process.arch) {
|
||||||
let buildFailed = false;
|
const name = HELPERS[platform]?.[arch] || '';
|
||||||
|
const bin = name ? path.join(moduleDir, 'bin', name) : '';
|
||||||
function ensureCaptureBinary({ moduleDir, log, onReady }) {
|
if (bin && fs.existsSync(bin)) return { bin, missing: '' };
|
||||||
const bin = path.join(moduleDir, 'spl-capture');
|
const here = `${platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform} ${arch}`;
|
||||||
if (fs.existsSync(bin)) return '';
|
return {
|
||||||
const src = bin + '.swift';
|
bin: '',
|
||||||
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
|
missing: `The module ships no sound level capture tool for ${here}${name ? ` (bin/${name} is missing)` : ''} — measurement is off on this machine`,
|
||||||
if (building) return BUILDING;
|
};
|
||||||
if (buildFailed) return BUILD_HELP;
|
|
||||||
|
|
||||||
building = true;
|
|
||||||
log('spl-capture binary missing — building it in the background');
|
|
||||||
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
||||||
let buildErr = '';
|
|
||||||
build.stderr.setEncoding('utf8');
|
|
||||||
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
|
|
||||||
const giveUp = setTimeout(() => {
|
|
||||||
try { build.kill('SIGKILL'); } catch { /* already gone */ }
|
|
||||||
}, 300000);
|
|
||||||
build.on('error', (err) => {
|
|
||||||
clearTimeout(giveUp);
|
|
||||||
building = false; buildFailed = true;
|
|
||||||
log(`swiftc could not be run: ${err.message}`);
|
|
||||||
});
|
|
||||||
build.on('exit', (code) => {
|
|
||||||
clearTimeout(giveUp);
|
|
||||||
building = false;
|
|
||||||
if (code === 0 && fs.existsSync(bin)) {
|
|
||||||
log('spl-capture built');
|
|
||||||
buildFailed = false;
|
|
||||||
if (typeof onReady === 'function') onReady();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
buildFailed = true;
|
|
||||||
log(`spl-capture build failed (exit ${code})`
|
|
||||||
+ (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
|
||||||
});
|
|
||||||
return BUILDING;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const RESTART_MS = 5000;
|
const RESTART_MS = 5000;
|
||||||
@@ -114,7 +73,6 @@ const STALL_MS = 3000;
|
|||||||
*/
|
*/
|
||||||
function createSplListener(opts) {
|
function createSplListener(opts) {
|
||||||
const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts;
|
const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts;
|
||||||
const bin = path.join(moduleDir, 'spl-capture');
|
|
||||||
|
|
||||||
let child = null;
|
let child = null;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
@@ -124,13 +82,6 @@ function createSplListener(opts) {
|
|||||||
let meta = null;
|
let meta = null;
|
||||||
let lastFrameWall = 0;
|
let lastFrameWall = 0;
|
||||||
|
|
||||||
/** '' when the tool is ready, otherwise what to say about it. */
|
|
||||||
function ensureBinary() {
|
|
||||||
return ensureCaptureBinary({ moduleDir, log, onReady() {
|
|
||||||
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
|
||||||
} });
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleRestart(ms) {
|
function scheduleRestart(ms) {
|
||||||
if (stopped || restartTimer) return;
|
if (stopped || restartTimer) return;
|
||||||
restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms);
|
restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms);
|
||||||
@@ -150,15 +101,11 @@ function createSplListener(opts) {
|
|||||||
function start() {
|
function start() {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
|
|
||||||
const missing = ensureBinary();
|
const tool = captureTool(moduleDir);
|
||||||
if (missing) {
|
if (tool.missing) {
|
||||||
onUpdate({ error: missing });
|
// No binary for this platform: say so and look again later — a module
|
||||||
// onReady is only the FAST path (a successful build restarts us at
|
// update remounts us anyway, this is only belt and braces.
|
||||||
// once). The slow retry must stay armed regardless: a failed build
|
onUpdate({ error: tool.missing });
|
||||||
// never calls back, and a binary can also arrive from outside — scp'd
|
|
||||||
// in on a machine whose toolchain can't build (AB's broken CLT).
|
|
||||||
// Waiting on onReady alone deadlocked exactly that way in the Timers
|
|
||||||
// module: "building…" forever, with a working binary on disk.
|
|
||||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -175,7 +122,7 @@ function createSplListener(opts) {
|
|||||||
let stderrTail = '';
|
let stderrTail = '';
|
||||||
let buf = '';
|
let buf = '';
|
||||||
|
|
||||||
child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
child = spawn(tool.bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||||
|
|
||||||
child.stderr.setEncoding('utf8');
|
child.stderr.setEncoding('utf8');
|
||||||
child.stderr.on('data', (text) => {
|
child.stderr.on('data', (text) => {
|
||||||
@@ -258,4 +205,4 @@ function createSplListener(opts) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createSplListener, ensureCaptureBinary };
|
module.exports = { createSplListener, captureTool };
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.10.0",
|
"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">
|
||||||
|
|||||||
+342
-18
@@ -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);
|
||||||
@@ -640,8 +650,10 @@ function buildTile(tile) {
|
|||||||
el.addEventListener('pointermove', (ev) => {
|
el.addEventListener('pointermove', (ev) => {
|
||||||
if (!headIsOff(tile)) return;
|
if (!headIsOff(tile)) return;
|
||||||
// Mid-drag the tile moves under the pointer, and mid-resize the edges do
|
// Mid-drag the tile moves under the pointer, and mid-resize the edges do
|
||||||
// — neither is a moment to take the bar away.
|
// — neither is a moment to take the bar away. Nor is an open header
|
||||||
|
// menu, which hangs below the bar and is about to be pointed at.
|
||||||
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
||||||
|
if (el.classList.contains('menu-open')) return;
|
||||||
const y = ev.clientY - el.getBoundingClientRect().top;
|
const y = ev.clientY - el.getBoundingClientRect().top;
|
||||||
const shown = el.classList.contains('head-peek');
|
const shown = el.classList.contains('head-peek');
|
||||||
// It takes less to hold the bar open than to open it, so a bar that
|
// It takes less to hold the bar open than to open it, so a bar that
|
||||||
@@ -652,6 +664,7 @@ function buildTile(tile) {
|
|||||||
|
|
||||||
el.addEventListener('pointerleave', () => {
|
el.addEventListener('pointerleave', () => {
|
||||||
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
||||||
|
if (el.classList.contains('menu-open')) return; // the pointer is on its way to the menu
|
||||||
el.classList.remove('head-peek');
|
el.classList.remove('head-peek');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -659,7 +672,7 @@ function buildTile(tile) {
|
|||||||
applyHeadState(tile, el);
|
applyHeadState(tile, el);
|
||||||
grid.appendChild(el);
|
grid.appendChild(el);
|
||||||
|
|
||||||
const entry = { el, body, dot, titleEl: title, controls, instance: null };
|
const entry = { el, body, dot, titleEl: title, controls, instance: null, openMenus: new Set() };
|
||||||
tileEls.set(tile.id, entry);
|
tileEls.set(tile.id, entry);
|
||||||
applyRect(tile);
|
applyRect(tile);
|
||||||
|
|
||||||
@@ -1385,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
|
||||||
@@ -1426,34 +1467,60 @@ function buildModuleApi(tile, man, entry) {
|
|||||||
menu.className = 'tile-menu';
|
menu.className = 'tile-menu';
|
||||||
menu.hidden = true;
|
menu.hidden = true;
|
||||||
|
|
||||||
|
/* The menu lives on the body, not in the bar. A folded-away bar
|
||||||
|
(auto-hide, fullscreen) carries a backdrop-filter and a transform,
|
||||||
|
and either one makes a fixed-positioned descendant position
|
||||||
|
itself against the bar instead of the viewport — the menu landed
|
||||||
|
a screen-height below its button, inside the tile's overflow, and
|
||||||
|
never showed. Its host carries the tile's data-module so the
|
||||||
|
module's scoped CSS still reaches the items. */
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.className = 'tile-menu-host';
|
||||||
|
if (man) host.dataset.module = man.id;
|
||||||
|
host.appendChild(menu);
|
||||||
|
|
||||||
|
const setOpen = (on) => {
|
||||||
|
if (on === !menu.hidden) return;
|
||||||
|
menu.hidden = !on;
|
||||||
|
btn.classList.toggle('is-open', on);
|
||||||
|
if (on) entry.openMenus.add(menu);
|
||||||
|
else entry.openMenus.delete(menu);
|
||||||
|
// an open menu holds a folded-away bar out; when the last one
|
||||||
|
// closes with the pointer elsewhere, the bar goes back
|
||||||
|
entry.el.classList.toggle('menu-open', entry.openMenus.size > 0);
|
||||||
|
if (!on && !entry.openMenus.size && !entry.el.matches(':hover')) entry.el.classList.remove('head-peek');
|
||||||
|
};
|
||||||
const open = () => {
|
const open = () => {
|
||||||
if (typeof build === 'function') {
|
if (typeof build === 'function') {
|
||||||
menu.innerHTML = '';
|
menu.innerHTML = '';
|
||||||
build(menu);
|
build(menu);
|
||||||
}
|
}
|
||||||
menu.hidden = false;
|
setOpen(true);
|
||||||
// fixed positioning (escapes the tile's overflow): under the
|
// under the button, right-aligned, kept whole inside the viewport
|
||||||
// button, right-aligned, clamped to the viewport
|
|
||||||
const r = btn.getBoundingClientRect();
|
const r = btn.getBoundingClientRect();
|
||||||
menu.style.top = Math.min(r.bottom + 6, window.innerHeight - 80) + 'px';
|
|
||||||
menu.style.left = '0px';
|
menu.style.left = '0px';
|
||||||
|
menu.style.top = '0px';
|
||||||
const w = menu.offsetWidth;
|
const w = menu.offsetWidth;
|
||||||
|
const h = menu.offsetHeight;
|
||||||
|
menu.style.top = Math.max(8, Math.min(r.bottom + 6, window.innerHeight - h - 8)) + 'px';
|
||||||
menu.style.left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8)) + 'px';
|
menu.style.left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8)) + 'px';
|
||||||
};
|
};
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (menu.hidden) open();
|
if (menu.hidden) open();
|
||||||
else menu.hidden = true;
|
else setOpen(false);
|
||||||
});
|
});
|
||||||
const onDocClick = (e) => {
|
const onDocClick = (e) => {
|
||||||
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) menu.hidden = true;
|
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) setOpen(false);
|
||||||
};
|
};
|
||||||
document.addEventListener('click', onDocClick);
|
document.addEventListener('click', onDocClick);
|
||||||
docListeners.add(onDocClick);
|
docListeners.add(onDocClick);
|
||||||
|
|
||||||
wrap.append(btn, menu);
|
wrap.append(btn);
|
||||||
entry.controls.appendChild(wrap);
|
entry.controls.appendChild(wrap);
|
||||||
|
document.body.appendChild(host);
|
||||||
headerEls.add(wrap);
|
headerEls.add(wrap);
|
||||||
|
headerEls.add(host);
|
||||||
return {
|
return {
|
||||||
button: btn,
|
button: btn,
|
||||||
menu,
|
menu,
|
||||||
@@ -1461,7 +1528,7 @@ function buildModuleApi(tile, man, entry) {
|
|||||||
btn.textContent = text;
|
btn.textContent = text;
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
menu.hidden = true;
|
setOpen(false);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -1473,6 +1540,8 @@ function buildModuleApi(tile, man, entry) {
|
|||||||
for (const handle of [...sseHandles]) handle.close();
|
for (const handle of [...sseHandles]) handle.close();
|
||||||
for (const el of headerEls) el.remove();
|
for (const el of headerEls) el.remove();
|
||||||
headerEls.clear();
|
headerEls.clear();
|
||||||
|
entry.openMenus.clear();
|
||||||
|
entry.el.classList.remove('menu-open');
|
||||||
for (const fn of docListeners) document.removeEventListener('click', fn);
|
for (const fn of docListeners) document.removeEventListener('click', fn);
|
||||||
docListeners.clear();
|
docListeners.clear();
|
||||||
},
|
},
|
||||||
@@ -2111,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();
|
||||||
@@ -2176,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);
|
||||||
@@ -2214,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 + ')');
|
||||||
@@ -2308,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 {
|
||||||
@@ -2459,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));
|
||||||
@@ -2488,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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2560,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() {
|
||||||
|
|||||||
+146
-2
@@ -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);
|
||||||
@@ -445,8 +587,9 @@ body.menu-hidden #topbar {
|
|||||||
}
|
}
|
||||||
.tile-dd { position: relative; display: inline-flex; }
|
.tile-dd { position: relative; display: inline-flex; }
|
||||||
|
|
||||||
/* a module header menu — fixed-positioned by the shell so the tile's
|
/* a module header menu — hosted on the body (shell.js addMenu) so neither
|
||||||
overflow:hidden can't clip it */
|
the tile's overflow nor a folded-away bar's backdrop-filter can touch it */
|
||||||
|
.tile-menu-host { display: contents; }
|
||||||
.tile-menu {
|
.tile-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 70;
|
z-index: 70;
|
||||||
@@ -979,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
|
||||||
@@ -956,9 +1148,21 @@ async function fetchArchive() {
|
|||||||
signal: AbortSignal.timeout(30000),
|
signal: AbortSignal.timeout(30000),
|
||||||
headers: { 'User-Agent': `ProdDash/${SHELL_VERSION}` },
|
headers: { 'User-Agent': `ProdDash/${SHELL_VERSION}` },
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${REPO.owner}/${REPO.repo}@${REPO.branch}`);
|
const where = `${REPO.owner}/${REPO.repo}@${REPO.branch}`;
|
||||||
|
// A forge that wants a sign-in does not refuse: it redirects the download
|
||||||
|
// to its login page, which arrives as a 200 of HTML. Say that, not
|
||||||
|
// zlib's "incorrect header check".
|
||||||
|
if (res.redirected && /\/user\/login\b|\/login\b/.test(new URL(res.url).pathname)) {
|
||||||
|
throw new Error(`${FORGE.base} requires a sign-in to read ${where} — turn off "require sign-in to view" in Gitea, or make the repo readable without one`);
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${where}`);
|
||||||
const gz = Buffer.from(await res.arrayBuffer());
|
const gz = Buffer.from(await res.arrayBuffer());
|
||||||
if (gz.length > MAX_ARCHIVE_BYTES) throw new Error('archive is unexpectedly large');
|
if (gz.length > MAX_ARCHIVE_BYTES) throw new Error('archive is unexpectedly large');
|
||||||
|
if (gz.length < 2 || gz[0] !== 0x1f || gz[1] !== 0x8b) {
|
||||||
|
const type = String(res.headers.get('content-type') || 'unknown type').split(';')[0].trim();
|
||||||
|
const looksHtml = /^\s*<(!doctype|html)/i.test(gz.subarray(0, 64).toString('utf8'));
|
||||||
|
throw new Error(`${FORGE.base} answered with ${looksHtml ? 'a web page' : type} instead of the ${where} archive — check the forge address and that the repo is readable without a sign-in`);
|
||||||
|
}
|
||||||
const { files, comment } = parseTar(zlib.gunzipSync(gz));
|
const { files, comment } = parseTar(zlib.gunzipSync(gz));
|
||||||
const stripped = new Map(); // drop the archive's single top-level folder
|
const stripped = new Map(); // drop the archive's single top-level folder
|
||||||
for (const [p, data] of files) {
|
for (const [p, data] of files) {
|
||||||
@@ -1722,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. */
|
||||||
@@ -1783,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 || ''),
|
||||||
@@ -2081,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);
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-9
@@ -1,9 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Mock ProdCom API for verifying the prodcom-transcript module without a
|
* Mock ProdCom API for verifying the prodcom-transcript module without a
|
||||||
* live ProdCom. Serves the endpoints the module uses — channels, groups,
|
* live ProdCom. Serves the endpoints the module uses — channels, groups,
|
||||||
* transcript history and the SSE stream — and simulates live speech:
|
* transcript history, the SSE stream and POST /api/v1/channels/{id}/send —
|
||||||
* entries appear in progress, grow word by word (same id, in-place update),
|
* and simulates live speech: entries appear in progress, grow word by word
|
||||||
* then complete.
|
* (same id, in-place update), then complete. Text sent to a channel lands
|
||||||
|
* in that channel's history as a completed entry with source "typed" and
|
||||||
|
* goes out on the stream, which is how a send from the Send Message module
|
||||||
|
* can be watched arriving in the transcript tile. A text containing "!429"
|
||||||
|
* answers RATE_LIMITED; an unknown channel NOT_FOUND. With PRODCOM_MOCK_KEY
|
||||||
|
* set, every request must carry it as a Bearer token (UNAUTHORIZED
|
||||||
|
* otherwise) — the module's "API key" field.
|
||||||
*
|
*
|
||||||
* node tools/prodcom-mock.js [port] (default 24480)
|
* node tools/prodcom-mock.js [port] (default 24480)
|
||||||
*
|
*
|
||||||
@@ -17,12 +23,13 @@ const http = require('http');
|
|||||||
|
|
||||||
const PORT = Number.parseInt(process.argv[2] || '24480', 10);
|
const PORT = Number.parseInt(process.argv[2] || '24480', 10);
|
||||||
const STEP_MS = Number.parseInt(process.env.PRODCOM_STEP_MS || '700', 10);
|
const STEP_MS = Number.parseInt(process.env.PRODCOM_STEP_MS || '700', 10);
|
||||||
|
const API_KEY = String(process.env.PRODCOM_MOCK_KEY || '');
|
||||||
|
|
||||||
const CHANNELS = [
|
const CHANNELS = [
|
||||||
{ id: 'ch-stage', name: 'Stage', color: '#e0645c' },
|
{ id: 'ch-stage', name: 'Stage', color: '#e0645c', sourceType: 'localAudio', typingEnabled: true, isSelected: true, unreadCount: 0 },
|
||||||
{ id: 'ch-foh', name: 'FOH', color: '#5c9de0' },
|
{ id: 'ch-foh', name: 'FOH', color: '#5c9de0', sourceType: 'localAudio', typingEnabled: true, isSelected: false, unreadCount: 0 },
|
||||||
{ id: 'ch-video', name: 'Video', color: '#5cd08c' },
|
{ id: 'ch-video', name: 'Video', color: '#5cd08c', sourceType: 'network', typingEnabled: false, isSelected: false, unreadCount: 0 },
|
||||||
{ id: 'ch-light', name: 'Lighting', color: '#e0b45c' },
|
{ id: 'ch-light', name: 'Lighting', color: '#e0b45c', sourceType: 'network', typingEnabled: false, isSelected: false, unreadCount: 0 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const GROUPS = [
|
const GROUPS = [
|
||||||
@@ -69,6 +76,7 @@ function stepSimulation() {
|
|||||||
text: words[0],
|
text: words[0],
|
||||||
date: new Date().toISOString(),
|
date: new Date().toISOString(),
|
||||||
inProgress: true,
|
inProgress: true,
|
||||||
|
source: 'audio',
|
||||||
};
|
};
|
||||||
speaking = { entry, words, at: 1 };
|
speaking = { entry, words, at: 1 };
|
||||||
history.push(entry);
|
history.push(entry);
|
||||||
@@ -90,15 +98,68 @@ function stepSimulation() {
|
|||||||
|
|
||||||
setInterval(stepSimulation, STEP_MS);
|
setInterval(stepSimulation, STEP_MS);
|
||||||
|
|
||||||
function sendJson(res, body) {
|
function sendJson(res, body, status = 200) {
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify(body));
|
res.end(JSON.stringify(body));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ProdCom's error envelope. */
|
||||||
|
function sendError(res, status, code, message) {
|
||||||
|
sendJson(res, { error: { code, message }, meta: { timestamp: new Date().toISOString() } }, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let data = '';
|
||||||
|
req.on('data', (c) => { data += c; });
|
||||||
|
req.on('end', () => {
|
||||||
|
try { resolve(data ? JSON.parse(data) : {}); } catch { resolve(null); }
|
||||||
|
});
|
||||||
|
req.on('error', () => resolve(null));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Typed text onto a channel: a completed entry with source "typed", like ProdCom makes. */
|
||||||
|
function sendText(channel, text) {
|
||||||
|
const entry = {
|
||||||
|
id: 'e' + RUN + '-' + nextId++,
|
||||||
|
channelId: channel.id,
|
||||||
|
channelName: channel.name,
|
||||||
|
text,
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
inProgress: false,
|
||||||
|
source: 'typed',
|
||||||
|
hasBeenSeen: false,
|
||||||
|
};
|
||||||
|
history.push(entry);
|
||||||
|
if (history.length > 200) history.shift();
|
||||||
|
broadcast(entry);
|
||||||
|
console.log(`[prodcom-mock] typed → ${channel.name}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
const url = new URL(req.url, `http://x.invalid`);
|
const url = new URL(req.url, `http://x.invalid`);
|
||||||
const p = url.pathname;
|
const p = url.pathname;
|
||||||
|
|
||||||
|
if (API_KEY && req.headers.authorization !== `Bearer ${API_KEY}`) {
|
||||||
|
return sendError(res, 401, 'UNAUTHORIZED', 'Authentication required or invalid API key');
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/channels/{id or name}/send { text }
|
||||||
|
const send = /^\/api\/v1\/channels\/([^/]+)\/send$/.exec(p);
|
||||||
|
if (send && req.method === 'POST') {
|
||||||
|
const ref = decodeURIComponent(send[1]);
|
||||||
|
const channel = CHANNELS.find((c) => c.id === ref) || CHANNELS.find((c) => c.name.toLowerCase() === ref.toLowerCase());
|
||||||
|
return void readBody(req).then((body) => {
|
||||||
|
if (!channel) return sendError(res, 404, 'NOT_FOUND', 'Channel not found');
|
||||||
|
const text = typeof body?.text === 'string' ? body.text.trim() : '';
|
||||||
|
if (!body || !text) return sendError(res, 400, 'VALIDATION_ERROR', 'text is required');
|
||||||
|
if (text.includes('!429')) return sendError(res, 429, 'RATE_LIMITED', 'Slow down');
|
||||||
|
sendText(channel, text);
|
||||||
|
sendJson(res, { data: { sent: true }, meta: { timestamp: new Date().toISOString() } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (p === '/api/v1/channels') return sendJson(res, { data: CHANNELS });
|
if (p === '/api/v1/channels') return sendJson(res, { data: CHANNELS });
|
||||||
if (p === '/api/v1/groups') return sendJson(res, { data: GROUPS });
|
if (p === '/api/v1/groups') return sendJson(res, { data: GROUPS });
|
||||||
if (p === '/api/v1/transcript') {
|
if (p === '/api/v1/transcript') {
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The update check against a forge that misbehaves: the admin page must say
|
||||||
|
* what went wrong in words, never zlib's "incorrect header check".
|
||||||
|
*
|
||||||
|
* node tools/update-check.test.js
|
||||||
|
*
|
||||||
|
* Plain Node, no dependencies. Starts a mock forge whose answer to the
|
||||||
|
* branch-archive download can be switched at runtime, starts server.js on a
|
||||||
|
* spare port pointed at it, and reads the shell status the admin page shows
|
||||||
|
* after each kind of answer:
|
||||||
|
*
|
||||||
|
* - a Gitea that requires a sign-in redirects the download to its login
|
||||||
|
* page (a 200 of HTML) — the case that hit both booths on 2026-09-29;
|
||||||
|
* - a captive portal or a wrong address answers HTML outright;
|
||||||
|
* - a plain HTTP error;
|
||||||
|
* - and a real archive, which must still parse and offer the update.
|
||||||
|
*
|
||||||
|
* Exits non-zero on the first failure and always stops what it started.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { spawn, execFileSync } = 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 branch archive the way a forge would build one: one top-level folder. */
|
||||||
|
function buildArchive(dir) {
|
||||||
|
const top = path.join(dir, 'ProdDash-main');
|
||||||
|
fs.mkdirSync(path.join(top, 'modules', 'demo'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' }));
|
||||||
|
fs.writeFileSync(path.join(top, 'modules', 'demo', 'module.json'), JSON.stringify({ id: 'demo', name: 'Demo', version: '1.0.0', client: 'client.js' }));
|
||||||
|
fs.writeFileSync(path.join(top, 'modules', 'demo', 'client.js'), 'export default function () {}\n');
|
||||||
|
const out = path.join(dir, 'main.tar.gz');
|
||||||
|
execFileSync('tar', ['-czf', out, '-C', dir, 'ProdDash-main']);
|
||||||
|
return fs.readFileSync(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-update-test-'));
|
||||||
|
const dataDir = path.join(work, 'data');
|
||||||
|
fs.mkdirSync(dataDir);
|
||||||
|
const archive = buildArchive(path.join(work, 'forge'));
|
||||||
|
|
||||||
|
// ── the mock forge ──
|
||||||
|
let mode = 'ok';
|
||||||
|
const LOGIN_PAGE = '<!DOCTYPE html>\n<html><head><title>Sign in</title></head><body>Sign in to continue</body></html>';
|
||||||
|
const forge = http.createServer((req, res) => {
|
||||||
|
const url = new URL(req.url, 'http://forge.invalid');
|
||||||
|
if (url.pathname === '/user/login') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
|
return res.end(LOGIN_PAGE);
|
||||||
|
}
|
||||||
|
if (url.pathname !== '/michael/ProdDash/archive/main.tar.gz') {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
|
return res.end('not here');
|
||||||
|
}
|
||||||
|
switch (mode) {
|
||||||
|
case 'login-redirect':
|
||||||
|
res.writeHead(303, { Location: '/user/login?redirect_to=' + encodeURIComponent(url.pathname) });
|
||||||
|
return res.end();
|
||||||
|
case 'html':
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
|
return res.end('<html><body>You must log in to this network first</body></html>');
|
||||||
|
case 'notfound':
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
|
return res.end('no such branch');
|
||||||
|
default:
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/gzip' });
|
||||||
|
return res.end(archive);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const forgePort = await freePort();
|
||||||
|
await new Promise((r) => forge.listen(forgePort, '127.0.0.1', r));
|
||||||
|
const forgeBase = `http://127.0.0.1:${forgePort}`;
|
||||||
|
|
||||||
|
// ── ProdDash, pointed at it ──
|
||||||
|
const port = await freePort();
|
||||||
|
const child = spawn(process.execPath, ['server.js'], {
|
||||||
|
cwd: ROOT,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PRODDASH_DATA_DIR: dataDir,
|
||||||
|
PORT: String(port),
|
||||||
|
PRODDASH_FORGE: forgeBase,
|
||||||
|
PRODDASH_REPO: 'michael/ProdDash',
|
||||||
|
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}`;
|
||||||
|
|
||||||
|
/** What the admin page shows after a forced check. Admin is open: a fresh data dir has no PIN. */
|
||||||
|
const check = async (m) => {
|
||||||
|
mode = m;
|
||||||
|
const r = await fetch(base + '/api/admin/catalog?refresh=1');
|
||||||
|
assert.equal(r.status, 200, `catalog: HTTP ${r.status}`);
|
||||||
|
const body = await r.json();
|
||||||
|
return body.shell;
|
||||||
|
};
|
||||||
|
|
||||||
|
let failed = false;
|
||||||
|
try {
|
||||||
|
await waitFor(base + '/api/theme');
|
||||||
|
|
||||||
|
let shell = await check('login-redirect');
|
||||||
|
assert.match(shell.remote.error, /requires a sign-in/, 'a login redirect is named as such: ' + shell.remote.error);
|
||||||
|
assert.match(shell.remote.error, /require sign-in to view/, 'and the Gitea setting to look at is named');
|
||||||
|
assert.doesNotMatch(shell.remote.error, /header check/, 'never zlib\'s words');
|
||||||
|
assert.equal(shell.updateAvailable, false);
|
||||||
|
|
||||||
|
shell = await check('html');
|
||||||
|
assert.match(shell.remote.error, /answered with a web page instead of the michael\/ProdDash@main archive/, shell.remote.error);
|
||||||
|
|
||||||
|
shell = await check('notfound');
|
||||||
|
assert.match(shell.remote.error, /HTTP 404 fetching michael\/ProdDash@main/, shell.remote.error);
|
||||||
|
|
||||||
|
shell = await check('ok');
|
||||||
|
assert.equal(shell.remote.error, '', 'a real archive clears the error: ' + shell.remote.error);
|
||||||
|
assert.equal(shell.remote.version, '9.9.9', 'and is read');
|
||||||
|
assert.equal(shell.updateAvailable, true, 'and offered');
|
||||||
|
|
||||||
|
// Back to a broken forge: the last good read is not mistaken for a current one.
|
||||||
|
shell = await check('login-redirect');
|
||||||
|
assert.match(shell.remote.error, /requires a sign-in/);
|
||||||
|
|
||||||
|
console.log('update check: every check passed');
|
||||||
|
} catch (err) {
|
||||||
|
failed = true;
|
||||||
|
console.error('FAIL:', err.message);
|
||||||
|
console.error('--- last of the server log ---\n' + log.slice(-2000));
|
||||||
|
} finally {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
forge.close();
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
fs.rmSync(work, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user