Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34a6def839 |
@@ -316,15 +316,10 @@ below. Nothing is installed without someone clicking.
|
|||||||
name, load a named layout (it becomes a local copy — browsers are never
|
name, load a named layout (it becomes a local copy — browsers are never
|
||||||
linked), or reset this browser's dashboard. Loading says what it did —
|
linked), or reset this browser's dashboard. Loading says what it did —
|
||||||
*Loaded "FOH": 9 tiles, 2 not shown — Service, #ops aren't offered right
|
*Loaded "FOH": 9 tiles, 2 not shown — Service, #ops aren't offered right
|
||||||
now; the Layouts menu can remove them* — with **Undo** in the message to
|
now* — with **Undo** in the message to get back the arrangement you had.
|
||||||
get back the arrangement you had. A tile whose module or picker entry
|
A tile whose module or picker entry isn't offered at the moment keeps its
|
||||||
isn't offered at the moment keeps its place and shows nothing, then
|
place and shows nothing, then appears when it is; it is never replaced by
|
||||||
appears when it is; it is never replaced by the module's default tile.
|
the module's default tile. Saving over an existing name asks first, and
|
||||||
While any tile is in that state the same menu has a **Not shown** section
|
|
||||||
listing each one with why — *not offered right now*, *module disabled*,
|
|
||||||
*module not installed* — and a ✕ that removes it and frees its cells, for
|
|
||||||
the tile that is not coming back (a module that stopped offering that
|
|
||||||
kind of tile). Saving over an existing name asks first, and
|
|
||||||
the server keeps the copy it replaced: **Restore previous** in Admin →
|
the server keeps the copy it replaced: **Restore previous** in Admin →
|
||||||
Saved layouts swaps it back (and swapping again undoes that). The menu's
|
Saved layouts swaps it back (and swapping again undoes that). The menu's
|
||||||
**Grid Size** stepper sets how many columns this browser cuts the board into —
|
**Grid Size** stepper sets how many columns this browser cuts the board into —
|
||||||
|
|||||||
@@ -688,11 +688,8 @@ module.exports = {
|
|||||||
tile added from an entry only when that entry (or one of its `aliases`) is
|
tile added from an entry only when that entry (or one of its `aliases`) is
|
||||||
in your list right now; otherwise the tile keeps its place in the layout,
|
in your list right now; otherwise the tile keeps its place in the layout,
|
||||||
draws nothing, and appears the moment your list offers it again — a
|
draws nothing, and appears the moment your list offers it again — a
|
||||||
Service timer waits for PCO Plan, a transcript for its channel. The
|
Service timer waits for PCO Plan, a transcript for its channel. So your
|
||||||
operator can remove such a tile from the Layouts menu's **Not shown**
|
client is never asked to render an entry you did not offer at mount time.
|
||||||
section, so an entry you retire for good (a send box that moved to another
|
|
||||||
module) leaves no dead cells behind. So your client is never asked to
|
|
||||||
render an entry you did not offer at mount time.
|
|
||||||
One case is still yours: an entry that vanishes *after* the tile mounted
|
One case is still yours: an entry that vanishes *after* the tile mounted
|
||||||
(a timer deleted in ProPresenter mid-service). Render nothing for it — no
|
(a timer deleted in ProPresenter mid-service). Render nothing for it — no
|
||||||
placeholder, no error — and the shell folds the tile away at its next
|
placeholder, no error — and the shell folds the tile away at its next
|
||||||
@@ -1047,8 +1044,7 @@ client-side failures (a tile that fails to start says so in the tile).
|
|||||||
|
|
||||||
**Poke the pieces directly:**
|
**Poke the pieces directly:**
|
||||||
|
|
||||||
- `GET /api/modules` — what the shell sees (your manifest + non-secret
|
- `GET /api/modules` — what the shell sees (your manifest + non-secret config).
|
||||||
config; `inactive` names the installed modules that are not running).
|
|
||||||
- `GET /api/modules/<id>/...` — your routes, `curl`-able. For SSE:
|
- `GET /api/modules/<id>/...` — your routes, `curl`-able. For SSE:
|
||||||
`curl -N http://localhost:24500/api/modules/<id>/stream`.
|
`curl -N http://localhost:24500/api/modules/<id>/stream`.
|
||||||
- `GET /api/admin/state` — enabled flags, health, mount errors.
|
- `GET /api/admin/state` — enabled flags, health, mount errors.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.13.0",
|
"version": "1.12.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
|||||||
+4
-93
@@ -188,10 +188,6 @@ function menuItem(label, onClick, { sub = '', danger = false, disabled = false,
|
|||||||
/* ── module registry ────────────────────────────────────────────────── */
|
/* ── module registry ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
let registry = new Map(); // module id -> manifest from GET /api/modules
|
let registry = new Map(); // module id -> manifest from GET /api/modules
|
||||||
/* The installed modules that are not running — disabled, or needing a newer
|
|
||||||
shell — id -> { id, name, reason }, from the same GET. Read for one thing:
|
|
||||||
to say why a tile one of them left in the layout is not drawn. */
|
|
||||||
let inactiveModules = new Map();
|
|
||||||
|
|
||||||
/** fetch that gives up after `ms`. A request the browser has queued behind
|
/** fetch that gives up after `ms`. A request the browser has queued behind
|
||||||
held connections would otherwise pend forever, and so would anything
|
held connections would otherwise pend forever, and so would anything
|
||||||
@@ -212,7 +208,6 @@ async function loadRegistry() {
|
|||||||
if (!res.ok) throw new Error('modules ' + res.status);
|
if (!res.ok) throw new Error('modules ' + res.status);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
registry = new Map((body.modules || []).map((m) => [m.id, m]));
|
registry = new Map((body.modules || []).map((m) => [m.id, m]));
|
||||||
inactiveModules = new Map((body.inactive || []).map((m) => [m.id, m]));
|
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -492,9 +487,7 @@ const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true
|
|||||||
registry offers them again: a Service timer waits for PCO Plan, a
|
registry offers them again: a Service timer waits for PCO Plan, a
|
||||||
transcript for its channel. Never the module's default in their place —
|
transcript for its channel. Never the module's default in their place —
|
||||||
a wall of "All timers" where five different timers were is worse than
|
a wall of "All timers" where five different timers were is worse than
|
||||||
five gaps that fill themselves in. For the one that is not coming back
|
five gaps that fill themselves in. */
|
||||||
(a module stopped offering that kind of tile) the Layouts menu lists them
|
|
||||||
under Not shown, each with a ✕ — the only way to let its cells go. */
|
|
||||||
|
|
||||||
function tileIsOffered(tile) {
|
function tileIsOffered(tile) {
|
||||||
const man = registry.get(tile.module);
|
const man = registry.get(tile.module);
|
||||||
@@ -528,7 +521,6 @@ function reconcileOfferedTiles() {
|
|||||||
if (changed) rebuildDividers();
|
if (changed) rebuildDividers();
|
||||||
scheduleOfferedRecheck();
|
scheduleOfferedRecheck();
|
||||||
refreshEmptyState(); // the registry may have gone from nothing to something
|
refreshEmptyState(); // the registry may have gone from nothing to something
|
||||||
repaintUnshown(); // a Not shown row goes the moment its tile is drawn again
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A module's entry list can change without anyone saving admin config: PCO
|
/* A module's entry list can change without anyone saving admin config: PCO
|
||||||
@@ -548,90 +540,10 @@ function scheduleOfferedRecheck() {
|
|||||||
}, OFFERED_RECHECK_MS);
|
}, OFFERED_RECHECK_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The tiles a layout holds that the board is not showing, each with what
|
|
||||||
to call it and, in a few words, why it is not drawn. */
|
|
||||||
function unshownTiles() {
|
|
||||||
return tiles.filter((t) => !tileEls.has(t.id)).map((tile) => {
|
|
||||||
const man = registry.get(tile.module);
|
|
||||||
if (man) {
|
|
||||||
const title = tile.title || (tile.variant ? `${man.name} · ${tile.variant}` : man.name);
|
|
||||||
return { tile, title, reason: 'not offered right now' };
|
|
||||||
}
|
|
||||||
const off = inactiveModules.get(tile.module);
|
|
||||||
if (off) {
|
|
||||||
const reason = off.reason === 'incompatible' ? 'module needs a newer ProdDash' : 'module disabled';
|
|
||||||
return { tile, title: tile.title || off.name || tile.module, reason };
|
|
||||||
}
|
|
||||||
return { tile, title: tile.title || tile.module, reason: 'module not installed' };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The tiles a layout holds that the board is not showing, by name. */
|
/** The tiles a layout holds that the board is not showing, by name. */
|
||||||
function unshownTileNames() {
|
function unshownTileNames() {
|
||||||
return unshownTiles().map((u) => u.title);
|
return tiles.filter((t) => !tileEls.has(t.id))
|
||||||
}
|
.map((t) => t.title || registry.get(t.module)?.name || t.module);
|
||||||
|
|
||||||
/* Repaints the Layouts menu's Not shown section while the menu is open —
|
|
||||||
set by unshownSection(), called whenever tiles become offered or stop
|
|
||||||
being, so the list never lags the board behind it. */
|
|
||||||
let repaintUnshown = () => {};
|
|
||||||
|
|
||||||
/** The Layouts menu's Not shown section: one row per tile the layout holds
|
|
||||||
that the board is not drawing — its name, why in a word or two, and a ✕
|
|
||||||
that removes it — present only while there is one. A tile that waits for
|
|
||||||
its module (a Service timer before PCO Plan is up) is listed but can stay;
|
|
||||||
this exists for the one that will never be back, which would otherwise
|
|
||||||
hold its cells with no control anywhere to let them go. */
|
|
||||||
function unshownSection() {
|
|
||||||
const wrap = document.createElement('div');
|
|
||||||
wrap.className = 'menu-unshown';
|
|
||||||
const signature = (list) => list.map((u) => `${u.tile.id}|${u.reason}`).join('\n');
|
|
||||||
let painted = '';
|
|
||||||
const paint = () => {
|
|
||||||
wrap.innerHTML = '';
|
|
||||||
const list = unshownTiles();
|
|
||||||
painted = signature(list);
|
|
||||||
wrap.hidden = !list.length;
|
|
||||||
if (!list.length) return;
|
|
||||||
const divider = document.createElement('div');
|
|
||||||
divider.className = 'menu-divider';
|
|
||||||
const title = document.createElement('div');
|
|
||||||
title.className = 'menu-title';
|
|
||||||
title.textContent = 'Not shown';
|
|
||||||
wrap.append(divider, title);
|
|
||||||
for (const { tile, title: name, reason } of list) {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'menu-item menu-unshown-row';
|
|
||||||
const label = document.createElement('span');
|
|
||||||
label.textContent = name;
|
|
||||||
const sub = document.createElement('span');
|
|
||||||
sub.className = 'sub';
|
|
||||||
sub.textContent = reason;
|
|
||||||
label.appendChild(sub);
|
|
||||||
const remove = document.createElement('button');
|
|
||||||
remove.type = 'button';
|
|
||||||
remove.className = 'menu-x';
|
|
||||||
remove.setAttribute('data-tip', 'Remove');
|
|
||||||
remove.setAttribute('aria-label', `Remove ${name}`);
|
|
||||||
remove.innerHTML = CLOSE_SVG;
|
|
||||||
remove.addEventListener('click', (e) => {
|
|
||||||
// The row goes with the tile, so the click must not reach the
|
|
||||||
// document as one from outside the menu — the menu stays open
|
|
||||||
// to show the next row (or none), as the stepper and Alerts do.
|
|
||||||
e.stopPropagation();
|
|
||||||
removeTile(tile.id); // saves the layout and frees the cells, as a drawn tile's ✕ does
|
|
||||||
paint();
|
|
||||||
});
|
|
||||||
row.append(label, remove);
|
|
||||||
wrap.appendChild(row);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
paint();
|
|
||||||
// Only when something changed: the registry is re-read every few seconds
|
|
||||||
// while a tile is folded away, and rows rebuilt under the pointer for
|
|
||||||
// nothing would lose the tooltip (and the click) the operator was on.
|
|
||||||
repaintUnshown = () => { if (wrap.isConnected && signature(unshownTiles()) !== painted) paint(); };
|
|
||||||
return wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTile(tile) {
|
function buildTile(tile) {
|
||||||
@@ -2341,7 +2253,6 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
|||||||
// how one is arranged.
|
// how one is arranged.
|
||||||
menu.appendChild(gridStepper());
|
menu.appendChild(gridStepper());
|
||||||
menu.appendChild(alertsFold());
|
menu.appendChild(alertsFold());
|
||||||
menu.appendChild(unshownSection()); // only while a tile the layout holds is not drawn
|
|
||||||
const stepperDivider = document.createElement('div');
|
const stepperDivider = document.createElement('div');
|
||||||
stepperDivider.className = 'menu-divider';
|
stepperDivider.className = 'menu-divider';
|
||||||
menu.appendChild(stepperDivider);
|
menu.appendChild(stepperDivider);
|
||||||
@@ -2433,7 +2344,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
|||||||
const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`;
|
const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`;
|
||||||
const gridNote = COLS !== gridBefore ? ` on a ${COLS}-column grid` : '';
|
const gridNote = COLS !== gridBefore ? ` on a ${COLS}-column grid` : '';
|
||||||
const note = unshown.length
|
const note = unshown.length
|
||||||
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now; the Layouts menu can remove ${unshown.length === 1 ? 'it' : 'them'}`
|
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now`
|
||||||
: '';
|
: '';
|
||||||
toast(`Loaded “${l.name}”: ${count}${gridNote}${note}`, false, {
|
toast(`Loaded “${l.name}”: ${count}${gridNote}${note}`, false, {
|
||||||
ms: unshown.length ? 10000 : 7000,
|
ms: unshown.length ? 10000 : 7000,
|
||||||
|
|||||||
@@ -368,33 +368,6 @@ body[data-alert="critical"] .tile.is-tinted {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* the Layouts menu's Not shown rows: a tile the layout holds but the board
|
|
||||||
is not drawing, why in a word or two, and the ✕ that removes it */
|
|
||||||
.menu-unshown[hidden] { display: none; }
|
|
||||||
.menu-unshown-row { cursor: default; }
|
|
||||||
.menu-unshown-row:hover { background: none; }
|
|
||||||
.menu-unshown-row > span:first-child {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.menu-x {
|
|
||||||
flex: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
border-radius: 5px;
|
|
||||||
color: var(--muted);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.menu-x:hover { background: var(--border); color: var(--danger); }
|
|
||||||
.menu-x svg { width: 12px; height: 12px; display: block; }
|
|
||||||
|
|
||||||
/* ── grid ──────────────────────────────────────────────────────────── */
|
/* ── grid ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
#gridwrap {
|
#gridwrap {
|
||||||
|
|||||||
@@ -1121,6 +1121,11 @@ function parseTar(buf) {
|
|||||||
while (off + 512 <= buf.length) {
|
while (off + 512 <= buf.length) {
|
||||||
const h = buf.subarray(off, off + 512);
|
const h = buf.subarray(off, off + 512);
|
||||||
if (h.every((b) => b === 0)) break; // end-of-archive blocks
|
if (h.every((b) => b === 0)) break; // end-of-archive blocks
|
||||||
|
// The mode matters: modules ship prebuilt helpers under bin/, and writing
|
||||||
|
// one out without its execute bits leaves spawn() with EACCES and the
|
||||||
|
// feature silently dead. Permission bits only — setuid/sticky from an
|
||||||
|
// archive are not wanted here.
|
||||||
|
const mode = (parseInt(cstr(h, 100, 8).trim() || '0', 8) & 0o777) || 0o644;
|
||||||
const size = parseInt(cstr(h, 124, 12).trim() || '0', 8) || 0;
|
const size = parseInt(cstr(h, 124, 12).trim() || '0', 8) || 0;
|
||||||
const type = h[156] === 0 ? '0' : String.fromCharCode(h[156]);
|
const type = h[156] === 0 ? '0' : String.fromCharCode(h[156]);
|
||||||
const prefix = cstr(h, 257, 6).startsWith('ustar') ? cstr(h, 345, 155) : '';
|
const prefix = cstr(h, 257, 6).startsWith('ustar') ? cstr(h, 345, 155) : '';
|
||||||
@@ -1136,7 +1141,7 @@ function parseTar(buf) {
|
|||||||
}
|
}
|
||||||
const name = pendingName || shortName;
|
const name = pendingName || shortName;
|
||||||
pendingName = null;
|
pendingName = null;
|
||||||
if (type === '0') files.set(name, Buffer.from(data));
|
if (type === '0') files.set(name, { data: Buffer.from(data), mode });
|
||||||
}
|
}
|
||||||
return { files, comment };
|
return { files, comment };
|
||||||
}
|
}
|
||||||
@@ -1165,9 +1170,9 @@ async function fetchArchive() {
|
|||||||
}
|
}
|
||||||
const { files, comment } = parseTar(zlib.gunzipSync(gz));
|
const { files, comment } = parseTar(zlib.gunzipSync(gz));
|
||||||
const stripped = new Map(); // drop the archive's single top-level folder
|
const stripped = new Map(); // drop the archive's single top-level folder
|
||||||
for (const [p, data] of files) {
|
for (const [p, entry] of files) {
|
||||||
const i = p.indexOf('/');
|
const i = p.indexOf('/');
|
||||||
if (i > 0 && i < p.length - 1) stripped.set(p.slice(i + 1), data);
|
if (i > 0 && i < p.length - 1) stripped.set(p.slice(i + 1), entry);
|
||||||
}
|
}
|
||||||
return { files: stripped, sha: /^[0-9a-f]{7,40}$/.test(comment) ? comment : '' };
|
return { files: stripped, sha: /^[0-9a-f]{7,40}$/.test(comment) ? comment : '' };
|
||||||
}
|
}
|
||||||
@@ -1182,17 +1187,17 @@ async function refreshRemote(force = false) {
|
|||||||
const job = (async () => {
|
const job = (async () => {
|
||||||
try {
|
try {
|
||||||
const { files, sha } = await fetchArchive();
|
const { files, sha } = await fetchArchive();
|
||||||
const pkg = safeJson(files.get('package.json'));
|
const pkg = safeJson(files.get('package.json')?.data);
|
||||||
const modules = new Map();
|
const modules = new Map();
|
||||||
for (const [p, data] of files) {
|
for (const [p, file] of files) {
|
||||||
const m = p.match(/^modules\/([^/]+)\/(.+)$/);
|
const m = p.match(/^modules\/([^/]+)\/(.+)$/);
|
||||||
if (!m) continue;
|
if (!m) continue;
|
||||||
const entry = modules.get(m[1]) || { id: m[1], manifest: null, files: new Map() };
|
const entry = modules.get(m[1]) || { id: m[1], manifest: null, files: new Map() };
|
||||||
entry.files.set(m[2], data);
|
entry.files.set(m[2], file);
|
||||||
modules.set(m[1], entry);
|
modules.set(m[1], entry);
|
||||||
}
|
}
|
||||||
for (const [id, entry] of modules) {
|
for (const [id, entry] of modules) {
|
||||||
const man = safeJson(entry.files.get('module.json'));
|
const man = safeJson(entry.files.get('module.json')?.data);
|
||||||
if (!man || typeof man !== 'object' || (man.id && man.id !== id) || !man.client) {
|
if (!man || typeof man !== 'object' || (man.id && man.id !== id) || !man.client) {
|
||||||
modules.delete(id);
|
modules.delete(id);
|
||||||
continue;
|
continue;
|
||||||
@@ -1322,12 +1327,14 @@ async function installModule(id) {
|
|||||||
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
|
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
|
||||||
fs.rmSync(staging, { recursive: true, force: true });
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
const shipped = new Set();
|
const shipped = new Set();
|
||||||
for (const [rel, data] of remoteEntry.files) {
|
for (const [rel, { data, mode }] of remoteEntry.files) {
|
||||||
if (!safeRelPath(rel)) continue;
|
if (!safeRelPath(rel)) continue;
|
||||||
shipped.add(rel);
|
shipped.add(rel);
|
||||||
const target = path.join(staging, rel);
|
const target = path.join(staging, rel);
|
||||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
fs.writeFileSync(target, data);
|
// The tarball's mode, or a shipped bin/ helper lands 0644 and spawns
|
||||||
|
// EACCES (what broke LTC and SPL on the 1.10.2 rollout, 2026-10-04).
|
||||||
|
fs.writeFileSync(target, data, { mode });
|
||||||
}
|
}
|
||||||
const dest = path.join(INSTALLED_MODULES_DIR, id);
|
const dest = path.join(INSTALLED_MODULES_DIR, id);
|
||||||
// Per-machine executables — a capture tool compiled here, or copied in
|
// Per-machine executables — a capture tool compiled here, or copied in
|
||||||
@@ -1439,11 +1446,11 @@ async function updateShell() {
|
|||||||
} else {
|
} else {
|
||||||
const staging = path.join(ROOT, '.proddash-update');
|
const staging = path.join(ROOT, '.proddash-update');
|
||||||
fs.rmSync(staging, { recursive: true, force: true });
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
for (const [rel, data] of remote.files) {
|
for (const [rel, { data, mode }] of remote.files) {
|
||||||
if (!safeRelPath(rel) || isKeptOnUpdate(rel)) continue;
|
if (!safeRelPath(rel) || isKeptOnUpdate(rel)) continue;
|
||||||
const target = path.join(staging, rel);
|
const target = path.join(staging, rel);
|
||||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
fs.writeFileSync(target, data);
|
fs.writeFileSync(target, data, { mode });
|
||||||
}
|
}
|
||||||
const newServer = path.join(staging, 'server.js');
|
const newServer = path.join(staging, 'server.js');
|
||||||
if (!fs.existsSync(newServer)) {
|
if (!fs.existsSync(newServer)) {
|
||||||
@@ -2336,17 +2343,8 @@ function handleRequest(req, res) {
|
|||||||
|
|
||||||
/* — shell API — */
|
/* — shell API — */
|
||||||
if (urlPath === '/api/modules' && req.method === 'GET') {
|
if (urlPath === '/api/modules' && req.method === 'GET') {
|
||||||
// Beside what dashboards may load: the installed modules that are not
|
|
||||||
// running, by id and name only, so a tile one of them left in a layout
|
|
||||||
// can say why it is not drawn ("module disabled") instead of guessing at
|
|
||||||
// "not installed". Nothing of their config travels with it.
|
|
||||||
const inactive = [...manifests.keys()].filter((id) => !isActive(id)).map((id) => ({
|
|
||||||
id,
|
|
||||||
name: String(manifests.get(id).name || id),
|
|
||||||
reason: isEnabled(id) ? 'incompatible' : 'disabled',
|
|
||||||
}));
|
|
||||||
Promise.all([...manifests.keys()].filter(isActive).map(clientManifest))
|
Promise.all([...manifests.keys()].filter(isActive).map(clientManifest))
|
||||||
.then((list) => sendJson(res, 200, { modules: list, inactive }))
|
.then((list) => sendJson(res, 200, { modules: list }))
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('[proddash] /api/modules failed:', err);
|
console.error('[proddash] /api/modules failed:', err);
|
||||||
if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' });
|
if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' });
|
||||||
|
|||||||
+22
-1
@@ -12,7 +12,12 @@
|
|||||||
* 2. a shell update from an archive that carries modules/ never writes
|
* 2. a shell update from an archive that carries modules/ never writes
|
||||||
* them into the app folder — they show up in the catalog instead;
|
* them into the app folder — they show up in the catalog instead;
|
||||||
* 3. a fresh install lists everything the repo offers, and installing
|
* 3. a fresh install lists everything the repo offers, and installing
|
||||||
* several at once lands them in the data directory and mounts them.
|
* several at once lands them in the data directory and mounts them;
|
||||||
|
* 4. a module downloaded from the catalog keeps the tarball's modes — a
|
||||||
|
* shipped bin/ helper is still executable (0644 means spawn EACCES,
|
||||||
|
* which is what broke LTC and SPL on the 1.10.2 rollout) — while
|
||||||
|
* per-machine executables the download does not include are still
|
||||||
|
* carried over from the copy being replaced.
|
||||||
*
|
*
|
||||||
* node tools/unbundled.test.js
|
* node tools/unbundled.test.js
|
||||||
*
|
*
|
||||||
@@ -70,6 +75,11 @@ function buildArchive(dir) {
|
|||||||
fs.copyFileSync(path.join(REPO, 'server.js'), path.join(top, 'server.js'));
|
fs.copyFileSync(path.join(REPO, 'server.js'), path.join(top, 'server.js'));
|
||||||
fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' }));
|
fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' }));
|
||||||
writeModule(path.join(top, 'modules', 'demo'), 'demo', '2.0.0');
|
writeModule(path.join(top, 'modules', 'demo'), 'demo', '2.0.0');
|
||||||
|
// The repo ships this one prebuilt (the way Timers and SPL ship their
|
||||||
|
// capture helpers): it must come out of the download still executable.
|
||||||
|
fs.mkdirSync(path.join(top, 'modules', 'demo', 'bin'));
|
||||||
|
fs.writeFileSync(path.join(top, 'modules', 'demo', 'bin', 'demo-capture'), '#!/bin/sh\necho capture\n');
|
||||||
|
fs.chmodSync(path.join(top, 'modules', 'demo', 'bin', 'demo-capture'), 0o755);
|
||||||
writeModule(path.join(top, 'modules', 'other'), 'other', '1.0.0');
|
writeModule(path.join(top, 'modules', 'other'), 'other', '1.0.0');
|
||||||
writeModule(path.join(top, 'modules', 'third'), 'third', '1.0.0');
|
writeModule(path.join(top, 'modules', 'third'), 'third', '1.0.0');
|
||||||
const out = path.join(dir, 'main.tar.gz');
|
const out = path.join(dir, 'main.tar.gz');
|
||||||
@@ -188,6 +198,17 @@ function buildPackagedApp(app) {
|
|||||||
const empty = await api('/api/admin/modules/install', { method: 'POST', body: '{}' });
|
const empty = await api('/api/admin/modules/install', { method: 'POST', body: '{}' });
|
||||||
assert.equal(empty.status, 400, 'nothing to install is said');
|
assert.equal(empty.status, 400, 'nothing to install is said');
|
||||||
|
|
||||||
|
// ── 4. a download keeps executable modes, and carry-over still works ──
|
||||||
|
const upd = await api('/api/admin/modules/demo/install', { method: 'POST', body: '{}' });
|
||||||
|
assert.equal(upd.status, 200, 'demo update: ' + JSON.stringify(upd.body));
|
||||||
|
assert.equal(upd.body.version, '2.0.0', 'demo was updated from the repo');
|
||||||
|
const shipped = path.join(dataDir, 'modules', 'demo', 'bin', 'demo-capture');
|
||||||
|
assert.ok(fs.existsSync(shipped), 'the downloaded helper is there');
|
||||||
|
assert.ok(fs.statSync(shipped).mode & 0o111, 'and the tarball\'s execute bit survived the install');
|
||||||
|
const carried = path.join(dataDir, 'modules', 'demo', 'bin', 'demo-helper');
|
||||||
|
assert.ok(fs.existsSync(carried), 'the per-machine executable the download lacks was carried over');
|
||||||
|
assert.ok(fs.statSync(carried).mode & 0o111, 'still executable');
|
||||||
|
|
||||||
console.log('unbundled shell: every check passed');
|
console.log('unbundled shell: every check passed');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
failed = true;
|
failed = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user