Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Fable 5.1 7481d1d9e2 Shell 1.13.0: the Layouts menu lists the tiles a layout holds but the board is not showing, with a ✕ to remove each
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 <noreply@anthropic.com>
2026-10-07 11:46:50 -04:00
11 changed files with 184 additions and 246 deletions
+9 -4
View File
@@ -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 —
+7 -3
View File
@@ -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/<id>/...` — your routes, `curl`-able. For SSE:
`curl -N http://localhost:24500/api/modules/<id>/stream`.
- `GET /api/admin/state` — enabled flags, health, mount errors.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "resi-cues",
"name": "Resi Cues",
"version": "1.0.3",
"version": "1.0.0",
"proddash": ">=1.4.0",
"description": "Time until the next Resi cue point, on the board and as a timer",
"provides": ["timers"],
+18 -93
View File
@@ -39,9 +39,6 @@ function msToPosition(total) {
const WINDOW_MS = 120000; // how long a reading stays useful
const JUMP_FLOOR_MS = 5000; // the smallest move ever treated as a seek
const LOGIN_BACKOFF_MS = 15000; // first wait after a failed Resi login
const LOGIN_BACKOFF_MAX_MS = 300000; // longest wait between login attempts
const SESSION_FRESH_MS = 60000; // a refusal younger than this is a permission, not an expired session
/**
* Resi republishes a decoder's `position` only every few seconds, on an irregular
@@ -64,8 +61,6 @@ function createPlayhead(options) {
let offsets = []; // { at, off } — off is mediaMs - wall time it was read
let spreads = []; // how far apart honest readings sit: this link's noise
let gaps = []; // how long between the playhead actually moving
let changes = 0; // readings that differed from the one before
let rejected = false; // the last gap looked like a pause and was skipped
let base; // the trusted offset
let lastMedia;
let lastNewAt;
@@ -95,25 +90,15 @@ function createPlayhead(options) {
spreads = spreads.concat(-delta).slice(-20);
}
}
// The first reading lands at an arbitrary point in Resi's cycle, so the gap
// from it to the next one is only a fraction of the real cadence. Learning
// from it locked in a 2s cadence that rejected every honest 30s gap.
if (lastNewAt !== undefined && changes > 0) {
if (lastNewAt !== undefined) {
const gap = t - lastNewAt;
// The gap either side of a pause is not Resi's cadence — recording it
// would inflate the estimate and make the next pause slow to notice.
// But a pause is one gap; twice in a row is the cadence having changed.
const worst = gaps.length ? Math.max.apply(null, gaps) : 0;
if (gap > 0 && gap < 60000) {
if (!worst || gap <= worst * 3 || rejected) {
gaps = gaps.concat(gap).slice(-8);
rejected = false;
} else {
rejected = true;
}
if (gap > 0 && gap < 60000 && (!worst || gap <= worst * 3)) {
gaps = gaps.concat(gap).slice(-8);
}
}
if (lastNewAt !== undefined) changes++;
offsets = offsets.concat({ at: t, off });
lastMedia = mediaMs;
lastNewAt = t;
@@ -133,9 +118,7 @@ function createPlayhead(options) {
// Only call it paused once the playhead has been still for clearly longer than
// Resi's own cadence. A fixed timeout mistakes a slow update for a stop, and a
// countdown that freezes mid-service is the worse failure of the two.
// Until a full gap has been measured the cadence is unknown; assume the slowest
// Resi plausibly is, or a 30s publisher reads as paused 15s after every start.
const pauseAfter = cadenceMs ? Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000)) : 45000;
const pauseAfter = Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000));
const playing = base !== undefined && t - lastNewAt < pauseAfter;
if (!playing) {
return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs };
@@ -178,43 +161,13 @@ function createResiClient(options) {
return h;
}
/* A failed login used to be retried on the very next poll -- once a second, for
ever. Resi answers that with 429s, and since the retries never let up, the
account never got out of them. So a failure now holds off further attempts,
doubling from 15s to 5min (or as long as Resi's Retry-After asks), and every
call in between fails at once with the same reason instead of signing in. */
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
let loginFailures = 0;
let loginBlockedUntil = 0;
let loginError = '';
async function login() {
if (now() < loginBlockedUntil) {
const wait = Math.ceil((loginBlockedUntil - now()) / 1000);
throw new Error(`${loginError} — retrying in ${wait}s`);
}
let res;
try {
res = await doFetch(`${RESI}/api/v3/login?newToken=true`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ userName: opts.username, password: opts.password }),
});
} catch (err) {
holdOff(`Resi unreachable (${(err && err.message) || err})`, 0);
throw new Error(loginError);
}
if (!res.ok) {
const reason = res.status === 429 ? 'Resi is rate-limiting this login (429)'
: res.status === 400 || res.status === 401 ? `Resi rejected the login (${res.status}) — check the password`
: `Resi login failed (${res.status})`;
const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 0;
holdOff(reason, retryAfter * 1000);
throw new Error(reason);
}
loginFailures = 0;
loginBlockedUntil = 0;
loginError = '';
const res = await doFetch(`${RESI}/api/v3/login?newToken=true`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ userName: opts.username, password: opts.password }),
});
if (!res.ok) throw new Error(`Resi login failed (${res.status})`);
const setCookies = (res.headers.getSetCookie && res.headers.getSetCookie())
|| (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
const jar = {};
@@ -226,49 +179,24 @@ function createResiClient(options) {
token = jar.livingas1 || '';
if (!token) throw new Error('Resi login returned no session cookie');
cookieHeader = Object.keys(jar).map((k) => `${k}=${jar[k]}`).join('; ');
loggedInAt = now();
loggedInAt = Date.now();
const me = await get('/api_v2.svc/users/me');
customerId = (me && me.customerId) || '';
if (!customerId) throw new Error('This Resi account has no customerId');
return me;
}
function holdOff(reason, askedMs) {
loginFailures++;
const backoff = Math.min(LOGIN_BACKOFF_MAX_MS, LOGIN_BACKOFF_MS * 2 ** (loginFailures - 1));
loginBlockedUntil = now() + Math.max(backoff, askedMs || 0);
loginError = reason;
}
async function ensureSession() {
if (!token || now() - loggedInAt > 50 * 60 * 1000) await login();
if (!token || Date.now() - loggedInAt > 50 * 60 * 1000) await login();
}
/* The same hold-off covers every request, not just the login. A 401/403 used to
sign in again on the spot, every time: for a login that simply isn't allowed to
see a venue, that was a fresh sign-in on every poll. Now a refusal straight
after signing in is taken as what it is -- a permission -- and held off like a
failed login; only an older session is renewed. A 429 holds off too. */
async function get(path) {
if (now() < loginBlockedUntil) {
const wait = Math.ceil((loginBlockedUntil - now()) / 1000);
throw new Error(`${loginError} — retrying in ${wait}s`);
}
let res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
if ((res.status === 401 || res.status === 403) && now() - loggedInAt > SESSION_FRESH_MS) {
await login();
res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
}
const res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
if (res.status === 401 || res.status === 403) {
const where = path.includes('/venues') ? 'its venues'
: path.includes('/players') ? "this venue's decoders" : 'part of the account';
holdOff(`This Resi login isn't allowed to see ${where} (${res.status}) — ask a Resi admin for access`, 0);
throw new Error(loginError);
}
if (res.status === 429) {
const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 0;
holdOff('Resi is rate-limiting requests (429)', retryAfter * 1000);
throw new Error(loginError);
await login();
const retry = await doFetch(RESI + path, { method: 'GET', headers: headers() });
if (!retry.ok) throw new Error(`Resi ${path} failed (${retry.status})`);
return retry.json();
}
if (!res.ok) throw new Error(`Resi ${path} failed (${res.status})`);
return res.json();
@@ -288,10 +216,7 @@ function createResiClient(options) {
try {
return (await get(`${path}?_=${Date.now()}`)) || [];
} catch (err) {
// Only an objection to the parameter itself; a refusal or a "slow down"
// (401/403/429) is about the login, and dropping it then lost fresh readings.
const code = (String(err.message).match(/\((4\d\d)\)/) || [])[1];
if (!code || ['401', '403', '429'].includes(code)) throw err;
if (!/\((4\d\d)\)/.test(String(err.message))) throw err;
cacheBust = false;
}
}
-26
View File
@@ -197,32 +197,6 @@ test('surroundingCues puts a cue you are exactly on behind you', () => {
assert.strictEqual(surroundingCues([], 1000).next, null);
});
/* ── starting partway through Resi's cycle ─────────────────────────────── */
// Seen live at North Attleboro: Resi publishing every ~30s, and the board starting a
// few seconds before a publish. The first "gap" it measured was 2s (9s after a
// reboot), and once that was the cadence every honest 30s gap was thrown out as a
// pause gap -- so playback read as paused 15s into every update, the countdown froze,
// and the next cue (and its name) stopped moving on.
for (const [into, burst] of [[28000, false], [21000, false], [0, true]]) {
test(`30s Resi, started ${into / 1000}s into its cycle${burst ? ', 2s burst after a seek' : ''}: never falsely paused`, () => {
const w = makeWorld({ cadence: 30000, lag: 1000, jitter: 2000, net: 500 });
w.lastPublish = w.t - into;
const head = createPlayhead({ now: () => w.t });
const second = () => { w.advance(1000); head.note(w.poll()); return head.read(); };
let paused = 0;
for (let i = 0; i < 100; i++) if (!second().playing) paused++;
if (burst) {
w.media += 15 * 60000; // a seek, published at once...
w.lastPublish = w.t - w.nextCadence;
second(); second();
w.lastPublish = w.t - w.nextCadence; // ...and again 2s later
}
for (let i = 0; i < 300; i++) if (!second().playing) paused++;
assert.strictEqual(paused, 0, `read as paused for ${paused}s while playing`);
});
}
/* ── the matrix ────────────────────────────────────────────────────────── */
const argv = process.argv.slice(2).map(Number);
+4 -20
View File
@@ -70,7 +70,6 @@ function emptyTimers(now) {
}
let current = null; // the live handle, shared with routes()
let pickerClient = null; // { key, client } for the decoder picker when the module isn't running those credentials
module.exports = {
init({ config, log }) {
@@ -147,9 +146,7 @@ module.exports = {
elapsedMs: null,
remainingMs,
targetMs: Math.max(1000, state.next.ms - from),
// The label already is the cue's name, and it is what the card is titled
// with; repeating it on the status line only crowded the card.
status: { text: state.playing ? 'Next cue' : 'Held', tone: state.playing ? 'ok' : 'warn' },
status: { text: state.next.name || 'Next cue', tone: state.playing ? 'ok' : 'warn' },
detail: state.event || '',
});
}
@@ -274,7 +271,6 @@ module.exports = {
stateStreams,
timerStreams,
resi,
credentialsKey: `${username}\n${password}`,
configured: Boolean(username && password),
stop() {
stopped = true;
@@ -336,21 +332,9 @@ module.exports = {
return json(res, 200, { options: [], note: 'Enter the Resi login first' });
}
try {
// The running module's client when it has these credentials, else one kept
// per login: a fresh client per request signed in on every admin page load,
// and its own hold-off never applied -- which kept Resi rate-limiting.
const username = String(config.username).trim();
const key = `${username}\n${config.password}`;
let client;
if (current && current.resi && current.credentialsKey === key) client = current.resi;
else {
if (!pickerClient || pickerClient.key !== key) {
pickerClient = { key, client: createResiClient({
username, password: String(config.password),
}) };
}
client = pickerClient.client;
}
const client = createResiClient({
username: String(config.username), password: String(config.password),
});
const options = [];
for (const v of await client.venues()) {
for (const p of await client.players(v.uuid)) {
+14 -93
View File
@@ -18,9 +18,8 @@
const assert = require('assert');
let passed = 0;
// Awaited, so an async test finishes before the next one changes the world under it.
async function test(name, fn) {
await fn();
function test(name, fn) {
fn();
passed++;
console.log(` ok ${name}`);
}
@@ -102,7 +101,7 @@ function collect(route, req) {
const routes = server.routes({ config: {}, log: (m) => logged.push(m) });
await test('signs in once and finds the configured decoder', () => {
test('signs in once and finds the configured decoder', () => {
assert.strictEqual(world.logins, 1, 'should sign in exactly once');
const state = handle.state;
assert.strictEqual(state.status, 'ok', state.message);
@@ -111,20 +110,20 @@ function collect(route, req) {
assert.strictEqual(state.cues.length, 3);
});
await test('works out the cue either side of the playhead', () => {
test('works out the cue either side of the playhead', () => {
const state = handle.state;
assert.strictEqual(state.next.name, 'Campus Host');
assert.strictEqual(state.prev.name, 'Welcome');
assert.ok(Math.abs(state.estMs - 528000) < 2000, `playhead was ${state.estMs}`);
});
await test('/state answers the same snapshot as the stream', () => {
test('/state answers the same snapshot as the stream', () => {
const res = collect(routes['GET /state']);
assert.strictEqual(res.code, 200);
assert.deepStrictEqual(JSON.parse(res.body), handle.state);
});
await test('/stream opens with an event so a new tile paints at once', () => {
test('/stream opens with an event so a new tile paints at once', () => {
const res = collect(routes['GET /stream']);
assert.strictEqual(res.code, 200);
assert.strictEqual(res.headers['Content-Type'], 'text/event-stream; charset=utf-8');
@@ -132,7 +131,7 @@ function collect(route, req) {
assert.ok(res.body.includes('event: state'), 'should send an initial state event');
});
await test('/timers offers the countdown and the playhead, shaped as the guide says', () => {
test('/timers offers the countdown and the playhead, shaped as the guide says', () => {
const snap = JSON.parse(collect(routes['GET /timers']).body);
assert.strictEqual(snap.source, 'resi-cues');
assert.strictEqual(snap.label, 'Resi');
@@ -146,7 +145,7 @@ function collect(route, req) {
assert.ok(Math.abs(next.remainingMs - 222000) < 2000, `remaining was ${next.remainingMs}`);
// the countdown runs from the cue before it, so a consumer can colour progress
assert.strictEqual(next.targetMs, 450000);
assert.strictEqual(next.status.text, 'Next cue'); // the name is the label; not repeated
assert.strictEqual(next.status.text, 'Campus Host');
assert.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past');
assert.strictEqual(playhead.id, 'playhead');
@@ -155,7 +154,7 @@ function collect(route, req) {
assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000);
});
await test('a cue fired at the host moves the countdown on the next poll', async () => {
test('a cue fired at the host moves the countdown on the next poll', async () => {
world.position = '00:12:40.000'; // jumped past Campus Host
tickFn();
await settle();
@@ -163,10 +162,10 @@ function collect(route, req) {
assert.strictEqual(state.next.name, 'Message');
assert.strictEqual(state.prev.name, 'Campus Host');
const snap = JSON.parse(collect(routes['GET /timers']).body);
assert.strictEqual(snap.timers[0].label, 'Message'); // the card's name follows the cue
assert.strictEqual(snap.timers[0].label, 'Message');
});
await test('a decoder with no event reports nothing playing rather than stale cues', async () => {
test('a decoder with no event reports nothing playing rather than stale cues', async () => {
world.eventId = '';
tickFn();
await settle();
@@ -175,13 +174,13 @@ function collect(route, req) {
assert.strictEqual(handle.state.event, '');
});
await test('health speaks about the upstream, not about itself', () => {
test('health speaks about the upstream, not about itself', () => {
const h = handle.health();
assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status);
assert.ok(h.message.length > 0, 'health should say something useful');
});
await test('an unconfigured module idles instead of hammering Resi', () => {
test('an unconfigured module idles instead of hammering Resi', () => {
const before = world.logins;
const idle = server.init({ config: {}, log: () => {} });
assert.strictEqual(world.logins, before, 'must not sign in with no credentials');
@@ -190,90 +189,12 @@ function collect(route, req) {
idle.stop();
});
await test('stop() leaves nothing running', () => {
test('stop() leaves nothing running', () => {
handle.stop();
const res = collect(routes['GET /state']);
assert.strictEqual(JSON.parse(res.body).status, 'error');
});
// Seen live at North Attleboro: a failed login was retried on every 1s poll, so
// Resi answered 429 and kept answering it, and the decoder picker stayed empty.
await test('a failed login holds off instead of retrying every poll', async () => {
const { createResiClient } = require('./resi');
let t = 1700000000000;
let logins = 0;
let limited = true;
const client = createResiClient({
username: 'a@b.c',
password: 'secret',
now: () => t,
fetch: async (url) => {
const path = String(url).replace('https://central.resi.io', '').split('?')[0];
if (path === '/api/v3/login') {
logins++;
if (limited) return reply({}, { ok: false, status: 429 });
return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=T; Path=/'] } });
}
if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST' });
if (path === '/api/v3/customers/CUST/venues') return reply([{ uuid: 'V1' }]);
return reply({}, { ok: false, status: 404 });
},
});
const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } };
assert.match(await poll(), /rate-limiting/);
for (let i = 0; i < 60; i++) { t += 1000; await poll(); } // a minute of 1s polls
assert.ok(logins <= 3, `signed in ${logins} times in a minute while rate-limited`);
assert.match(await poll(), /retrying in \d+s/, 'the wait is shown, not hidden');
limited = false;
t += 5 * 60000; // past the longest hold-off
assert.strictEqual(await poll(), 'ok', 'recovers once Resi lets it in');
const after = logins;
for (let i = 0; i < 30; i++) { t += 1000; await poll(); }
assert.strictEqual(logins, after, 'a good session is not signed in again');
});
await test('a rejected password says so', async () => {
const { createResiClient } = require('./resi');
const client = createResiClient({
username: 'a@b.c', password: 'wrong', now: () => 0,
fetch: async () => reply({}, { ok: false, status: 400 }),
});
await assert.rejects(client.venues(), /check the password/);
});
// Seen live: media@waters.church signs in fine but may not list venues (403). The
// client answered every 403 by signing in again -- a fresh login on every poll.
await test('a login that may not see venues is held off, not signed in every poll', async () => {
const { createResiClient } = require('./resi');
let t = 1700000000000;
let logins = 0;
let venueCalls = 0;
const client = createResiClient({
username: 'a@b.c', password: 'secret', now: () => t,
fetch: async (url) => {
const path = String(url).replace('https://central.resi.io', '').split('?')[0];
if (path === '/api/v3/login') {
logins++;
return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=T; Path=/'] } });
}
if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST' });
if (path === '/api/v3/customers/CUST/venues') { venueCalls++; return reply({}, { ok: false, status: 403 }); }
return reply({}, { ok: false, status: 404 });
},
});
const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } };
assert.match(await poll(), /isn't allowed to see its venues \(403\)/);
for (let i = 0; i < 120; i++) { t += 1000; await poll(); } // two minutes of 1s polls
// At most one renewal once the session is over a minute old (in case it really
// had expired), then the hold-off keeps growing -- not one sign-in per poll.
assert.ok(logins <= 2, `signed in ${logins} times in two minutes for a permission problem`);
assert.ok(venueCalls <= 8, `asked for venues ${venueCalls} times in two minutes (a poll each would be 121)`);
assert.match(await poll(), /ask a Resi admin/);
});
globalThis.setInterval = realSetInterval;
globalThis.clearInterval = realClearInterval;
console.log(`\n${passed} tests passed`);
+1 -1
View File
@@ -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",
+93 -4
View File
@@ -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 = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true
registry offers them again: a Service timer waits for PCO Plan, a
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
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) {
const man = registry.get(tile.module);
@@ -521,6 +528,7 @@ function reconcileOfferedTiles() {
if (changed) rebuildDividers();
scheduleOfferedRecheck();
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
@@ -540,10 +548,90 @@ function scheduleOfferedRecheck() {
}, 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. */
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,
+27
View File
@@ -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 {
+10 -1
View File
@@ -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.' });