From 7481d1d9e2ff9b54071cc2209c1218efc6283ff4 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Wed, 7 Oct 2026 11:46:50 -0400 Subject: [PATCH] =?UTF-8?q?Shell=201.13.0:=20the=20Layouts=20menu=20lists?= =?UTF-8?q?=20the=20tiles=20a=20layout=20holds=20but=20the=20board=20is=20?= =?UTF-8?q?not=20showing,=20with=20a=20=E2=9C=95=20to=20remove=20each?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tile whose module stopped offering its picker entry — Slack's Send message and Quick replies since the Send Message module took them over — kept its place in the layout and drew nothing, as designed, but still held its grid cells with no control anywhere to let them go: a dead area the operator could not place anything in. The Layouts menu now has a Not shown section, present only while the layout holds such a tile. One row per tile: its title (or the module's name plus the variant, or the module id when it is not installed), why in a few words — "not offered right now", "module disabled", "module needs a newer ProdDash", "module not installed" — and a drawn ✕ with a tooltip that removes it, saves the layout and frees the cells, leaving the menu open as the Grid Size stepper and Alerts do. The section repaints while the menu is open whenever tiles become offered or stop being, and only when the list changed. A tile that merely waits for its module keeps its place as before. GET /api/modules also returns `inactive` — the installed modules that are not running, id, name and reason only — so a dashboard can tell a disabled module from one that is not installed. The toast after loading a named layout now says the Layouts menu can remove the tiles it did not show. README and the module guide updated. Co-Authored-By: Claude Fable 5.1 --- README.md | 13 ++++-- docs/MODULE-GUIDE.md | 10 +++-- package.json | 2 +- public/shell.js | 97 ++++++++++++++++++++++++++++++++++++++++++-- public/style.css | 27 ++++++++++++ server.js | 11 ++++- 6 files changed, 147 insertions(+), 13 deletions(-) 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.' }); -- 2.54.0