diff --git a/README.md b/README.md index 2fe2a0f..1b85fa4 100644 --- a/README.md +++ b/README.md @@ -316,10 +316,15 @@ below. Nothing is installed without someone clicking. 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 — *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. - A tile whose module or picker entry isn't offered at the moment keeps its - place and shows nothing, then appears when it is; it is never replaced by - the module's default tile. Saving over an existing name asks first, and + now; the Layouts menu can remove them* — with **Undo** in the message to + get back the arrangement you had. A tile whose module or picker entry + isn't offered at the moment keeps its place and shows nothing, then + 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 → 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 — diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 1d53ab3..9edee5c 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -688,8 +688,11 @@ module.exports = { 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, 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 - client is never asked to render an entry you did not offer at mount time. + Service timer waits for PCO Plan, a transcript for its channel. The + 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 (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 @@ -1044,7 +1047,8 @@ client-side failures (a tile that fails to start says so in the tile). **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//...` — your routes, `curl`-able. For SSE: `curl -N http://localhost:24500/api/modules//stream`. - `GET /api/admin/state` — enabled flags, health, mount errors. diff --git a/package.json b/package.json index 2dd686c..e0ff2e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proddash", - "version": "1.12.0", + "version": "1.13.0", "private": true, "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client", "license": "UNLICENSED", diff --git a/public/shell.js b/public/shell.js index 261f7db..e9be457 100644 --- a/public/shell.js +++ b/public/shell.js @@ -188,6 +188,10 @@ function menuItem(label, onClick, { sub = '', danger = false, disabled = false, /* ── module registry ────────────────────────────────────────────────── */ 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 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); const body = await res.json(); registry = new Map((body.modules || []).map((m) => [m.id, m])); + inactiveModules = new Map((body.inactive || []).map((m) => [m.id, m])); return true; } catch { return false; @@ -487,7 +492,9 @@ const GEAR_SVG = ' !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. */ function unshownTileNames() { - return tiles.filter((t) => !tileEls.has(t.id)) - .map((t) => t.title || registry.get(t.module)?.name || t.module); + return unshownTiles().map((u) => u.title); +} + +/* 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) { @@ -2253,6 +2341,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => { // how one is arranged. menu.appendChild(gridStepper()); menu.appendChild(alertsFold()); + menu.appendChild(unshownSection()); // only while a tile the layout holds is not drawn const stepperDivider = document.createElement('div'); stepperDivider.className = 'menu-divider'; 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 gridNote = COLS !== gridBefore ? ` on a ${COLS}-column grid` : ''; 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, { ms: unshown.length ? 10000 : 7000, diff --git a/public/style.css b/public/style.css index af2634f..5b27d5c 100644 --- a/public/style.css +++ b/public/style.css @@ -368,6 +368,33 @@ body[data-alert="critical"] .tile.is-tinted { 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 ──────────────────────────────────────────────────────────── */ #gridwrap { diff --git a/server.js b/server.js index cb8a838..7fc0f8e 100644 --- a/server.js +++ b/server.js @@ -2336,8 +2336,17 @@ function handleRequest(req, res) { /* — shell API — */ 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)) - .then((list) => sendJson(res, 200, { modules: list })) + .then((list) => sendJson(res, 200, { modules: list, inactive })) .catch((err) => { console.error('[proddash] /api/modules failed:', err); if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' });