diff --git a/README.md b/README.md index 9a2a0e6..dab0645 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,129 @@ # ProdDash -A new web application, built on lessons and code from two existing Waters Church -production tools, which are included here as reference forks: +A modular **production dashboard** for Waters Church. Any browser on the +building network opens one page and composes its own dashboard out of +**modules** — independent tiles that each show one aspect of a live service. +Ships with the live **ProdCom transcript**, the **ProPresenter Now / Next** +display, and a booth **clock**; new modules drop into `modules/` without +touching the shell (see [docs/MODULE-GUIDE.md](docs/MODULE-GUIDE.md)). + +## How it works + +`server.js` (zero dependencies, just Node.js) does four things: + +1. Serves the dashboard shell (`public/`) and the admin page (`/admin`). +2. Discovers modules in `modules/` and serves each one's client at + `/modules//`. +3. Mounts each enabled module's server routes under `/api/modules//` — + that's where module servers proxy ProdCom, poll ProPresenter, and stream + live updates over SSE, so browsers never talk to production gear + directly (no CORS, and API keys never leave the server). +4. Stores server-wide module config and named layouts under `config/`. + +Each browser arranges its own tiles (add, drag, resize, remove, per-tile +settings); the layout autosaves to that browser's `localStorage`. Named +layouts can also be saved to the server and loaded from any other browser. +Every module survives its upstream disappearing: tiles show a red status dot +and a clear degraded state, then recover on their own. + +## Run it + +Requires [Node.js](https://nodejs.org) 18 or newer. + +```bash +node server.js +``` + +Or double-click **Start ProdDash.command** (macOS) / **start.bat** (Windows). + +Then open the URL it prints — `http://localhost:24500` on this machine, or +the `http://:24500` address from any other device on the +network. + +## Configure + +Server-wide module settings (ProdCom URL, ProPresenter host, API keys, +enable/disable) live in the **admin page** — open **Admin** from the +dashboard's top bar, or go to `/admin`. Saving re-initialises the module +immediately; open tiles reconnect on their own. + +Shell settings live in [config/proddash.json](config/proddash.json): + +| Key | Meaning | +| --- | --- | +| `port` | Port ProdDash serves on (default `24500`) | +| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. | + +Environment variables `PORT` and `PRODDASH_PASSCODE` override the file. +`config/modules.json` (module config) and `config/layouts/` (named layouts) +are written by the app itself. + +## Using the dashboard + +- **+ Add tile** — add any enabled module; multiple tiles of the same + module are fine (e.g. two transcripts filtered to different channels). +- Drag a tile by its **header** to move it; drag the **corner handle** to + resize; **✕** removes it. +- The **gear** on a tile holds per-tile settings (filters, text size, + display options) — these belong to this browser's layout, not the server. +- **Layout ▾** — save the current arrangement to the server under a name, + load a named layout (it becomes a local copy — browsers are never linked), + or reset this browser's dashboard. +- Every tile has a **status dot**: green = live, amber = connecting, + red = upstream unreachable (hover it for details). Tiles reconnect + automatically. + +## Included modules + +- **ProdCom Transcript** — live transcript stream with in-place updates of + in-progress speech, channel colors, per-tile channel visibility toggles + and group presets. Admin config: ProdCom URL, API key. +- **ProPresenter Now / Next** — what's live and what's next, with slide + progress, driven by the shared `propresenter-core` client (ProPresenter + 7/20). Admin config: host, port, password. +- **Clock** — a big booth clock; also the smallest possible module and the + reference for module authors. + +To develop modules against mocks instead of real gear: +`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`) and +`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter on +`127.0.0.1:1599`). See the testing section of the +[module guide](docs/MODULE-GUIDE.md). + +## Install as a desktop web app + +Open the page in a browser first, then: + +- **macOS — Safari**: File → **Add to Dock…** +- **macOS/Windows — Chrome**: click the install icon in the address bar, or + ⋮ menu → Cast, save and share → **Install page as app…** +- **Windows — Edge**: ⋯ menu → Apps → **Install this site as an app** + +Note: Chrome only offers the one-click PWA install on `http://localhost` (a +"secure context"). When opening from *another* machine over plain HTTP, use +Edge's "Install this site as an app" or Chrome's ⋮ → Cast, save and share → +"Create shortcut…" (check "Open as window") — same result: a dock/taskbar +app in its own window. + +## Repository layout ``` ProdDash/ -├── prodcom-listener/ fork of ProdCom Listener — live transcript web app -│ (zero-dependency Node server + SSE proxy to the ProdCom API) -└── band-lineup-display/ fork of BandLineupDisplay — backstage stage-lineup Electron app, - plus the shared ProPresenter 7/20 client (propresenter-core/) - and the standalone Now/Next display (propresenter-app/) +├── server.js shell server: static files, module discovery/mounting, +│ admin + layouts APIs +├── public/ dashboard shell client (index.html, shell.js, style.css) +│ └── admin/ admin page client +├── modules/ installed modules, one folder each +│ ├── clock/ +│ ├── prodcom-transcript/ +│ └── propresenter-now-next/ (carries its own copy of propresenter-core) +├── config/ proddash.json + state written by the app +├── docs/MODULE-GUIDE.md how to build and install a module +├── tools/prodcom-mock.js mock ProdCom API for development +├── prodcom-listener/ reference fork — read-only, not served +└── band-lineup-display/ reference fork — read-only, not served ``` -The new application will live at the repo root as it takes shape; the two -reference folders are snapshots (build output and `node_modules` excluded) and -are not wired into a build. - -## Reference apps at a glance - -- **prodcom-listener** — serves a web UI and proxies `/prodcom/*` to the ProdCom - API, including the Server-Sent Events transcript stream, so browsers never - talk to ProdCom directly. See [prodcom-listener/README.md](prodcom-listener/README.md). -- **band-lineup-display** — Electron app for the backstage screen showing stage - lineup photos with mic/pack labels. Carries the ProPresenter **Now / Next** - feature; `propresenter-core/` is the single home for every ProPresenter - quirk. See [band-lineup-display/README.md](band-lineup-display/README.md). +The two reference folders are snapshots of the apps ProdDash grew out of; +they are not wired into anything (but `band-lineup-display/tmp/pp-mock.js` +is handy for testing). diff --git a/Start ProdDash.command b/Start ProdDash.command new file mode 100644 index 0000000..0ca52ec --- /dev/null +++ b/Start ProdDash.command @@ -0,0 +1,4 @@ +#!/bin/zsh +# Double-click to start ProdDash (macOS) +cd "$(dirname "$0")" +exec node server.js diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md new file mode 100644 index 0000000..1dc59a4 --- /dev/null +++ b/docs/MODULE-GUIDE.md @@ -0,0 +1,364 @@ +# ProdDash Module Guide + +How to build, install and test a ProdDash module. Everything a module author +needs is on this page — you never have to read the shell's source. + +A **module** is one kind of dashboard tile: the live transcript, the +Now/Next display, a clock. Users add any number of tiles of your module to +their own dashboards, move and resize them, and give each tile its own +per-tile settings. Your module renders inside the tile and talks to the +outside world only through the small API the shell hands it. + +## The shape of a module + +A module is one folder dropped into `modules/`: + +``` +modules// +├── module.json manifest (required) +├── client.js ES module, client entry (required) +├── server.js server entry (optional — only if you need server code) +└── ... assets (css, images…) served statically at /modules// +``` + +**Installation is exactly this:** copy the folder into `modules/`, restart +ProdDash (`node server.js`), then configure and enable it at `/admin`. No +shell code changes, ever. + +## module.json + +```json +{ + "id": "my-module", + "name": "My Module", + "version": "1.0.0", + "description": "One line shown in the Add-tile picker and the admin page", + "client": "client.js", + "server": "server.js", + "style": "style.css", + "minSize": { "w": 2, "h": 2 }, + "defaultSize": { "w": 4, "h": 3 }, + "configSchema": { + "upstreamUrl": { "type": "string", "label": "Upstream URL", "default": "http://10.0.0.5:1234" }, + "apiKey": { "type": "password", "label": "API key", "default": "" } + }, + "instanceSchema": { + "channel": { "type": "string", "label": "Channel filter", "default": "" } + } +} +``` + +| Field | Required | Meaning | +| --- | --- | --- | +| `id` | yes | Must equal the folder name. Lowercase, dashes. | +| `name` | yes | Human name shown on the tile header and in menus. | +| `version`, `description` | no | Shown in the admin page / picker. | +| `client` | yes | Client entry file, loaded as an ES module. | +| `server` | no | Server entry file (CommonJS), loaded with `require()`. | +| `style` | no | A stylesheet the shell injects once per module. | +| `minSize`, `defaultSize` | no | Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3. | +| `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. | +| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). | + +Both schemas map field names to specs: + +``` +{ "type": "string" | "number" | "boolean" | "select" | "password", + "label": "Shown next to the field", + "default": , + "options": [ { "value": "a", "label": "A" }, … ] // "select" only +} +``` + +`password` fields are admin-config only in practice: their values are stored +in `config/modules.json`, handed to your *server* entry, and **never sent to +browsers** — not in `/api/modules`, and never echoed back into the admin +form. Anything upstream that needs the secret must go through your server +routes. + +Admin config lives in `config/modules.json` (written by the admin page — +your module never touches that file). Per-tile settings live inside each +browser's layout. + +## Client entry (`client.js`) + +An ES module whose **default export is a factory**. The shell calls it once +per tile: + +```js +export default function create({ root, moduleApi }) { + return { + start() {}, // begin rendering / connecting + stop() {}, // tear down EVERYTHING (timers, streams, observers) + onResize(w, h) {}, // optional; tile was resized (w, h in grid cells) + onConfigChange(cfg) {} // optional; admin config was saved + }; +} +``` + +- `root` — the tile's body element. **The module owns everything inside it + and must never touch DOM outside it.** +- All four methods are optional, but a module without `stop()` will leak + timers when its tile is removed. `stop()` must leave nothing running. + +### moduleApi + +| Member | What it is | +| --- | --- | +| `id` | Your module id. | +| `instanceId` | Unique per tile — two tiles of the same module get different ids. Handy for keying anything per-tile. | +| `config` | Admin (server-wide) config, read-only, always current. Password fields are absent — they never reach the client. | +| `instanceSettings` | This tile's settings: your `instanceSchema` defaults overlaid with whatever this tile has saved. Read it fresh whenever you render — don't cache it. | +| `saveInstanceSettings(patch)` | Merge `patch` into this tile's settings and persist them with the layout. No restart happens — you made the change, you already know. Use it for state the user sets *inside* your tile (a toggled filter, a chosen tab). | +| `fetch(path, opts)` | `fetch` scoped to your server routes: `fetch('/state')` hits `/api/modules//state`. | +| `sse(path, handlers)` | An `EventSource` scoped the same way, **with auto-reconnect** (see below). Returns a handle with `close()`. | +| `setStatus(state, msg)` | Drives the tile's status dot: `'ok'`, `'connecting'` or `'error'`, plus a hover message. Call it honestly — during a live service the dot is how an operator spots a dead feed. | + +`sse(path, handlers)` handlers: + +```js +const stream = moduleApi.sse('/stream', { + open(e) {}, // (re)connected + error(e) {}, // connection lost — say so via setStatus + message(e) {}, // unnamed "data:" events + events: { state(e) {} }, // named events by name +}); +// later: stream.close() +``` + +Browsers' `EventSource` retries transient drops itself but gives up for good +when a retry gets a completed non-SSE response — which is what a proxy's 502 +looks like while the upstream is down. The shell's `sse()` recreates closed +streams on a 3-second timer until they work again, so **reconnect logic is +free**: handle `open` (refresh/backfill your state; you may have missed +events) and `error` (report degraded status), and you're resilient. Streams +you forget to close are force-closed when the tile unmounts — but close your +own in `stop()` anyway. + +### Lifecycle you must expect + +- **Any number of instances.** Two transcript tiles with different filters + is a feature. Keep all state inside `create()`'s closure — no module-level + mutable state in `client.js`. +- **Remounts are routine.** When the user edits the tile's gear settings the + shell calls `stop()` and starts a fresh instance. When admin config + changes, the shell calls your `onConfigChange(cfg)` if you export one, + otherwise it remounts you. Either way you must come back cleanly. +- **The tile can be removed at any moment.** `stop()` is your only notice. + +### Rules for the client + +1. **Never touch DOM outside `root`. Never define global CSS.** Scope every + rule in your stylesheet with the attribute the shell puts on your tiles: + + ```css + [data-module="my-module"] .my-thing { … } + ``` + + (Shadow DOM works too, if you prefer full isolation.) + +2. **Style with the shell's CSS variables — no hardcoded colors.** The + palette: `--bg` (page), `--panel` (tile), `--border`, `--text`, + `--muted`, `--accent`, `--danger`, `--warn`. Derive tints with + `color-mix(in srgb, var(--accent) 20%, transparent)`. This is what makes + every module look native on the dark, high-contrast booth theme. + +3. **No external network calls from the client.** Browsers talk only to + ProdDash. Anything upstream (your device, service, API) goes through your + module's server routes — same reasoning as the reference apps: no CORS, + no per-browser credentials, one place that owns the connection. + +4. **Handle upstream loss.** Reconnect with backoff (or lean on `sse()`), + and report honestly via `setStatus`. Tiles run unattended during live + services; a module that silently freezes is worse than one that says + it's down. + +## Server entry (`server.js`) + +Optional — only needed when your module talks to something upstream or keeps +server-side state. CommonJS, Node built-ins only (keep the zero-dependency +ethos; Node 18+ so global `fetch` is available). Exports either or both of: + +```js +module.exports = { + init({ config, log }) { + // Called on startup / re-init, BEFORE routes(). Start your upstream + // connection here. Return a handle: + return { + stop() {}, // tear everything down + health() { return { status: 'ok', message: '' }; } // optional + }; + }, + + routes({ config, log }) { + // Route table, mounted under /api/modules// + return { + 'GET /state': (req, res) => { … }, + 'GET /stream': (req, res) => { … }, // SSE — see notes + 'POST /command': (req, res) => { … }, + 'GET /proxy/*': (req, res) => { … }, // trailing /* = prefix route + '* /anything/*': (req, res) => { … }, // * method = any method + }; + }, +}; +``` + +- `config` is your admin config: `configSchema` defaults overlaid with what + the admin page saved — **including password fields**. +- `log(...)` prefixes output with your module id. +- Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is + the matched remainder (leading `/` included) and `req.search` is the query + string (with `?`, or `''`) — so a proxy rebuilds the upstream path as + `req.wildcard + req.search`. +- Exact routes match the path exactly; prefix routes (`/x/*`) match `/x` and + everything under it. First declared match wins. +- **Re-init instead of restart:** when the admin page saves your config, the + shell calls your handle's `stop()`, then `init()` and `routes()` again + with the new config. Keep module-level state inside `init()`'s closure (or + reset it there) so a re-init starts clean. Disabling a module unmounts its + routes entirely (clients get 404). +- `health()` feeds the admin page's status line — report your upstream's + reachability (`status: 'ok' | 'connecting' | 'error'`), not just "running". +- A throw from `init()`/`routes()` marks the module as failed: the error + shows in `/admin`, and API calls answer 502 until a config fix re-inits + it. Validate config early so bad input fails loudly there. + +### Server-Sent Events from a module + +For live data, expose a `/stream` route and let clients use +`moduleApi.sse('/stream', …)`: + +```js +'GET /stream': (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }); + res.write('retry: 3000\n\n'); + res.write(`event: state\ndata: ${JSON.stringify(latest)}\n\n`); // instant paint + streams.add(res); + req.on('close', () => streams.delete(res)); +} +``` + +Broadcast only when the payload actually changed, send a `: ping\n\n` +comment every ~15 s so idle connections survive sleepy Wi-Fi, and wrap every +per-client `res.write` in try/catch (drop the client on failure). The +`propresenter-now-next` module is the reference implementation of this +pattern. + +## Complete minimal example + +A working clock — copy-paste this as your starter. (A slightly fancier +version ships in `modules/clock/`.) + +`modules/hello-clock/module.json` + +```json +{ + "id": "hello-clock", + "name": "Hello Clock", + "version": "1.0.0", + "description": "Minimal example module", + "client": "client.js", + "style": "style.css", + "minSize": { "w": 2, "h": 1 }, + "defaultSize": { "w": 3, "h": 2 }, + "instanceSchema": { + "showSeconds": { "type": "boolean", "label": "Show seconds", "default": true } + } +} +``` + +`modules/hello-clock/client.js` + +```js +export default function create({ root, moduleApi }) { + let timer = null; + + function tick() { + const { showSeconds } = moduleApi.instanceSettings; + root.querySelector('.hc-time').textContent = new Date().toLocaleTimeString( + [], { hour: '2-digit', minute: '2-digit', ...(showSeconds ? { second: '2-digit' } : {}) } + ); + } + + return { + start() { + root.innerHTML = '
'; + tick(); + timer = setInterval(tick, 250); + moduleApi.setStatus('ok', 'Ticking'); + }, + stop() { + clearInterval(timer); + root.innerHTML = ''; + }, + }; +} +``` + +`modules/hello-clock/style.css` + +```css +[data-module="hello-clock"] .hc-time { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 2.4em; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--text); +} +``` + +Drop the folder in `modules/`, restart ProdDash, and “Hello Clock” appears +in the Add-tile picker (new modules are enabled by default; `/admin` can +disable them). + +## Testing your module + +**Run it:** + +```bash +node server.js # from the repo root — prints the LAN URL +``` + +The server logs each module it mounts (or why it refused to). `/admin` +shows mount errors and your `health()` line; the browser console shows +client-side failures (a tile that fails to start says so in the tile). + +**Poke the pieces directly:** + +- `GET /api/modules` — what the shell sees (your manifest + non-secret config). +- `GET /api/modules//...` — your routes, `curl`-able. For SSE: + `curl -N http://localhost:24500/api/modules//stream`. +- `GET /api/admin/state` — enabled flags, health, mount errors. + +**Mock upstreams** (so you can develop without the real gear): + +- ProdCom: `node tools/prodcom-mock.js` (port 24480) — channels, groups, + history, and a live stream with in-progress speech. Point the + transcript module's ProdCom URL at `http://127.0.0.1:24480`. +- ProPresenter: `node band-lineup-display/tmp/pp-mock.js 1599` — playlists + and an advancing slide index (including the arrangement-repeat case). + Point the Now/Next module at host `127.0.0.1`, port `1599`. + +**The resilience checklist** — every module must pass this before it's done: + +1. Add your tile, then **kill the upstream** (or the mock). The status dot + must go red (with a useful hover message) and the tile must show a clear + degraded state, not a freeze. +2. **Restart the upstream.** The tile must recover by itself — no reload, + no clicking. +3. **Restart ProdDash itself** with the tile open. Same expectation. +4. Add **two tiles of your module** with different per-tile settings — both + must work independently. +5. **Remove your tile**, then check the browser console and server log: no + errors, no timers still firing, no streams still open. +6. Change your admin config in `/admin` while the tile is open — the tile + must pick it up (via `onConfigChange` or the automatic remount). +7. Reload the page — the tile comes back with its settings intact. diff --git a/public/admin/index.html b/public/admin/index.html index a273555..3081a90 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -5,6 +5,7 @@ ProdDash Admin + diff --git a/public/icons/icon-180.png b/public/icons/icon-180.png new file mode 100644 index 0000000..f7475cd Binary files /dev/null and b/public/icons/icon-180.png differ diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..96ada4d Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..8dfcb4f Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/index.html b/public/index.html index 69afd10..83b26d4 100644 --- a/public/index.html +++ b/public/index.html @@ -5,6 +5,12 @@ ProdDash + + + + + + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..27b77a2 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,30 @@ +{ + "name": "ProdDash", + "short_name": "ProdDash", + "description": "Modular production dashboard for Waters Church", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#0b0e12", + "theme_color": "#0b0e12", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/public/shell.js b/public/shell.js index 6ae8ec5..f4a8412 100644 --- a/public/shell.js +++ b/public/shell.js @@ -844,3 +844,7 @@ async function boot() { } boot(); + +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js').catch(() => {}); +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..1d4af60 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,45 @@ +'use strict'; + +/* Minimal service worker: caches the shell so the PWA is installable. + Never touches /api/* (live data + SSE streams). Network-first, so open + dashboards always run the latest shell when the server is reachable. */ + +const CACHE = 'proddash-v1'; +const SHELL = [ + '/', + '/style.css', + '/shell.js', + '/manifest.webmanifest', + '/icons/icon-192.png', + '/icons/icon-512.png', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + if (event.request.method !== 'GET' || url.pathname.startsWith('/api/')) return; + + // Network first, cache fallback + event.respondWith( + fetch(event.request) + .then((res) => { + const copy = res.clone(); + caches.open(CACHE).then((cache) => cache.put(event.request, copy)); + return res; + }) + .catch(() => caches.match(event.request, { ignoreSearch: true })) + ); +}); diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..a6086fc --- /dev/null +++ b/start.bat @@ -0,0 +1,5 @@ +@echo off +rem Double-click to start ProdDash (Windows) +cd /d %~dp0 +node server.js +pause