Shell 1.13.0: the Layouts menu lists the tiles a layout holds but the board is not showing, with a ✕ to remove each #46
@@ -316,10 +316,15 @@ 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* — with **Undo** in the message to get back the arrangement you had.
|
now; the Layouts menu can remove them* — with **Undo** in the message to
|
||||||
A tile whose module or picker entry isn't offered at the moment keeps its
|
get back the arrangement you had. A tile whose module or picker entry
|
||||||
place and shows nothing, then appears when it is; it is never replaced by
|
isn't offered at the moment keeps its place and shows nothing, then
|
||||||
the module's default tile. Saving over an existing name asks first, and
|
appears when it is; it is never replaced by the module's default tile.
|
||||||
|
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,8 +688,11 @@ 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. So your
|
Service timer waits for PCO Plan, a transcript for its channel. The
|
||||||
client is never asked to render an entry you did not offer at mount time.
|
operator can remove such a tile from the Layouts menu's **Not shown**
|
||||||
|
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
|
||||||
@@ -1044,7 +1047,8 @@ 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 config).
|
- `GET /api/modules` — what the shell sees (your manifest + non-secret
|
||||||
|
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.12.0",
|
"version": "1.13.0",
|
||||||
"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",
|
||||||
|
|||||||
+93
-4
@@ -188,6 +188,10 @@ 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
|
||||||
@@ -208,6 +212,7 @@ 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;
|
||||||
@@ -487,7 +492,9 @@ 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. */
|
five gaps that fill themselves in. For the one that is not coming back
|
||||||
|
(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);
|
||||||
@@ -521,6 +528,7 @@ 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
|
||||||
@@ -540,10 +548,90 @@ 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 tiles.filter((t) => !tileEls.has(t.id))
|
return unshownTiles().map((u) => u.title);
|
||||||
.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) {
|
||||||
@@ -2253,6 +2341,7 @@ 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);
|
||||||
@@ -2344,7 +2433,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`
|
? `, ${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'}`
|
||||||
: '';
|
: '';
|
||||||
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,6 +368,33 @@ 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 {
|
||||||
|
|||||||
@@ -2336,8 +2336,17 @@ 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 }))
|
.then((list) => sendJson(res, 200, { modules: list, inactive }))
|
||||||
.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.' });
|
||||||
|
|||||||
Reference in New Issue
Block a user