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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 || []) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
Reference in New Issue
Block a user