Keep module config and layouts in a per-machine data directory

Connection settings, API keys, enabled flags and named layouts were written
to config/ inside the checkout, so a re-clone or reset from main started the
server with nothing configured. They now live in a data directory outside
the app folder:

  Windows  %APPDATA%\ProdDash
  macOS    ~/Library/Application Support/ProdDash
  other    $XDG_CONFIG_HOME/proddash, else ~/.config/proddash

PRODDASH_DATA_DIR overrides the location (a relative path resolves against
the app folder, so "config" restores the old spot). The first start after
upgrading copies an existing config/modules.json and config/layouts/ into
the data directory, so nothing has to be re-entered. If the directory cannot
be created or written, the server warns and falls back to config/ rather
than refusing to start. A proddash.json in the data directory overrides the
checked-in defaults (port, adminPasscode).

The startup banner prints the directory in use, /api/admin/state exposes it,
and the admin page shows it under the Modules heading. Admin config and
layout writes that fail on disk now report the path instead of a generic
server error. README, MODULE-GUIDE and CLAUDE.md updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
This commit is contained in:
Claude
2026-09-08 14:42:15 +00:00
parent f80790e3a1
commit 1292ba33ba
7 changed files with 184 additions and 31 deletions
+4 -1
View File
@@ -3,7 +3,10 @@
Modular production dashboard web app for Waters Church, developed in this repo. Modular production dashboard web app for Waters Church, developed in this repo.
The app lives at the repo root and is built: zero-dependency Node server The app lives at the repo root and is built: zero-dependency Node server
(`server.js`, Node 18+), dashboard shell client in `public/`, admin page at (`server.js`, Node 18+), dashboard shell client in `public/`, admin page at
`/admin`, modules in `modules/`. [SPEC.md](SPEC.md) was the authority for the `/admin`, modules in `modules/`. Runtime state (module config, named layouts)
is written to a per-machine data directory outside the checkout
(`PRODDASH_DATA_DIR` overrides; see README "Where settings are kept") — never
add code that writes runtime state into the repo folder. [SPEC.md](SPEC.md) was the authority for the
initial build; [README.md](README.md) has run/configure/use instructions. initial build; [README.md](README.md) has run/configure/use instructions.
## Building a new module — read docs/MODULE-GUIDE.md first ## Building a new module — read docs/MODULE-GUIDE.md first
+35 -6
View File
@@ -18,7 +18,8 @@ touching the shell (see [docs/MODULE-GUIDE.md](docs/MODULE-GUIDE.md)).
that's where module servers proxy ProdCom, poll ProPresenter, and stream that's where module servers proxy ProdCom, poll ProPresenter, and stream
live updates over SSE, so browsers never talk to production gear live updates over SSE, so browsers never talk to production gear
directly (no CORS, and API keys never leave the server). directly (no CORS, and API keys never leave the server).
4. Stores server-wide module config and named layouts under `config/`. 4. Stores server-wide module config and named layouts in a per-machine data
directory outside the app folder, so updating ProdDash never loses them.
Each browser arranges its own tiles (add, drag, resize, remove, per-tile Each browser arranges its own tiles (add, drag, resize, remove, per-tile
settings); the layout autosaves to that browser's `localStorage`. Named settings); the layout autosaves to that browser's `localStorage`. Named
@@ -47,16 +48,43 @@ enable/disable) live in the **admin page** — open **Admin** from the
dashboard's top bar, or go to `/admin`. Saving re-initialises the module dashboard's top bar, or go to `/admin`. Saving re-initialises the module
immediately; open tiles reconnect on their own. immediately; open tiles reconnect on their own.
Shell settings live in [config/proddash.json](config/proddash.json): ### Where settings are kept
Everything you enter in the admin page (module connection settings, API keys,
enabled/disabled flags) and every named layout is written to a **data
directory on the machine running the server** — not into the app folder:
| OS | Data directory |
| --- | --- |
| Windows | `%APPDATA%\ProdDash` (e.g. `C:\Users\<you>\AppData\Roaming\ProdDash`) |
| macOS | `~/Library/Application Support/ProdDash` |
| Linux | `$XDG_CONFIG_HOME/proddash`, else `~/.config/proddash` |
Because it lives outside the checkout, you can `git pull`, re-clone, or reset
the app folder from `main` and start the new version with everything still
configured. The admin page shows the exact path in use, and the server prints
it at startup (`Settings : …`).
The directory holds `modules.json` (module config) and `layouts/` (named
layouts), both written by the app itself. It may also hold a `proddash.json`
with this machine's shell settings — the same keys as the checked-in
[config/proddash.json](config/proddash.json), which supplies the defaults:
| Key | Meaning | | Key | Meaning |
| --- | --- | | --- | --- |
| `port` | Port ProdDash serves on (default `24500`) | | `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. | | `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. Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
`config/modules.json` (module config) and `config/layouts/` (named layouts) `PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path
are written by the app itself. is taken from the app folder; `PRODDASH_DATA_DIR=config` restores the old
in-repo location). If the default directory can't be created, the server says
so and falls back to the app's `config/` folder.
**Upgrading from an earlier version:** the first start copies an existing
`config/modules.json` and `config/layouts/` from the app folder into the data
directory, so nothing has to be re-entered. The old files are left in place and
ignored from then on.
## Using the dashboard ## Using the dashboard
@@ -130,7 +158,8 @@ ProdDash/
│ ├── clock/ │ ├── clock/
│ ├── prodcom-transcript/ │ ├── prodcom-transcript/
│ └── propresenter-now-next/ (carries its own copy of propresenter-core) │ └── propresenter-now-next/ (carries its own copy of propresenter-core)
├── config/ proddash.json + state written by the app ├── config/ proddash.json defaults (runtime state lives in the
│ 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
├── tools/prodcom-mock.js mock ProdCom API for development ├── tools/prodcom-mock.js mock ProdCom API for development
├── prodcom-listener/ reference fork — read-only, not served ├── prodcom-listener/ reference fork — read-only, not served
+5 -4
View File
@@ -72,8 +72,8 @@ Both schemas map field names to specs:
``` ```
`password` fields are admin-config only in practice: their values are stored `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 in the server's `modules.json` (in the per-machine data directory, see the
browsers** — not in `/api/modules`, and never echoed back into the admin README), 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 form. Anything upstream that needs the secret must go through your server
routes. routes.
@@ -105,8 +105,9 @@ web address where protocol or path matter (a page to embed, a webhook).
Endpoints belong in admin config, not per-tile settings — connections are Endpoints belong in admin config, not per-tile settings — connections are
server-wide by design. server-wide by design.
Admin config lives in `config/modules.json` (written by the admin page — Admin config lives in `modules.json` inside the server's data directory
your module never touches that file). Per-tile settings live inside each (written by the admin page — your module never touches that file, and it
survives updates of the app folder). Per-tile settings live inside each
browser's layout. browser's layout.
## Client entry (`client.js`) ## Client entry (`client.js`)
+12
View File
@@ -35,6 +35,18 @@ body { overflow: auto; }
margin-bottom: 14px; margin-bottom: 14px;
} }
.admin-storage { margin-top: -6px; font-size: 12px; }
.admin-storage code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11.5px;
color: var(--text);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 5px;
padding: 1px 5px;
overflow-wrap: anywhere;
}
.admin-card { .admin-card {
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
+5
View File
@@ -382,6 +382,11 @@ async function loadState() {
} }
loginCard.hidden = true; loginCard.hidden = true;
modulesSection.hidden = false; modulesSection.hidden = false;
const storageNote = document.getElementById('storage-note');
if (state.dataDir) {
document.getElementById('storage-path').textContent = state.dataDir;
storageNote.hidden = false;
}
modulesList.innerHTML = ''; modulesList.innerHTML = '';
liveEls.clear(); liveEls.clear();
for (const mod of state.modules || []) { for (const mod of state.modules || []) {
+2
View File
@@ -32,6 +32,8 @@
<p class="admin-copy">Server-wide settings, shared by every dashboard. Saving re-initialises the <p class="admin-copy">Server-wide settings, shared by every dashboard. Saving re-initialises the
module immediately — open tiles reconnect on their own. Per-tile options (filters, text size) module immediately — open tiles reconnect on their own. Per-tile options (filters, text size)
live in each tile's gear menu instead.</p> live in each tile's gear menu instead.</p>
<p class="admin-copy admin-storage" id="storage-note" hidden>Saved on the server at
<code id="storage-path"></code> — outside the app folder, so updating ProdDash keeps them.</p>
<div id="modules-list"></div> <div id="modules-list"></div>
</section> </section>
+114 -13
View File
@@ -4,13 +4,16 @@
* *
* Shell server: serves the dashboard client from public/, discovers modules * Shell server: serves the dashboard client from public/, discovers modules
* in modules/, mounts each enabled module's server routes under * in modules/, mounts each enabled module's server routes under
* /api/modules/<id>/, and stores server-side config + named layouts under * /api/modules/<id>/, and stores server-side module config + named layouts
* config/. * in a per-machine data directory (see DATA_DIR below) so that updating the
* checkout never loses them.
* *
* Zero dependencies — Node built-ins only (Node 18+ for global fetch). * Zero dependencies — Node built-ins only (Node 18+ for global fetch).
* Configure via config/proddash.json or env vars: * Configure via config/proddash.json (defaults, in the repo), an optional
* proddash.json in the data directory (this machine's overrides), or env vars:
* PORT (default: value in proddash.json, else 24500) * PORT (default: value in proddash.json, else 24500)
* PRODDASH_PASSCODE (overrides adminPasscode in proddash.json) * PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
* PRODDASH_DATA_DIR (where module config + layouts are kept; see below)
*/ */
'use strict'; 'use strict';
@@ -22,9 +25,54 @@ const crypto = require('crypto');
const ROOT = __dirname; const ROOT = __dirname;
const PUBLIC_DIR = path.join(ROOT, 'public'); const PUBLIC_DIR = path.join(ROOT, 'public');
const CONFIG_DIR = path.join(ROOT, 'config');
const MODULES_DIR = path.join(ROOT, 'modules'); const MODULES_DIR = path.join(ROOT, 'modules');
const MODULES_CONFIG_PATH = path.join(CONFIG_DIR, 'modules.json'); /** Checked-in defaults (proddash.json) — and where runtime state used to live. */
const REPO_CONFIG_DIR = path.join(ROOT, 'config');
/* ── data directory ─────────────────────────────────────────────────── */
/**
* Everything the app writes — module config (connection settings, API keys,
* enabled flags) and named layouts — lives in a per-machine data directory
* OUTSIDE the checkout. Pulling, re-cloning or resetting the app folder from
* main therefore never touches it, and nothing has to be re-entered in /admin.
*
* PRODDASH_DATA_DIR overrides the location. A relative path resolves
* against the app folder ("config" = the old in-repo spot).
* default Windows %APPDATA%\ProdDash
* macOS ~/Library/Application Support/ProdDash
* other $XDG_CONFIG_HOME/proddash, else ~/.config/proddash
*
* If the directory cannot be created or written, the server says so and
* falls back to the in-repo config/ folder rather than refusing to start.
*/
function defaultDataDir() {
const home = os.homedir();
if (process.platform === 'win32') {
return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash');
}
if (process.platform === 'darwin') {
return path.join(home, 'Library', 'Application Support', 'ProdDash');
}
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash');
}
function resolveDataDir() {
const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
const dir = fromEnv ? path.resolve(ROOT, fromEnv) : defaultDataDir();
try {
fs.mkdirSync(dir, { recursive: true });
fs.accessSync(dir, fs.constants.W_OK);
return dir;
} catch (err) {
console.warn(`[proddash] cannot use data directory ${dir} (${err.message}) — falling back to ${REPO_CONFIG_DIR}`);
return REPO_CONFIG_DIR;
}
}
const DATA_DIR = resolveDataDir();
const MODULES_CONFIG_PATH = path.join(DATA_DIR, 'modules.json');
const LAYOUTS_DIR = path.join(DATA_DIR, 'layouts');
/* ── config files ───────────────────────────────────────────────────── */ /* ── config files ───────────────────────────────────────────────────── */
@@ -43,7 +91,52 @@ function writeJson(file, value) {
fs.renameSync(tmp, file); // atomic-ish: never leave a half-written config fs.renameSync(tmp, file); // atomic-ish: never leave a half-written config
} }
const shellConfig = readJson(path.join(CONFIG_DIR, 'proddash.json'), {}); /**
* One-time move-in. Earlier versions kept modules.json and layouts/ inside the
* repo's config/ folder; if the data directory has none yet and those files
* exist, copy them across (the originals are left alone).
*/
function migrateLegacyState() {
if (DATA_DIR === REPO_CONFIG_DIR) return;
const moved = [];
const oldModules = path.join(REPO_CONFIG_DIR, 'modules.json');
if (!fs.existsSync(MODULES_CONFIG_PATH) && fs.existsSync(oldModules)) {
try {
fs.copyFileSync(oldModules, MODULES_CONFIG_PATH);
moved.push('modules.json');
} catch (err) {
console.warn(`[proddash] could not copy ${oldModules} into the data directory: ${err.message}`);
}
}
const oldLayouts = path.join(REPO_CONFIG_DIR, 'layouts');
let oldFiles = [];
try {
oldFiles = fs.readdirSync(oldLayouts).filter((f) => f.endsWith('.json'));
} catch { /* none to move */ }
let haveLayouts = false;
try {
haveLayouts = fs.readdirSync(LAYOUTS_DIR).some((f) => f.endsWith('.json'));
} catch { /* no layouts dir yet */ }
if (oldFiles.length && !haveLayouts) {
try {
fs.mkdirSync(LAYOUTS_DIR, { recursive: true });
for (const f of oldFiles) fs.copyFileSync(path.join(oldLayouts, f), path.join(LAYOUTS_DIR, f));
moved.push(`${oldFiles.length} layout${oldFiles.length === 1 ? '' : 's'}`);
} catch (err) {
console.warn(`[proddash] could not copy layouts into the data directory: ${err.message}`);
}
}
if (moved.length) {
console.log(`[proddash] moved ${moved.join(' and ')} from ${REPO_CONFIG_DIR} into ${DATA_DIR}`);
}
}
migrateLegacyState();
/** Shell settings: checked-in defaults, then this machine's overrides on top. */
const shellConfig = {
...readJson(path.join(REPO_CONFIG_DIR, 'proddash.json'), {}),
...readJson(path.join(DATA_DIR, 'proddash.json'), {}),
};
const PORT = Number(process.env.PORT || shellConfig.port || 24500); const PORT = Number(process.env.PORT || shellConfig.port || 24500);
/** Per-module server-wide state: { "<id>": { enabled: bool, config: {…} } } */ /** Per-module server-wide state: { "<id>": { enabled: bool, config: {…} } } */
@@ -453,6 +546,7 @@ async function handleAdminApi(req, res, urlPath) {
return sendJson(res, 200, { return sendJson(res, 200, {
authRequired: Boolean(ADMIN_PASSCODE), authRequired: Boolean(ADMIN_PASSCODE),
authed: true, authed: true,
dataDir: DATA_DIR,
modules: [...manifests.keys()].sort().map(adminModuleView), modules: [...manifests.keys()].sort().map(adminModuleView),
}); });
} }
@@ -468,15 +562,19 @@ async function handleAdminApi(req, res, urlPath) {
} catch { } catch {
return sendJson(res, 400, { error: 'Malformed request.' }); return sendJson(res, 400, { error: 'Malformed request.' });
} }
try {
if (m[2] === 'config') { if (m[2] === 'config') {
applyConfigPatch(id, body.config); applyConfigPatch(id, body.config);
remountModule(id); // config changes take effect without a manual restart
} else { } else {
const enabled = Boolean(body.enabled); modulesConfig[id] = { ...moduleState(id), enabled: Boolean(body.enabled) };
modulesConfig[id] = { ...moduleState(id), enabled };
writeJson(MODULES_CONFIG_PATH, modulesConfig); writeJson(MODULES_CONFIG_PATH, modulesConfig);
remountModule(id); // mounts when enabled, unmounts (only) otherwise
} }
} catch (err) {
return sendJson(res, 500, { error: `Could not save ${MODULES_CONFIG_PATH}: ${err?.message || err}` });
}
// Config changes take effect without a manual restart; a toggle mounts when
// enabled and unmounts (only) otherwise.
remountModule(id);
broadcastShellEvent('modules-changed', { id }); broadcastShellEvent('modules-changed', { id });
return sendJson(res, 200, { ok: true, module: adminModuleView(id) }); return sendJson(res, 200, { ok: true, module: adminModuleView(id) });
} }
@@ -484,9 +582,7 @@ async function handleAdminApi(req, res, urlPath) {
sendJson(res, 404, { error: 'Unknown admin endpoint.' }); sendJson(res, 404, { error: 'Unknown admin endpoint.' });
} }
/* ── named layouts (JSON files under config/layouts/) ───────────────── */ /* ── named layouts (JSON files under <data dir>/layouts/) ───────────── */
const LAYOUTS_DIR = path.join(CONFIG_DIR, 'layouts');
/** Names double as file names — keep them boring on purpose. */ /** Names double as file names — keep them boring on purpose. */
function layoutNameOk(name) { function layoutNameOk(name) {
@@ -564,7 +660,11 @@ async function handleLayoutsApi(req, res, urlPath) {
} }
const layout = sanitizeLayout(body.layout); const layout = sanitizeLayout(body.layout);
if (!layout.tiles.length) return sendJson(res, 400, { error: 'Refusing to save an empty layout.' }); if (!layout.tiles.length) return sendJson(res, 400, { error: 'Refusing to save an empty layout.' });
try {
writeJson(layoutPath(name), layout); writeJson(layoutPath(name), layout);
} catch (err) {
return sendJson(res, 500, { error: `Could not save ${layoutPath(name)}: ${err?.message || err}` });
}
return sendJson(res, 200, { ok: true, name }); return sendJson(res, 200, { ok: true, name });
} }
@@ -824,6 +924,7 @@ server.listen(PORT, '0.0.0.0', () => {
console.log(ids.length console.log(ids.length
? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)')).join(', ') ? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)')).join(', ')
: ' Modules : none installed'); : ' Modules : none installed');
console.log(' Settings : ' + DATA_DIR + (DATA_DIR === REPO_CONFIG_DIR ? ' (inside the app folder)' : ''));
}); });
for (const signal of ['SIGINT', 'SIGTERM']) { for (const signal of ['SIGINT', 'SIGTERM']) {