Docs, README, launchers, PWA install
Milestone 7. docs/MODULE-GUIDE.md documents the whole module contract from an author's point of view — manifest reference, client factory + moduleApi, server init/routes/health, scoping and resilience rules, a copy-paste minimal module, and a testing section with the two mocks and a resilience checklist. README rewritten with run/configure/use instructions in the prodcom-listener style, plus double-click launchers for macOS and Windows. PWA: manifest, icons, and a minimal network-first service worker that never touches /api/*. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
This commit is contained in:
@@ -1,27 +1,129 @@
|
|||||||
# ProdDash
|
# ProdDash
|
||||||
|
|
||||||
A new web application, built on lessons and code from two existing Waters Church
|
A modular **production dashboard** for Waters Church. Any browser on the
|
||||||
production tools, which are included here as reference forks:
|
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/<id>/`.
|
||||||
|
3. Mounts each enabled module's server routes under `/api/modules/<id>/` —
|
||||||
|
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://<this-machine's-IP>: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/
|
ProdDash/
|
||||||
├── prodcom-listener/ fork of ProdCom Listener — live transcript web app
|
├── server.js shell server: static files, module discovery/mounting,
|
||||||
│ (zero-dependency Node server + SSE proxy to the ProdCom API)
|
│ admin + layouts APIs
|
||||||
└── band-lineup-display/ fork of BandLineupDisplay — backstage stage-lineup Electron app,
|
├── public/ dashboard shell client (index.html, shell.js, style.css)
|
||||||
plus the shared ProPresenter 7/20 client (propresenter-core/)
|
│ └── admin/ admin page client
|
||||||
and the standalone Now/Next display (propresenter-app/)
|
├── 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
|
The two reference folders are snapshots of the apps ProdDash grew out of;
|
||||||
reference folders are snapshots (build output and `node_modules` excluded) and
|
they are not wired into anything (but `band-lineup-display/tmp/pp-mock.js`
|
||||||
are not wired into a build.
|
is handy for testing).
|
||||||
|
|
||||||
## 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).
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
# Double-click to start ProdDash (macOS)
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
exec node server.js
|
||||||
@@ -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/<id>/
|
||||||
|
├── 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/<id>/
|
||||||
|
```
|
||||||
|
|
||||||
|
**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": <value>,
|
||||||
|
"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/<id>/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/<id>/
|
||||||
|
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 = '<div class="hc-time"></div>';
|
||||||
|
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/<id>/...` — your routes, `curl`-able. For SSE:
|
||||||
|
`curl -N http://localhost:24500/api/modules/<id>/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.
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>ProdDash Admin</title>
|
<title>ProdDash Admin</title>
|
||||||
<meta name="theme-color" content="#0b0e12">
|
<meta name="theme-color" content="#0b0e12">
|
||||||
|
<link rel="icon" href="/icons/icon-192.png">
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
<link rel="stylesheet" href="/admin/admin.css">
|
<link rel="stylesheet" href="/admin/admin.css">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -5,6 +5,12 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>ProdDash</title>
|
<title>ProdDash</title>
|
||||||
<meta name="theme-color" content="#0b0e12">
|
<meta name="theme-color" content="#0b0e12">
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
|
<link rel="icon" href="/icons/icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" href="/icons/icon-180.png">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="ProdDash">
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -844,3 +844,7 @@ async function boot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
boot();
|
boot();
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }))
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user