Merge pull request #8 from michaelmcgary2008/claude/proddash-app-build-gnwq7y

Per-machine data directory; bottom-edge height pill; fullscreen columns to the bottom; menubar tab fix
This commit is contained in:
Michael McGary
2026-09-09 15:07:54 -04:00
committed by GitHub
10 changed files with 404 additions and 80 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
+54 -11
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,28 +48,69 @@ 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
- **+** — 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 **title bar** to move it; drag the **corner handle**
to resize; **✕** removes it. Tiles sit flush — when two share an edge,
hover the seam and a small grab pill appears; drag it to trade width
between the two tiles (macOS split-view style).
- Drag a tile by its **title bar** to move it; drag either **bottom
corner** to resize; **✕** removes it. Hover a tile and a small pill
appears on its **bottom edge** — drag it to change just the height. If
another tile sits directly below, the pill moves the shared edge instead,
trading rows between the two so the column keeps its total height. Tiles
sit flush — when two share a vertical edge, hover the seam and the same
kind of pill appears; drag it to trade width (macOS split-view style).
- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (its
green tab brings it back, minus the Admin button), the occupied columns
stretch to the full width, and every column runs to the
bottom of the screen — the last tile in each column is stretched to end
there. A short tile like the clock is never stretched into a ribbon: under
another tile it slides to the bottom edge and the tile above grows into the
gap; alone in its columns it stays as it is. Nothing is saved — the layout
reverts when you leave fullscreen. A layout deeper than the screen keeps
its normal row height and scrolls.
- A tile's actions (menus, text size, clear…) live in its **title bar**;
the small **notch** on the bar's bottom edge hides/shows the whole bar
for a clean wall-display look.
for a clean wall-display look. The menu bar has the same kind of tab —
the green one under the ProdDash name, kept at the left so it never sits
on top of a tile's own tab.
- The **gear** on a tile holds per-tile settings (filters, text size,
display options) — these belong to this browser's layout, not the server.
- The **grid icon** — save the current arrangement to the server under a
@@ -130,7 +172,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>
+1 -1
View File
@@ -42,7 +42,7 @@
<div class="menu" id="layout-menu" hidden></div>
</div>
<a class="tb-btn" href="/admin/" title="Configure modules (server-wide)">
<a class="tb-btn" id="admin-link" href="/admin/" title="Configure modules (server-wide)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm8.4-2.1.1-1.4-.1-1.4 1.7-1.3-1.6-2.8-2 .7a7.9 7.9 0 0 0-2.4-1.4l-.3-2.1H11l-.3 2.1a7.9 7.9 0 0 0-2.4 1.4l-2-.7L4.7 9.3 6.4 10.6l-.1 1.4.1 1.4-1.7 1.3 1.6 2.8 2-.7a7.9 7.9 0 0 0 2.4 1.4l.3 2.1h2.8l.3-2.1a7.9 7.9 0 0 0 2.4-1.4l2 .7 1.6-2.8-1.7-1.3Z"/>
</svg>
+149 -31
View File
@@ -277,6 +277,11 @@ function buildTile(tile) {
resizeLeft.className = 'tile-resize-left';
resizeLeft.title = 'Drag to resize';
// pill on the bottom edge: moves that edge (trading rows with tiles below)
const resizeBottom = document.createElement('div');
resizeBottom.className = 'tile-resize-bottom';
resizeBottom.title = 'Drag to adjust height';
// notch on the title bar's bottom edge: hides/shows the bar
const notch = document.createElement('button');
notch.className = 'tile-notch';
@@ -288,7 +293,7 @@ function buildTile(tile) {
saveLayout();
});
el.append(head, body, resize, resizeLeft, notch);
el.append(head, body, resize, resizeLeft, resizeBottom, notch);
el.classList.toggle('head-hidden', Boolean(tile.headHidden));
grid.appendChild(el);
@@ -299,6 +304,7 @@ function buildTile(tile) {
wireDrag(tile, el, head);
wireResize(tile, el, resize, false);
wireResize(tile, el, resizeLeft, true);
wireBottomResize(tile, el, resizeBottom);
mountModule(tile);
return entry;
@@ -429,6 +435,79 @@ function wireResize(tile, el, handle, fromLeft) {
});
}
/* ── drag the bottom edge: height only ──────────────────────────────── */
/* The pill on a tile's bottom edge moves that edge. When other tiles sit
directly below it (sharing the edge), the drag trades rows with them —
the column's total height stays the same, like the seam pill trades
columns. With nothing directly below, it simply changes this tile's
height. */
function tilesDirectlyBelow(tile) {
return tiles.filter((t) => t !== tile
&& t.y === tile.y + tile.h
&& t.x < tile.x + tile.w && tile.x < t.x + t.w);
}
function wireBottomResize(tile, el, handle) {
handle.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
if (document.body.classList.contains('fullscreen')) return;
ev.preventDefault();
ev.stopPropagation();
closeSettingsPopovers();
handle.setPointerCapture(ev.pointerId);
const m = cellMetrics();
const sy = ev.clientY;
const startH = tile.h;
const below = tilesDirectlyBelow(tile).map((t) => ({ t, bottom: t.y + t.h }));
const group = [tile.id, ...below.map((b) => b.t.id)];
const minH = minSizeOf(tile).h;
// the edge can rise until this tile hits its minimum, and drop until the
// shortest neighbour below hits its own
const maxH = below.length
? Math.min(...below.map((b) => b.bottom - minSizeOf(b.t).h)) - tile.y
: Infinity;
el.classList.add('resizing');
grid.classList.add('no-dividers');
let changed = false;
const onMove = (e) => {
const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
const h = Math.max(minH, Math.min(maxH, startH + dRows));
if (h === tile.h || h > maxH) return;
const edge = tile.y + h;
const candSelf = { x: tile.x, y: tile.y, w: tile.w, h };
const candBelow = below.map((b) => ({ x: b.t.x, y: edge, w: b.t.w, h: b.bottom - edge }));
if (!isFreeExcept(candSelf, group) || candBelow.some((c) => !isFreeExcept(c, group))) return;
tile.h = h;
applyRect(tile);
notifyResize(tile);
below.forEach((b, i) => {
b.t.y = candBelow[i].y;
b.t.h = candBelow[i].h;
applyRect(b.t);
notifyResize(b.t);
});
changed = true;
};
const onUp = () => {
handle.removeEventListener('pointermove', onMove);
handle.removeEventListener('pointerup', onUp);
handle.removeEventListener('pointercancel', onUp);
el.classList.remove('resizing');
grid.classList.remove('no-dividers');
if (changed) {
saveLayout();
rebuildDividers();
}
};
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp);
handle.addEventListener('pointercancel', onUp);
});
}
function notifyResize(tile) {
const entry = tileEls.get(tile.id);
try {
@@ -1079,27 +1158,68 @@ try {
/* ── fullscreen: auto-fit the layout to the screen ──────────────────── */
/* Fullscreen is a viewing mode. The occupied columns stretch proportionally
to the full width; tiles whose bottom edge lands near the bottom of the
viewport are stretched to end exactly at it; when nothing extends below
the viewport, scrolling is disabled. All of it is visual-only — the saved
to the full width, and every column runs to the bottom of the screen: the
bottom-most tile of each column stretches to end exactly there. A small
tile (the clock) is never stretched into a ribbon — with a tile above it,
it drops to the bottom edge and the tile above grows into the gap; alone
in its columns, it stays exactly as it is. When nothing extends below the
viewport, scrolling is disabled. All of it is visual-only — the saved
layout is untouched and everything reverts on exit. */
const fsBtn = document.getElementById('fs-btn');
/** Rows this close to the viewport bottom count as "near" and get stretched. */
const FS_NEAR_ROWS = 2;
/** Tiles this short (the clock) are moved, never stretched, in fullscreen. */
const FS_SMALL_ROWS = 2;
let menuHiddenBeforeFs = false;
function fsSmallExempt(tile) {
// A small tile (like the clock) alone in its columns must not become a
// full-height ribbon.
if (tile.h > 2) return false;
return !tiles.some((t) => t !== tile && t.x < tile.x + tile.w && tile.x < t.x + t.w);
function colsOverlap(a, b) {
return a.x < b.x + b.w && b.x < a.x + a.w;
}
function fsSmall(tile) {
return tile.h <= FS_SMALL_ROWS;
}
function fsAloneInColumns(tile) {
return !tiles.some((t) => t !== tile && colsOverlap(t, tile));
}
function fsBottomMost(tile) {
return !tiles.some((t) => t !== tile
&& t.x < tile.x + tile.w && tile.x < t.x + t.w
&& t.y >= tile.y + tile.h);
return !tiles.some((t) => t !== tile && colsOverlap(t, tile) && t.y >= tile.y + tile.h);
}
/**
* Fullscreen row placement for every tile, given R rows on screen:
* { id -> { y, h } }. Only bottom-most tiles change (stretch, or drop when
* small), plus the tile directly above a dropped small one, which grows
* into the space it left — stopping at anything else below it.
*/
function fullscreenRows(R) {
const rows = new Map(tiles.map((t) => [t.id, { y: t.y, h: t.h }]));
const dropped = [];
for (const tile of tiles) {
if (!fsBottomMost(tile)) continue;
if (!fsSmall(tile)) {
rows.get(tile.id).h = R - tile.y;
} else if (!fsAloneInColumns(tile) && R - tile.h > tile.y) {
rows.get(tile.id).y = R - tile.h;
dropped.push(tile);
}
}
for (const small of dropped) {
const gapTop = rows.get(small.id).y;
for (const above of tiles) {
if (above === small || fsSmall(above) || !colsOverlap(above, small)) continue;
if (above.y + above.h !== small.y) continue; // only a tile touching it
let limit = gapTop;
for (const other of tiles) {
if (other === above || other === small || !colsOverlap(above, other)) continue;
const oy = rows.get(other.id).y;
if (oy >= above.y + above.h) limit = Math.min(limit, oy);
}
rows.get(above.id).h = Math.max(above.h, limit - above.y);
}
}
return rows;
}
function applyFullscreenFit() {
@@ -1128,16 +1248,14 @@ function applyFullscreenFit() {
const cellH = (wrapH - (R - 1) * m.gap) / R;
grid.style.gridAutoRows = cellH + 'px';
const rows = fullscreenRows(R);
for (const tile of tiles) {
applyRect(tile); // reset any previous stretch first
const bottom = tile.y + tile.h;
if (R - bottom <= FS_NEAR_ROWS && fsBottomMost(tile) && !fsSmallExempt(tile)) {
const entry = tileEls.get(tile.id);
if (entry) {
entry.el.style.gridRow = `${tile.y + 1} / span ${R - tile.y}`;
try { entry.instance?.onResize?.(tile.w, R - tile.y); } catch { /* module's problem */ }
}
}
const entry = tileEls.get(tile.id);
if (!entry) continue;
const r = rows.get(tile.id);
entry.el.style.gridColumn = `${tile.x + 1} / span ${tile.w}`;
entry.el.style.gridRow = `${r.y + 1} / span ${r.h}`;
try { entry.instance?.onResize?.(tile.w, r.h); } catch { /* module's problem, not the shell's */ }
}
}
@@ -1174,14 +1292,14 @@ document.addEventListener('fullscreenchange', () => {
}
});
window.addEventListener('resize', () => {
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
});
// the menubar collapse animates, so the grid's final height arrives late
topbar.addEventListener('transitionend', () => {
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
});
// The grid area's size settles late: the browser animates into fullscreen,
// then the menubar collapse animates. Re-fit whenever its size changes.
let fsRefit = 0;
new ResizeObserver(() => {
if (!document.body.classList.contains('fullscreen')) return;
cancelAnimationFrame(fsRefit);
fsRefit = requestAnimationFrame(applyFullscreenFit);
}).observe(gridwrap);
/* ── live updates from the admin page ───────────────────────────────── */
+51 -12
View File
@@ -54,25 +54,30 @@ body.menu-hidden #topbar {
overflow: hidden;
}
/* the menubar's own show/hide tab, same as the tile notches */
/* The menubar's own show/hide tab. It sits under the ProdDash brand at the
left — not screen-centre, where a tile's own (centred) tab would land
right under it once both bars are hidden — and wears the accent colour
so it never reads as a tile tab. */
#topbar-notch {
position: fixed;
top: var(--topbar-h, 46px);
left: 50%;
transform: translateX(-50%);
/* straddles the bar's bottom edge — mostly on the bar, a short lip below
it — so it never covers a top-row tile's status dot or title */
top: calc(var(--topbar-h, 46px) - 8px);
left: 12px;
width: 54px;
height: 14px;
padding: 0;
background: var(--panel);
border: 1px solid var(--border);
border-top: none;
background: var(--accent);
border: none;
border-radius: 0 0 10px 10px;
cursor: pointer;
z-index: 60;
opacity: 0.35;
transition: opacity 0.15s ease, top 0.22s ease;
opacity: 0.55;
transition: opacity 0.15s ease, top 0.22s ease, height 0.22s ease;
}
#topbar-notch:hover { opacity: 1; }
/* bar hidden: a slim tab at the very top edge, clear of a top-row tile's dot and title */
body.menu-hidden #topbar-notch { top: 0; height: 10px; }
#topbar-notch::after {
content: "";
display: block;
@@ -80,9 +85,8 @@ body.menu-hidden #topbar {
height: 3px;
margin: 4px auto 0;
border-radius: 999px;
background: var(--muted);
background: var(--bg);
}
#topbar-notch:hover::after { background: var(--text); }
#brand {
font-weight: 800;
@@ -411,6 +415,39 @@ body.menu-hidden #topbar {
border-radius: 0 0 0 3px;
}
/* bottom-edge pill: drag to move the tile's bottom edge (height only);
sits between the two corner handles */
.tile-resize-bottom {
position: absolute;
left: 24px;
right: 24px;
bottom: 0;
height: 10px;
z-index: 5;
display: flex;
align-items: flex-end;
justify-content: center;
cursor: ns-resize;
touch-action: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.tile-resize-bottom::after {
content: "";
width: 44px;
max-width: 60%;
height: 5px;
margin-bottom: 2px;
border-radius: 999px;
background: var(--muted);
transition: background 0.15s ease;
}
.tile:hover .tile-resize-bottom { opacity: 0.6; }
.tile .tile-resize-bottom:hover,
.tile.resizing .tile-resize-bottom { opacity: 1; }
.tile .tile-resize-bottom:hover::after,
.tile.resizing .tile-resize-bottom::after { background: var(--text); }
/* ── seam divider between adjacent tiles ───────────────────────────── */
/* When two tiles share a vertical edge, an invisible strip sits on the seam;
@@ -452,7 +489,9 @@ body.menu-hidden #topbar {
editing affordances (corner handles, seam pills) are parked. */
body.fullscreen .tile-resize,
body.fullscreen .tile-resize-left,
body.fullscreen .tile-divider { display: none; }
body.fullscreen .tile-resize-bottom,
body.fullscreen .tile-divider,
body.fullscreen #admin-link { display: none; }
body.fullscreen #grid { padding-bottom: 0; }
#gridwrap.no-scroll { overflow-y: hidden; }
+121 -20
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:
* 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: { "<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.' });
}
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 <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.' });
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']) {