diff --git a/README.md b/README.md index e10d6eb..c10c64e 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,14 @@ below. Nothing is installed without someone clicking. display options) — these belong to this browser's layout, not the server. - The **grid icon** — save the current arrangement to the server under a name, load a named layout (it becomes a local copy — browsers are never - linked), or reset this browser's dashboard. + 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 + the server keeps the copy it replaced: **Restore previous** in Admin → + Saved layouts swaps it back (and swapping again undoes that). - Every tile has a **status dot**: green = live, amber = connecting, red = upstream unreachable (hover it for details). Tiles reconnect automatically. diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 33e5384..9a911f2 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -592,10 +592,17 @@ module.exports = { - When the user adds an entry, its `settings` overlay your `instanceSchema` defaults in that tile's instance settings, the entry's `name` becomes the tile's title, and `moduleApi.variant` carries its `id`. -- The tile keeps its variant id and title even if the entry later vanishes - from your list (a timer deleted in ProPresenter, a page removed in admin). - **Your client decides what that means** — show a clear "no longer - configured" state rather than erroring. +- **A tile is shown only while its entry is offered.** The shell mounts a + 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. + 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 + registry refresh. Never fall back to your default view in its place: five + different timers turning into five "All timers" is worse than five gaps. - A client-only module can declare a fixed list as a `tiles` array in `module.json` instead (same entry shape). - An empty list (or no `tiles` at all) keeps today's behavior: one picker diff --git a/modules/propresenter-timers/client.js b/modules/propresenter-timers/client.js index c2c10f8..44b975e 100644 --- a/modules/propresenter-timers/client.js +++ b/modules/propresenter-timers/client.js @@ -239,8 +239,6 @@ export default function create({ root, moduleApi }) { : variant.startsWith('timer:') ? `propresenter:${variant.slice('timer:'.length)}` : variant.startsWith('solo:') ? variant.slice('solo:'.length) : ''; - /** Last label seen for a solo timer, so its card stays labeled if the timer is later deleted upstream. */ - let lastSoloLabel = ''; /* ── DOM skeleton ───────────────────────────────────────────────── */ @@ -376,22 +374,21 @@ export default function create({ root, moduleApi }) { /* ── state → what this tile shows ───────────────────────────────── */ - /** [{ id, source, items: [{ key, source, timer } | { key, missing }] }] */ + /** [{ id, source, items: [{ key, source, timer }] }] */ function visibleGroups() { if (!state) return []; if (solo) { for (const source of state.sources) { for (const timer of source.timers) { if (keyOf(source, timer) !== solo) continue; - lastSoloLabel = timer.label; return [{ id: source.id, source, items: [{ key: solo, source, timer }] }]; } } - // The timer this tile was added for isn't offered (yet, or any more) — - // keep a clearly-labeled card up rather than erroring. - const sourceId = solo.slice(0, solo.indexOf(':')); - const source = state.sources.find((s) => s.id === sourceId) || null; - return [{ id: 'missing', source, items: [{ key: solo, missing: true, source }] }]; + // The timer this tile was added for is not offered right now. Nothing is + // drawn for it — no placeholder that reads like a timer, and never the + // full list in its place. The shell folds the tile away on its next + // registry refresh (module guide, Presenting multiple tiles). + return []; } const groups = []; for (const source of state.sources) { @@ -451,7 +448,7 @@ export default function create({ root, moduleApi }) { /** The parts of a card that only change with the snapshot (label, kind). */ function updateCard(card, item) { card.item = item; - card.isClock = !item.missing && item.source?.kind === 'clock'; + card.isClock = item.source?.kind === 'clock'; const isLtc = item.source?.kind === 'ltc'; const badge = card.nameEl.querySelector('.tm-ltc-badge'); if (isLtc && !badge) { @@ -462,29 +459,13 @@ export default function create({ root, moduleApi }) { } else if (!isLtc && badge) { badge.remove(); } - const label = item.missing ? (lastSoloLabel || 'Timer') : card.isClock ? '' : item.timer.label; + const label = card.isClock ? '' : item.timer.label; if (card.labelEl.textContent !== label) { setText(card.labelEl, label); needFit = true; // the title's size follows its length } } - function updateMissingCard(card) { - const source = card.item.source; - setText(card.timeEl, '—'); - card.chars = 5; - let note; - if (!source) note = 'Source not available — check Admin'; - else if (source.status === 'ok') note = `Not offered by ${source.label}`; - else note = '—'; - setText(card.statusEl, note); - card.statusEl.hidden = false; - card.statusEl.className = 'tm-status is-note'; - card.statusEl.dataset.tone = source && source.status === 'ok' ? 'warn' : 'muted'; - card.detailEl.hidden = true; - setCardClass(card, 'tm-card is-muted is-missing'); - } - /* ── clocks ─────────────────────────────────────────────────────── */ function clockOptions() { @@ -633,10 +614,6 @@ export default function create({ root, moduleApi }) { let charsChanged = false; for (const card of cards.values()) { const { item } = card; - if (item.missing) { - updateMissingCard(card); - continue; - } const { source, timer } = item; let text; let tone; @@ -735,8 +712,9 @@ export default function create({ root, moduleApi }) { moduleApi.setStatus('connecting', say(waiting)); return; } - const n = groups.reduce((a, g) => a + g.items.filter((i) => !i.missing).length, 0); - moduleApi.setStatus('ok', `${n} timer${n === 1 ? '' : 's'} from ${shown.length} source${shown.length === 1 ? '' : 's'}`); + const n = groups.reduce((a, g) => a + g.items.length, 0); + if (solo && n === 0) moduleApi.setStatus('connecting', 'Not offered right now'); + else moduleApi.setStatus('ok', `${n} timer${n === 1 ? '' : 's'} from ${shown.length} source${shown.length === 1 ? '' : 's'}`); } /* ── main render: diff groups and cards, update in place ────────── */ @@ -791,7 +769,7 @@ export default function create({ root, moduleApi }) { let hint = ''; const groups = visibleGroups(); if (state && !state.sources.length) hint = 'Every source is switched off — turn one on in Admin.'; - else if (state && !groups.length) hint = 'Nothing selected — switch a source on in this tile’s settings, or pick timers in the title bar’s timers menu.'; + else if (state && !groups.length && !solo) hint = 'Nothing selected — switch a source on in this tile’s settings, or pick timers in the title bar’s timers menu.'; hintEl.hidden = !hint; hintEl.textContent = hint; groupsEl.hidden = Boolean(hint); diff --git a/modules/propresenter-timers/module.json b/modules/propresenter-timers/module.json index 728c444..5e70595 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -1,7 +1,7 @@ { "id": "propresenter-timers", "name": "Timers", - "version": "1.4.0", + "version": "1.4.1", "proddash": ">=1.4.0", "description": "Timers, Clocks, and LTC From configured Modules", "provides": [ diff --git a/modules/propresenter-timers/style.css b/modules/propresenter-timers/style.css index f1b13a8..4513e32 100644 --- a/modules/propresenter-timers/style.css +++ b/modules/propresenter-timers/style.css @@ -295,7 +295,6 @@ } /* A solo tile whose timer is no longer offered. */ -[data-module="propresenter-timers"] .tm-card.is-missing { border-style: dashed; } /* ── the LTC card: visually its own thing ── */ diff --git a/package.json b/package.json index b8e89fb..ba4bb22 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proddash", - "version": "1.7.0", + "version": "1.8.0", "private": true, "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client", "license": "UNLICENSED", diff --git a/public/admin/admin.js b/public/admin/admin.js index cc10af8..f035683 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -909,7 +909,25 @@ async function loadLayouts() { else toast(e.message, true); } }); - row.append(name, meta, rename, del); + // The copy the last save replaced, one back. Restoring swaps the two, + // so a restore is itself one restore away from being undone. + const restore = document.createElement('button'); + restore.className = 'btn'; + restore.textContent = 'Restore previous'; + restore.title = 'Put back the copy the last save replaced'; + restore.hidden = !layout.hasPrevious; + restore.addEventListener('click', async () => { + if (!confirm(`Go back to the previous version of “${layout.name}”? The current one becomes its previous version, so this can be undone the same way.`)) return; + try { + await api(`/api/layouts/${encodeURIComponent(layout.name)}/restore`, { method: 'POST', body: '{}' }); + toast('Previous version restored'); + loadLayouts(); + } catch (e) { + if (e.authRequired) showLogin(); + else toast(e.message, true); + } + }); + row.append(name, meta, restore, rename, del); layoutsList.appendChild(row); } } diff --git a/public/shell.js b/public/shell.js index 9d9294e..128aa97 100644 --- a/public/shell.js +++ b/public/shell.js @@ -16,12 +16,24 @@ const toastEl = document.getElementById('toast'); /* ── tiny helpers ───────────────────────────────────────────────────── */ let toastTimer = null; -function toast(msg, isError = false) { +function toast(msg, isError = false, { action = null, ms } = {}) { toastEl.textContent = msg; + if (action) { + // One button, one verb — "Undo" — for a change worth taking back. + const btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = action.label; + btn.addEventListener('click', () => { + clearTimeout(toastTimer); + toastEl.hidden = true; + action.onClick(); + }); + toastEl.appendChild(btn); + } toastEl.classList.toggle('error', isError); toastEl.hidden = false; clearTimeout(toastTimer); - toastTimer = setTimeout(() => { toastEl.hidden = true; }, 3200); + toastTimer = setTimeout(() => { toastEl.hidden = true; }, ms ?? (action ? 8000 : 3200)); } const autoHideBtn = document.getElementById('autohide-btn'); @@ -70,6 +82,41 @@ function promptModal({ title, placeholder = '', value = '', okLabel = 'Save' }) }); } +/** A yes-or-no question in the same window: resolves true for the OK button. */ +function confirmModal({ title, body = '', okLabel = 'OK' }) { + return new Promise((resolve) => { + modalEl.innerHTML = ''; + const h = document.createElement('h2'); + h.textContent = title; + const copy = document.createElement('p'); + copy.className = 'modal-copy'; + copy.textContent = body; + const actions = document.createElement('div'); + actions.className = 'actions'; + const cancel = document.createElement('button'); + cancel.className = 'btn'; + cancel.textContent = 'Cancel'; + const ok = document.createElement('button'); + ok.className = 'btn primary'; + ok.textContent = okLabel; + actions.append(cancel, ok); + modalEl.append(h, copy, actions); + modalBackdrop.hidden = false; + ok.focus(); + const done = (result) => { + modalBackdrop.hidden = true; + modalEl.innerHTML = ''; + resolve(result); + }; + cancel.addEventListener('click', () => done(false)); + ok.addEventListener('click', () => done(true)); + modalEl.addEventListener('keydown', (e) => { if (e.key === 'Escape') done(false); }, { once: true }); + modalBackdrop.addEventListener('click', (e) => { + if (e.target === modalBackdrop) done(false); + }, { once: true }); + }); +} + /* ── dropdown menus ─────────────────────────────────────────────────── */ /* A menu opens the moment its button is clicked, drawn from what the page @@ -169,17 +216,35 @@ const tileEls = new Map(); // tile id -> { el, body, dot, titleEl, instance } away regardless; this is the windowed equivalent, asked for by hand. */ let autoHideHeads = false; +/* Beside the layout itself: the copy it replaced (restored if the layout + ever fails to parse) and, after loading a named layout, the arrangement + that was on screen before (what Undo goes back to). */ +const LS_LAYOUT_BACKUP = LS_LAYOUT + '.backup'; +const LS_LAYOUT_PREVIOUS = LS_LAYOUT + '.previous'; + function saveLayout() { try { - localStorage.setItem(LS_LAYOUT, JSON.stringify({ v: 1, autoHideHeads, tiles })); + const next = JSON.stringify({ v: 1, autoHideHeads, tiles }); + const current = localStorage.getItem(LS_LAYOUT); + if (current && current !== next) localStorage.setItem(LS_LAYOUT_BACKUP, current); + localStorage.setItem(LS_LAYOUT, next); } catch { /* private mode / full — layout just won't persist */ } } function loadLayoutFromStorage() { - try { - const raw = JSON.parse(localStorage.getItem(LS_LAYOUT) || 'null'); - if (raw && Array.isArray(raw.tiles)) return raw; - } catch { /* corrupted — start fresh */ } + for (const key of [LS_LAYOUT, LS_LAYOUT_BACKUP]) { + try { + const raw = JSON.parse(localStorage.getItem(key) || 'null'); + if (raw && Array.isArray(raw.tiles)) { + // A layout that would not parse is not a reason for an empty wall. + if (key === LS_LAYOUT_BACKUP) { + toast('This browser’s layout was unreadable — the last good copy is back', true); + try { localStorage.setItem(LS_LAYOUT, JSON.stringify(raw)); } catch { /* fine */ } + } + return raw; + } + } catch { /* unreadable — try the copy behind it */ } + } return { tiles: [] }; } @@ -301,6 +366,72 @@ const CLOSE_SVG = ' String(e.id) === variant || (Array.isArray(e.aliases) && e.aliases.includes(variant))); +} + +/** Show tiles that have become offered; fold away tiles that have stopped being. */ +function reconcileOfferedTiles() { + let changed = false; + for (const tile of tiles) { + const shown = tileEls.has(tile.id); + const offered = tileIsOffered(tile); + if (offered && !shown) { + buildTile(tile); + changed = true; + } else if (!offered && shown) { + unmountModule(tile); + tileEls.get(tile.id)?.el.remove(); + tileEls.delete(tile.id); + changed = true; + } + } + if (changed) rebuildDividers(); + scheduleOfferedRecheck(); +} + +/* A module's entry list can change without anyone saving admin config: PCO + Plan comes back and, a few seconds later, the timers module is offering + its timers again. No event marks that moment, so while any tile is folded + away the registry is re-read every few seconds until nothing is. It costs + one GET while something is hidden and nothing at all otherwise. */ +const OFFERED_RECHECK_MS = 6000; +let offeredRecheck = 0; +function scheduleOfferedRecheck() { + clearTimeout(offeredRecheck); + if (!tiles.some((t) => !tileEls.has(t.id))) return; + offeredRecheck = setTimeout(async () => { + offeredRecheck = 0; + if (await loadRegistry()) reconcileOfferedTiles(); + else scheduleOfferedRecheck(); + }, OFFERED_RECHECK_MS); +} + +/** 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); +} + function buildTile(tile) { const man = registry.get(tile.module); @@ -741,6 +872,7 @@ function rebuildDividers() { for (const L of tiles) { for (const R of tiles) { if (L === R || L.x + L.w !== R.x) continue; + if (!tileEls.has(L.id) || !tileEls.has(R.id)) continue; const top = Math.max(L.y, R.y); const bottom = Math.min(L.y + L.h, R.y + R.h); if (bottom <= top) continue; @@ -1861,7 +1993,8 @@ function renderLayout(layout) { clearAllTiles(); tiles = list.map(sanitizeTile); if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false }); - for (const tile of tiles) buildTile(tile); + for (const tile of tiles) if (tileIsOffered(tile)) buildTile(tile); + scheduleOfferedRecheck(); refreshEmptyState(); rebuildDividers(); if (document.body.classList.contains('fullscreen')) applyFullscreenFit(); @@ -1937,6 +2070,21 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => { }); if (!name) return; try { + // Saving over a name is asked about first — and the copy it replaces + // is kept one back on the server, so a yes is not the end of it either. + const existing = await fetch('/api/layouts') + .then((r) => (r.ok ? r.json() : { layouts: [] })) + .then((b) => (b.layouts || []).find((l) => l.name === name)) + .catch(() => null); + if (existing) { + const when = existing.updated ? new Date(existing.updated).toLocaleString() : 'earlier'; + const go = await confirmModal({ + title: `Replace “${name}”?`, + body: `It was saved ${when} with ${existing.tiles} tile${existing.tiles === 1 ? '' : 's'}. The copy it replaces is kept as its previous version.`, + okLabel: 'Replace', + }); + if (!go) return; + } const res = await fetch('/api/layouts/' + encodeURIComponent(name), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -1944,7 +2092,9 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => { }); const body = await res.json().catch(() => ({})); if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')'); - toast(`Saved “${name}” — any browser can load it now`); + toast(body.replaced + ? `Replaced “${name}” — the earlier copy is kept as its previous version` + : `Saved “${name}” — any browser can load it now`); } catch (e) { toast(e.message, true); } @@ -1984,9 +2134,31 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => { if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')'); // Loading copies the layout into this browser's own state — it // does not live-link browsers together. + let outgoing = null; + try { outgoing = localStorage.getItem(LS_LAYOUT); } catch { /* fine */ } renderLayout(body.layout); saveLayout(); - toast(`Loaded “${l.name}”`); + try { if (outgoing) localStorage.setItem(LS_LAYOUT_PREVIOUS, outgoing); } catch { /* fine */ } + const unshown = unshownTileNames(); + const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`; + const note = unshown.length + ? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now` + : ''; + toast(`Loaded “${l.name}”: ${count}${note}`, false, { + ms: unshown.length ? 10000 : 7000, + action: outgoing ? { + label: 'Undo', + onClick() { + try { + renderLayout(JSON.parse(outgoing)); + saveLayout(); + toast('Back to the arrangement you had'); + } catch { + toast('Could not go back', true); + } + }, + } : null, + }); } catch (e) { toast(e.message, true); } @@ -2188,7 +2360,7 @@ function subscribeEvents() { // Rides on the page's one stream connection (see sseSubscribe) as `shell:`. sseSubscribe('/api/events', { events: { - 'modules-changed': () => handleModulesChanged(), + 'modules-changed': () => handleModulesChanged().then(reconcileOfferedTiles), theme: handleThemeEvent, }, }); @@ -2270,6 +2442,10 @@ async function boot() { } renderLayout(loadLayoutFromStorage()); subscribeEvents(); + // Entries a module builds at runtime (a timer per discovered source) can + // land a beat after the first registry read; look once more shortly after + // boot so a tile hidden only by that timing appears without a nudge. + setTimeout(async () => { if (await loadRegistry()) reconcileOfferedTiles(); }, 5000); } boot(); diff --git a/public/style.css b/public/style.css index f7869a0..66d891b 100644 --- a/public/style.css +++ b/public/style.css @@ -906,6 +906,24 @@ body.fullscreen #grid { padding-bottom: 0; } transition: opacity 0.25s ease; } #toast[hidden] { display: none; } +#toast button { + margin-left: 12px; + padding: 3px 10px; + border: 1px solid var(--accent); + border-radius: 5px; + background: none; + color: var(--accent); + font: inherit; + font-weight: 700; + cursor: pointer; +} +#toast button:hover { background: var(--accent); color: var(--accent-text); } +#modal .modal-copy { + color: var(--muted); + font-size: 13px; + line-height: 1.5; + margin: 6px 0 14px; +} #toast.error { border-color: var(--danger); } /* ── scrollbars ────────────────────────────────────────────────────── */ diff --git a/server.js b/server.js index 19b194f..2542d92 100644 --- a/server.js +++ b/server.js @@ -1601,10 +1601,46 @@ function layoutPath(name) { return path.join(LAYOUTS_DIR, name + '.json'); } -/** Keep only the fields a layout is made of; everything else is dropped. */ +/** The copy a save replaced — one back, kept until the next save replaces it. + Names cannot contain a dot (layoutNameOk), so this can never be a real + layout's own file. */ +function previousLayoutPath(name) { + return path.join(LAYOUTS_DIR, name + '.previous.json'); +} + +/* A saved layout is `{ v, autoHideHeads, tiles }`. The fields the shell knows + are checked for shape; anything else a tile carries rides along untouched. + That second part is the lesson of a bug: this function used to rebuild each + tile from the fields it knew and drop the rest, and for seventeen days it + dropped `variant` — every solo timer saved to a server came back as "All + timers". The server is the part of ProdDash that lags (a booth updates + weeks after the dev machine), so it must never be the one deciding which + tile fields exist. Retired fields are the one exception: they are dropped + so an old file does not haunt a new client. */ + +const LAYOUT_VERSION = 2; +const MAX_LAYOUT_TILES = 100; +const KNOWN_TILE_FIELDS = new Set(['id', 'module', 'x', 'y', 'w', 'h', 'settings', 'variant', 'title', 'headPinned', 'minSize']); +const RETIRED_TILE_FIELDS = new Set(['headHidden']); + +/** An unknown tile field is kept when it is a sane key holding small, plain JSON. */ +function keepUnknownTileField(key, value) { + if (!/^[A-Za-z][A-Za-z0-9_]{0,39}$/.test(key)) return false; + switch (typeof value) { + case 'string': return value.length <= 2000; + case 'number': return Number.isFinite(value); + case 'boolean': return true; + case 'object': + if (value === null) return false; + try { return JSON.stringify(value).length <= 20000; } catch { return false; } + default: return false; + } +} + function sanitizeLayout(raw) { - const tiles = Array.isArray(raw?.tiles) ? raw.tiles.slice(0, 100) : []; + const tiles = Array.isArray(raw?.tiles) ? raw.tiles : []; return { + v: LAYOUT_VERSION, // Whether tile title bars auto-hide is part of how a dashboard is // arranged, so it travels with the layout like the tiles themselves. autoHideHeads: Boolean(raw?.autoHideHeads), @@ -1618,8 +1654,6 @@ function sanitizeLayout(raw) { h: Number(t?.h) || 1, settings: t?.settings && typeof t.settings === 'object' ? t.settings : {}, // Which picker entry the tile came from, and what it was called. - // Without these a saved "Now" tile comes back as the full Now/Next - // card, and a solo timer comes back with a full tile's settings. variant: String(t?.variant || ''), title: String(t?.title || ''), headPinned: Boolean(t?.headPinned), @@ -1627,6 +1661,10 @@ function sanitizeLayout(raw) { if (Number(t?.minSize?.w) > 0 && Number(t?.minSize?.h) > 0) { tile.minSize = { w: Number(t.minSize.w), h: Number(t.minSize.h) }; } + for (const [key, value] of Object.entries(t && typeof t === 'object' ? t : {})) { + if (KNOWN_TILE_FIELDS.has(key) || RETIRED_TILE_FIELDS.has(key)) continue; + if (keepUnknownTileField(key, value)) tile[key] = value; + } return tile; }), }; @@ -1640,17 +1678,23 @@ async function handleLayoutsApi(req, res, urlPath) { } catch { /* no layouts dir yet */ } const layouts = []; for (const file of files.sort()) { + if (file.endsWith('.previous.json')) continue; // a save's one-back copy, listed on its layout const name = file.slice(0, -5); const data = readJson(path.join(LAYOUTS_DIR, file), null); if (!data) continue; let updated = null; try { updated = fs.statSync(path.join(LAYOUTS_DIR, file)).mtime.toISOString(); } catch { /* fine */ } - layouts.push({ name, tiles: Array.isArray(data.tiles) ? data.tiles.length : 0, updated }); + layouts.push({ + name, + tiles: Array.isArray(data.tiles) ? data.tiles.length : 0, + updated, + hasPrevious: fs.existsSync(previousLayoutPath(name)), + }); } return sendJson(res, 200, { layouts }); } - const m = urlPath.match(/^\/api\/layouts\/([^/]+)(\/rename)?$/); + const m = urlPath.match(/^\/api\/layouts\/([^/]+)(\/rename|\/restore)?$/); if (!m) return sendJson(res, 404, { error: 'Unknown endpoint.' }); let name; try { @@ -1683,12 +1727,20 @@ async function handleLayoutsApi(req, res, urlPath) { } const layout = sanitizeLayout(body.layout); if (!layout.tiles.length) return sendJson(res, 400, { error: 'Refusing to save an empty layout.' }); + if (layout.tiles.length > MAX_LAYOUT_TILES) { + // Saying no beats quietly saving the first hundred and losing the rest. + return sendJson(res, 400, { error: `A layout can hold ${MAX_LAYOUT_TILES} tiles; this one has ${layout.tiles.length}.` }); + } + const replaced = fs.existsSync(layoutPath(name)); try { + // The copy being replaced is kept one back, so a save over the wrong + // name is a restore away rather than gone. + if (replaced) fs.copyFileSync(layoutPath(name), previousLayoutPath(name)); 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, replaced }); } if (!m[2] && req.method === 'DELETE') { @@ -1698,10 +1750,11 @@ async function handleLayoutsApi(req, res, urlPath) { } catch { return sendJson(res, 404, { error: `No layout named "${name}".` }); } + fs.rmSync(previousLayoutPath(name), { force: true }); return sendJson(res, 200, { ok: true }); } - if (m[2] && req.method === 'POST') { + if (m[2] === '/rename' && req.method === 'POST') { if (refuseAdminWrite(req, res)) return; let body = {}; try { @@ -1725,6 +1778,25 @@ async function handleLayoutsApi(req, res, urlPath) { return sendJson(res, 200, { ok: true, name: next }); } + // Put back the copy the last save replaced. The two swap, so a restore is + // itself one restore away from being undone. + if (m[2] === '/restore' && req.method === 'POST') { + if (refuseAdminWrite(req, res)) return; + const current = layoutPath(name); + const previous = previousLayoutPath(name); + if (!fs.existsSync(previous)) return sendJson(res, 404, { error: `"${name}" has no previous version to go back to.` }); + try { + const swapTmp = current + '.swap'; + const hadCurrent = fs.existsSync(current); + if (hadCurrent) fs.renameSync(current, swapTmp); + fs.renameSync(previous, current); + if (hadCurrent) fs.renameSync(swapTmp, previous); + } catch (err) { + return sendJson(res, 500, { error: 'Restore failed: ' + (err?.message || err) }); + } + return sendJson(res, 200, { ok: true, name }); + } + sendJson(res, 405, { error: 'Method not allowed.' }); } diff --git a/tools/layout-roundtrip.test.js b/tools/layout-roundtrip.test.js new file mode 100644 index 0000000..efec351 --- /dev/null +++ b/tools/layout-roundtrip.test.js @@ -0,0 +1,157 @@ +'use strict'; + +/** + * Round-trips a layout through the server's layouts API and checks that + * nothing is lost on the way: a fully-populated tile, a tile field the server + * has never heard of, the one-back copy a save leaves behind, restore, the + * tile cap and delete. + * + * node tools/layout-roundtrip.test.js + * + * Plain Node, no dependencies. Starts server.js on a spare port with a + * temporary data directory, talks to it over HTTP, exits non-zero on the + * first failure and always stops the server it started. + * + * It exists because of one bug: for seventeen days the server rebuilt each + * saved tile from the fields it knew and dropped the rest — including + * `variant`, so every solo timer saved to a booth came back as "All timers". + * A field added on the client must survive a server that has not heard of + * it, and this is where that is checked. + */ + +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const fs = require('node:fs'); +const net = require('node:net'); +const os = require('node:os'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); + +function freePort() { + return new Promise((resolve, reject) => { + const probe = net.createServer(); + probe.listen(0, '127.0.0.1', () => { + const { port } = probe.address(); + probe.close(() => resolve(port)); + }); + probe.on('error', reject); + }); +} + +async function waitFor(url, ms = 15000) { + const deadline = Date.now() + ms; + let last = ''; + while (Date.now() < deadline) { + try { + const r = await fetch(url); + if (r.ok) return; + last = 'HTTP ' + r.status; + } catch (err) { + last = err.message; + } + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error('server did not come up: ' + last); +} + +(async () => { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-layout-test-')); + const port = await freePort(); + const child = spawn(process.execPath, ['server.js'], { + cwd: ROOT, + env: { ...process.env, PRODDASH_DATA_DIR: dataDir, PORT: String(port), PRODDASH_NO_REMOTE_CHECK: '1', PRODDASH_LAUNCHER: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let log = ''; + child.stdout.on('data', (d) => { log += d; }); + child.stderr.on('data', (d) => { log += d; }); + + const base = `http://127.0.0.1:${port}`; + const api = async (p, init = {}) => { + const r = await fetch(base + p, { headers: { 'Content-Type': 'application/json' }, ...init }); + return { status: r.status, body: await r.json().catch(() => ({})) }; + }; + const layoutsDir = path.join(dataDir, 'layouts'); + let failed = false; + + try { + await waitFor(base + '/api/theme'); + + // Every field the shell writes today, one it will write tomorrow, and one + // it has stopped writing. + const tile = { + id: 't1', module: 'propresenter-timers', x: 4, y: 2, w: 3, h: 2, + settings: { showNames: false, __tint: '#112233' }, + variant: 'solo:pco-plan:service', title: 'Service', headPinned: true, minSize: { w: 2, h: 1 }, + futureField: 'kept', futureFlag: true, futureBox: { a: 1 }, + headHidden: true, + }; + + let r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { autoHideHeads: true, tiles: [tile] } }) }); + assert.equal(r.status, 200, 'save: ' + JSON.stringify(r.body)); + assert.equal(r.body.replaced, false, 'a first save replaces nothing'); + + r = await api('/api/layouts/Booth%20A'); + assert.equal(r.status, 200); + const got = r.body.layout; + assert.equal(got.v, 2, 'the file carries a schema version'); + assert.equal(got.autoHideHeads, true); + const t = got.tiles[0]; + for (const k of ['id', 'module', 'x', 'y', 'w', 'h', 'variant', 'title', 'headPinned']) assert.deepEqual(t[k], tile[k], k); + assert.deepEqual(t.settings, tile.settings, 'settings'); + assert.deepEqual(t.minSize, tile.minSize, 'minSize'); + assert.equal(t.futureField, 'kept', 'an unknown string field survives'); + assert.equal(t.futureFlag, true, 'an unknown boolean survives'); + assert.deepEqual(t.futureBox, { a: 1 }, 'an unknown object survives'); + assert.equal('headHidden' in t, false, 'a retired field is dropped'); + + // Saving over the name keeps the copy it replaced. + r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { tiles: [{ ...tile, title: 'Service v2' }] } }) }); + assert.equal(r.status, 200); + assert.equal(r.body.replaced, true, 'the second save reports a replacement'); + assert.ok(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), 'the replaced copy is kept'); + r = await api('/api/layouts'); + assert.deepEqual(r.body.layouts.map((l) => [l.name, l.hasPrevious]), [['Booth A', true]], + 'the listing shows one layout with a previous copy — not two layouts'); + + // Restore swaps them, so a second restore undoes the first. Admin is open + // here (no PIN), which is what a fresh data directory gives. + r = await api('/api/layouts/Booth%20A/restore', { method: 'POST', body: '{}' }); + assert.equal(r.status, 200, 'restore: ' + JSON.stringify(r.body)); + r = await api('/api/layouts/Booth%20A'); + assert.equal(r.body.layout.tiles[0].title, 'Service', 'restore brings the earlier save back'); + r = await api('/api/layouts/Booth%20A/restore', { method: 'POST', body: '{}' }); + assert.equal(r.status, 200); + r = await api('/api/layouts/Booth%20A'); + assert.equal(r.body.layout.tiles[0].title, 'Service v2', 'restoring again goes forward again'); + r = await api('/api/layouts/Never%20Saved/restore', { method: 'POST', body: '{}' }); + assert.equal(r.status, 404, 'nothing to restore is said, not faked'); + + // Too many tiles is refused, not trimmed. + const many = Array.from({ length: 101 }, (_, i) => ({ ...tile, id: 't' + i })); + r = await api('/api/layouts/Too%20Big', { method: 'PUT', body: JSON.stringify({ layout: { tiles: many } }) }); + assert.equal(r.status, 400); + assert.match(String(r.body.error || ''), /100 tiles/, 'the refusal names the cap'); + assert.equal(fs.existsSync(path.join(layoutsDir, 'Too Big.json')), false, 'nothing was written'); + + // Delete takes the previous copy with it. + r = await api('/api/layouts/Booth%20A', { method: 'DELETE' }); + assert.equal(r.status, 200); + assert.equal(fs.existsSync(path.join(layoutsDir, 'Booth A.json')), false); + assert.equal(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), false, 'the previous copy is gone too'); + r = await api('/api/layouts'); + assert.deepEqual(r.body.layouts, [], 'nothing left listed'); + + console.log('layout round-trip: every check passed'); + } catch (err) { + failed = true; + console.error('FAIL:', err.message); + console.error('--- last of the server log ---\n' + log.slice(-2000)); + } finally { + child.kill('SIGTERM'); + await new Promise((r) => setTimeout(r, 400)); + fs.rmSync(dataDir, { recursive: true, force: true }); + } + process.exit(failed ? 1 : 0); +})();