diff --git a/CLAUDE.md b/CLAUDE.md index 56f7f2a..f08eb4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index c2b5201..925b752 100644 --- a/README.md +++ b/README.md @@ -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\\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 diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 87786db..6741fa1 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -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`) diff --git a/public/admin/admin.css b/public/admin/admin.css index 2e54255..4a7c0e7 100644 --- a/public/admin/admin.css +++ b/public/admin/admin.css @@ -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); diff --git a/public/admin/admin.js b/public/admin/admin.js index fdcd56c..9f43bbc 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -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 || []) { diff --git a/public/admin/index.html b/public/admin/index.html index 3081a90..c386ec5 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -32,6 +32,8 @@

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.

+
diff --git a/server.js b/server.js index 8204103..98be267 100644 --- a/server.js +++ b/server.js @@ -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//, and stores server-side config + named layouts under - * config/. + * /api/modules//, 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: - * PORT (default: value in proddash.json, else 24500) - * PRODDASH_PASSCODE (overrides adminPasscode in proddash.json) + * 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: { "": { 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.' }); } - 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 }; - writeJson(MODULES_CONFIG_PATH, modulesConfig); - remountModule(id); // mounts when enabled, unmounts (only) otherwise + try { + if (m[2] === 'config') { + applyConfigPatch(id, body.config); + } else { + modulesConfig[id] = { ...moduleState(id), enabled: Boolean(body.enabled) }; + writeJson(MODULES_CONFIG_PATH, modulesConfig); + } + } 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 /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.' }); - writeJson(layoutPath(name), 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']) {