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.
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
`/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.
## 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
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/`.
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
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
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 |
| --- | --- |
| `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.
Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
`PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path
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
@@ -130,7 +158,8 @@ ProdDash/
│ ├── clock/
│ ├── prodcom-transcript/
│ └── 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
├── tools/prodcom-mock.js mock ProdCom API for development
├── 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
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
in the server's `modules.json` (in the per-machine data directory, see the
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
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
server-wide by design.
Admin config lives in `config/modules.json` (written by the admin page —
your module never touches that file). Per-tile settings live inside each
Admin config lives in `modules.json` inside the server's data directory
(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.
## Client entry (`client.js`)
+12
View File
@@ -35,6 +35,18 @@ body { overflow: auto; }
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 {
background: var(--panel);
border: 1px solid var(--border);
+5
View File
@@ -382,6 +382,11 @@ async function loadState() {
}
loginCard.hidden = true;
modulesSection.hidden = false;
const storageNote = document.getElementById('storage-note');
if (state.dataDir) {
document.getElementById('storage-path').textContent = state.dataDir;
storageNote.hidden = false;
}
modulesList.innerHTML = '';
liveEls.clear();
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
module immediately — open tiles reconnect on their own. Per-tile options (filters, text size)
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>
</section>
+114 -13
View File
@@ -4,13 +4,16 @@
*
* Shell server: serves the dashboard client from public/, discovers modules
* in modules/, mounts each enabled module's server routes under
* /api/modules/<id>/, and stores server-side config + named layouts under
* config/.
* /api/modules/<id>/, and stores server-side module config + named layouts
* 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).
* 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)
* PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
* PRODDASH_DATA_DIR (where module config + layouts are kept; see below)
*/
'use strict';
@@ -22,9 +25,54 @@ const crypto = require('crypto');
const ROOT = __dirname;
const PUBLIC_DIR = path.join(ROOT, 'public');
const CONFIG_DIR = path.join(ROOT, 'config');
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 ───────────────────────────────────────────────────── */
@@ -43,7 +91,52 @@ function writeJson(file, value) {
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);
/** Per-module server-wide state: { "<id>": { enabled: bool, config: {…} } } */
@@ -453,6 +546,7 @@ async function handleAdminApi(req, res, urlPath) {
return sendJson(res, 200, {
authRequired: Boolean(ADMIN_PASSCODE),
authed: true,
dataDir: DATA_DIR,
modules: [...manifests.keys()].sort().map(adminModuleView),
});
}
@@ -468,15 +562,19 @@ async function handleAdminApi(req, res, urlPath) {
} catch {
return sendJson(res, 400, { error: 'Malformed request.' });
}
try {
if (m[2] === 'config') {
applyConfigPatch(id, body.config);
remountModule(id); // config changes take effect without a manual restart
} else {
const enabled = Boolean(body.enabled);
modulesConfig[id] = { ...moduleState(id), enabled };
modulesConfig[id] = { ...moduleState(id), enabled: Boolean(body.enabled) };
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 });
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.' });
}
/* ── named layouts (JSON files under config/layouts/) ───────────────── */
const LAYOUTS_DIR = path.join(CONFIG_DIR, 'layouts');
/* ── named layouts (JSON files under <data dir>/layouts/) ───────────── */
/** Names double as file names — keep them boring on purpose. */
function layoutNameOk(name) {
@@ -564,7 +660,11 @@ async function handleLayoutsApi(req, res, urlPath) {
}
const layout = sanitizeLayout(body.layout);
if (!layout.tiles.length) return sendJson(res, 400, { error: 'Refusing to save an empty layout.' });
try {
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 });
}
@@ -824,6 +924,7 @@ server.listen(PORT, '0.0.0.0', () => {
console.log(ids.length
? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)')).join(', ')
: ' Modules : none installed');
console.log(' Settings : ' + DATA_DIR + (DATA_DIR === REPO_CONFIG_DIR ? ' (inside the app folder)' : ''));
});
for (const signal of ['SIGINT', 'SIGTERM']) {