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:
@@ -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
|
||||||
|
|||||||
@@ -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,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
|
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
|
||||||
|
|
||||||
- **+** — add any enabled module; multiple tiles of the same module are
|
- **+** — add any enabled module; multiple tiles of the same module are
|
||||||
fine (e.g. two transcripts filtered to different channels).
|
fine (e.g. two transcripts filtered to different channels).
|
||||||
- Drag a tile by its **title bar** to move it; drag the **corner handle**
|
- Drag a tile by its **title bar** to move it; drag either **bottom
|
||||||
to resize; **✕** removes it. Tiles sit flush — when two share an edge,
|
corner** to resize; **✕** removes it. Hover a tile and a small pill
|
||||||
hover the seam and a small grab pill appears; drag it to trade width
|
appears on its **bottom edge** — drag it to change just the height. If
|
||||||
between the two tiles (macOS split-view style).
|
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**;
|
- 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
|
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,
|
- The **gear** on a tile holds per-tile settings (filters, text size,
|
||||||
display options) — these belong to this browser's layout, not the server.
|
display options) — these belong to this browser's layout, not the server.
|
||||||
- The **grid icon** — save the current arrangement to the server under a
|
- The **grid icon** — save the current arrangement to the server under a
|
||||||
@@ -130,7 +172,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
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 || []) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@
|
|||||||
<div class="menu" id="layout-menu" hidden></div>
|
<div class="menu" id="layout-menu" hidden></div>
|
||||||
</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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
|
|||||||
+149
-31
@@ -277,6 +277,11 @@ function buildTile(tile) {
|
|||||||
resizeLeft.className = 'tile-resize-left';
|
resizeLeft.className = 'tile-resize-left';
|
||||||
resizeLeft.title = 'Drag to resize';
|
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
|
// notch on the title bar's bottom edge: hides/shows the bar
|
||||||
const notch = document.createElement('button');
|
const notch = document.createElement('button');
|
||||||
notch.className = 'tile-notch';
|
notch.className = 'tile-notch';
|
||||||
@@ -288,7 +293,7 @@ function buildTile(tile) {
|
|||||||
saveLayout();
|
saveLayout();
|
||||||
});
|
});
|
||||||
|
|
||||||
el.append(head, body, resize, resizeLeft, notch);
|
el.append(head, body, resize, resizeLeft, resizeBottom, notch);
|
||||||
el.classList.toggle('head-hidden', Boolean(tile.headHidden));
|
el.classList.toggle('head-hidden', Boolean(tile.headHidden));
|
||||||
grid.appendChild(el);
|
grid.appendChild(el);
|
||||||
|
|
||||||
@@ -299,6 +304,7 @@ function buildTile(tile) {
|
|||||||
wireDrag(tile, el, head);
|
wireDrag(tile, el, head);
|
||||||
wireResize(tile, el, resize, false);
|
wireResize(tile, el, resize, false);
|
||||||
wireResize(tile, el, resizeLeft, true);
|
wireResize(tile, el, resizeLeft, true);
|
||||||
|
wireBottomResize(tile, el, resizeBottom);
|
||||||
|
|
||||||
mountModule(tile);
|
mountModule(tile);
|
||||||
return entry;
|
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) {
|
function notifyResize(tile) {
|
||||||
const entry = tileEls.get(tile.id);
|
const entry = tileEls.get(tile.id);
|
||||||
try {
|
try {
|
||||||
@@ -1079,27 +1158,68 @@ try {
|
|||||||
/* ── fullscreen: auto-fit the layout to the screen ──────────────────── */
|
/* ── fullscreen: auto-fit the layout to the screen ──────────────────── */
|
||||||
|
|
||||||
/* Fullscreen is a viewing mode. The occupied columns stretch proportionally
|
/* Fullscreen is a viewing mode. The occupied columns stretch proportionally
|
||||||
to the full width; tiles whose bottom edge lands near the bottom of the
|
to the full width, and every column runs to the bottom of the screen: the
|
||||||
viewport are stretched to end exactly at it; when nothing extends below
|
bottom-most tile of each column stretches to end exactly there. A small
|
||||||
the viewport, scrolling is disabled. All of it is visual-only — the saved
|
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. */
|
layout is untouched and everything reverts on exit. */
|
||||||
|
|
||||||
const fsBtn = document.getElementById('fs-btn');
|
const fsBtn = document.getElementById('fs-btn');
|
||||||
/** Rows this close to the viewport bottom count as "near" and get stretched. */
|
/** Tiles this short (the clock) are moved, never stretched, in fullscreen. */
|
||||||
const FS_NEAR_ROWS = 2;
|
const FS_SMALL_ROWS = 2;
|
||||||
let menuHiddenBeforeFs = false;
|
let menuHiddenBeforeFs = false;
|
||||||
|
|
||||||
function fsSmallExempt(tile) {
|
function colsOverlap(a, b) {
|
||||||
// A small tile (like the clock) alone in its columns must not become a
|
return a.x < b.x + b.w && b.x < a.x + a.w;
|
||||||
// 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 fsSmall(tile) {
|
||||||
|
return tile.h <= FS_SMALL_ROWS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fsAloneInColumns(tile) {
|
||||||
|
return !tiles.some((t) => t !== tile && colsOverlap(t, tile));
|
||||||
}
|
}
|
||||||
|
|
||||||
function fsBottomMost(tile) {
|
function fsBottomMost(tile) {
|
||||||
return !tiles.some((t) => t !== tile
|
return !tiles.some((t) => t !== tile && colsOverlap(t, tile) && t.y >= tile.y + tile.h);
|
||||||
&& t.x < tile.x + tile.w && tile.x < t.x + t.w
|
}
|
||||||
&& 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() {
|
function applyFullscreenFit() {
|
||||||
@@ -1128,16 +1248,14 @@ function applyFullscreenFit() {
|
|||||||
const cellH = (wrapH - (R - 1) * m.gap) / R;
|
const cellH = (wrapH - (R - 1) * m.gap) / R;
|
||||||
grid.style.gridAutoRows = cellH + 'px';
|
grid.style.gridAutoRows = cellH + 'px';
|
||||||
|
|
||||||
|
const rows = fullscreenRows(R);
|
||||||
for (const tile of tiles) {
|
for (const tile of tiles) {
|
||||||
applyRect(tile); // reset any previous stretch first
|
const entry = tileEls.get(tile.id);
|
||||||
const bottom = tile.y + tile.h;
|
if (!entry) continue;
|
||||||
if (R - bottom <= FS_NEAR_ROWS && fsBottomMost(tile) && !fsSmallExempt(tile)) {
|
const r = rows.get(tile.id);
|
||||||
const entry = tileEls.get(tile.id);
|
entry.el.style.gridColumn = `${tile.x + 1} / span ${tile.w}`;
|
||||||
if (entry) {
|
entry.el.style.gridRow = `${r.y + 1} / span ${r.h}`;
|
||||||
entry.el.style.gridRow = `${tile.y + 1} / span ${R - tile.y}`;
|
try { entry.instance?.onResize?.(tile.w, r.h); } catch { /* module's problem, not the shell's */ }
|
||||||
try { entry.instance?.onResize?.(tile.w, R - tile.y); } catch { /* module's problem */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1174,14 +1292,14 @@ document.addEventListener('fullscreenchange', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('resize', () => {
|
// The grid area's size settles late: the browser animates into fullscreen,
|
||||||
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
|
// then the menubar collapse animates. Re-fit whenever its size changes.
|
||||||
});
|
let fsRefit = 0;
|
||||||
|
new ResizeObserver(() => {
|
||||||
// the menubar collapse animates, so the grid's final height arrives late
|
if (!document.body.classList.contains('fullscreen')) return;
|
||||||
topbar.addEventListener('transitionend', () => {
|
cancelAnimationFrame(fsRefit);
|
||||||
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
|
fsRefit = requestAnimationFrame(applyFullscreenFit);
|
||||||
});
|
}).observe(gridwrap);
|
||||||
|
|
||||||
/* ── live updates from the admin page ───────────────────────────────── */
|
/* ── live updates from the admin page ───────────────────────────────── */
|
||||||
|
|
||||||
|
|||||||
+51
-12
@@ -54,25 +54,30 @@ body.menu-hidden #topbar {
|
|||||||
overflow: hidden;
|
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 {
|
#topbar-notch {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: var(--topbar-h, 46px);
|
/* straddles the bar's bottom edge — mostly on the bar, a short lip below
|
||||||
left: 50%;
|
it — so it never covers a top-row tile's status dot or title */
|
||||||
transform: translateX(-50%);
|
top: calc(var(--topbar-h, 46px) - 8px);
|
||||||
|
left: 12px;
|
||||||
width: 54px;
|
width: 54px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: var(--panel);
|
background: var(--accent);
|
||||||
border: 1px solid var(--border);
|
border: none;
|
||||||
border-top: none;
|
|
||||||
border-radius: 0 0 10px 10px;
|
border-radius: 0 0 10px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
opacity: 0.35;
|
opacity: 0.55;
|
||||||
transition: opacity 0.15s ease, top 0.22s ease;
|
transition: opacity 0.15s ease, top 0.22s ease, height 0.22s ease;
|
||||||
}
|
}
|
||||||
#topbar-notch:hover { opacity: 1; }
|
#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 {
|
#topbar-notch::after {
|
||||||
content: "";
|
content: "";
|
||||||
display: block;
|
display: block;
|
||||||
@@ -80,9 +85,8 @@ body.menu-hidden #topbar {
|
|||||||
height: 3px;
|
height: 3px;
|
||||||
margin: 4px auto 0;
|
margin: 4px auto 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--muted);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
#topbar-notch:hover::after { background: var(--text); }
|
|
||||||
|
|
||||||
#brand {
|
#brand {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -411,6 +415,39 @@ body.menu-hidden #topbar {
|
|||||||
border-radius: 0 0 0 3px;
|
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 ───────────────────────────── */
|
/* ── seam divider between adjacent tiles ───────────────────────────── */
|
||||||
|
|
||||||
/* When two tiles share a vertical edge, an invisible strip sits on the seam;
|
/* 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. */
|
editing affordances (corner handles, seam pills) are parked. */
|
||||||
body.fullscreen .tile-resize,
|
body.fullscreen .tile-resize,
|
||||||
body.fullscreen .tile-resize-left,
|
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; }
|
body.fullscreen #grid { padding-bottom: 0; }
|
||||||
#gridwrap.no-scroll { overflow-y: hidden; }
|
#gridwrap.no-scroll { overflow-y: hidden; }
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
* PORT (default: value in proddash.json, else 24500)
|
* proddash.json in the data directory (this machine's overrides), or env vars:
|
||||||
* PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
|
* 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';
|
'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.' });
|
||||||
}
|
}
|
||||||
if (m[2] === 'config') {
|
try {
|
||||||
applyConfigPatch(id, body.config);
|
if (m[2] === 'config') {
|
||||||
remountModule(id); // config changes take effect without a manual restart
|
applyConfigPatch(id, body.config);
|
||||||
} 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.' });
|
||||||
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 });
|
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']) {
|
||||||
|
|||||||
Reference in New Issue
Block a user