Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60f82690f3 | ||
|
|
79d3237328 | ||
|
|
67f612e371 | ||
|
|
0f0adb4c60 |
@@ -316,15 +316,10 @@ below. Nothing is installed without someone clicking.
|
|||||||
name, load a named layout (it becomes a local copy — browsers are never
|
name, load a named layout (it becomes a local copy — browsers are never
|
||||||
linked), or reset this browser's dashboard. Loading says what it did —
|
linked), or reset this browser's dashboard. Loading says what it did —
|
||||||
*Loaded "FOH": 9 tiles, 2 not shown — Service, #ops aren't offered right
|
*Loaded "FOH": 9 tiles, 2 not shown — Service, #ops aren't offered right
|
||||||
now; the Layouts menu can remove them* — with **Undo** in the message to
|
now* — with **Undo** in the message to get back the arrangement you had.
|
||||||
get back the arrangement you had. A tile whose module or picker entry
|
A tile whose module or picker entry isn't offered at the moment keeps its
|
||||||
isn't offered at the moment keeps its place and shows nothing, then
|
place and shows nothing, then appears when it is; it is never replaced by
|
||||||
appears when it is; it is never replaced by the module's default tile.
|
the module's default tile. Saving over an existing name asks first, and
|
||||||
While any tile is in that state the same menu has a **Not shown** section
|
|
||||||
listing each one with why — *not offered right now*, *module disabled*,
|
|
||||||
*module not installed* — and a ✕ that removes it and frees its cells, for
|
|
||||||
the tile that is not coming back (a module that stopped offering that
|
|
||||||
kind of tile). Saving over an existing name asks first, and
|
|
||||||
the server keeps the copy it replaced: **Restore previous** in Admin →
|
the server keeps the copy it replaced: **Restore previous** in Admin →
|
||||||
Saved layouts swaps it back (and swapping again undoes that). The menu's
|
Saved layouts swaps it back (and swapping again undoes that). The menu's
|
||||||
**Grid Size** stepper sets how many columns this browser cuts the board into —
|
**Grid Size** stepper sets how many columns this browser cuts the board into —
|
||||||
|
|||||||
@@ -688,11 +688,8 @@ module.exports = {
|
|||||||
tile added from an entry only when that entry (or one of its `aliases`) is
|
tile added from an entry only when that entry (or one of its `aliases`) is
|
||||||
in your list right now; otherwise the tile keeps its place in the layout,
|
in your list right now; otherwise the tile keeps its place in the layout,
|
||||||
draws nothing, and appears the moment your list offers it again — a
|
draws nothing, and appears the moment your list offers it again — a
|
||||||
Service timer waits for PCO Plan, a transcript for its channel. The
|
Service timer waits for PCO Plan, a transcript for its channel. So your
|
||||||
operator can remove such a tile from the Layouts menu's **Not shown**
|
client is never asked to render an entry you did not offer at mount time.
|
||||||
section, so an entry you retire for good (a send box that moved to another
|
|
||||||
module) leaves no dead cells behind. So your client is never asked to
|
|
||||||
render an entry you did not offer at mount time.
|
|
||||||
One case is still yours: an entry that vanishes *after* the tile mounted
|
One case is still yours: an entry that vanishes *after* the tile mounted
|
||||||
(a timer deleted in ProPresenter mid-service). Render nothing for it — no
|
(a timer deleted in ProPresenter mid-service). Render nothing for it — no
|
||||||
placeholder, no error — and the shell folds the tile away at its next
|
placeholder, no error — and the shell folds the tile away at its next
|
||||||
@@ -1047,8 +1044,7 @@ client-side failures (a tile that fails to start says so in the tile).
|
|||||||
|
|
||||||
**Poke the pieces directly:**
|
**Poke the pieces directly:**
|
||||||
|
|
||||||
- `GET /api/modules` — what the shell sees (your manifest + non-secret
|
- `GET /api/modules` — what the shell sees (your manifest + non-secret config).
|
||||||
config; `inactive` names the installed modules that are not running).
|
|
||||||
- `GET /api/modules/<id>/...` — your routes, `curl`-able. For SSE:
|
- `GET /api/modules/<id>/...` — your routes, `curl`-able. For SSE:
|
||||||
`curl -N http://localhost:24500/api/modules/<id>/stream`.
|
`curl -N http://localhost:24500/api/modules/<id>/stream`.
|
||||||
- `GET /api/admin/state` — enabled flags, health, mount errors.
|
- `GET /api/admin/state` — enabled flags, health, mount errors.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "resi-cues",
|
"id": "resi-cues",
|
||||||
"name": "Resi Cues",
|
"name": "Resi Cues",
|
||||||
"version": "1.0.0",
|
"version": "1.0.3",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Time until the next Resi cue point, on the board and as a timer",
|
"description": "Time until the next Resi cue point, on the board and as a timer",
|
||||||
"provides": ["timers"],
|
"provides": ["timers"],
|
||||||
|
|||||||
+94
-19
@@ -39,6 +39,9 @@ function msToPosition(total) {
|
|||||||
|
|
||||||
const WINDOW_MS = 120000; // how long a reading stays useful
|
const WINDOW_MS = 120000; // how long a reading stays useful
|
||||||
const JUMP_FLOOR_MS = 5000; // the smallest move ever treated as a seek
|
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
|
* Resi republishes a decoder's `position` only every few seconds, on an irregular
|
||||||
@@ -61,6 +64,8 @@ function createPlayhead(options) {
|
|||||||
let offsets = []; // { at, off } — off is mediaMs - wall time it was read
|
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 spreads = []; // how far apart honest readings sit: this link's noise
|
||||||
let gaps = []; // how long between the playhead actually moving
|
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 base; // the trusted offset
|
||||||
let lastMedia;
|
let lastMedia;
|
||||||
let lastNewAt;
|
let lastNewAt;
|
||||||
@@ -90,15 +95,25 @@ function createPlayhead(options) {
|
|||||||
spreads = spreads.concat(-delta).slice(-20);
|
spreads = spreads.concat(-delta).slice(-20);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (lastNewAt !== undefined) {
|
// 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) {
|
||||||
const gap = t - lastNewAt;
|
const gap = t - lastNewAt;
|
||||||
// The gap either side of a pause is not Resi's cadence — recording it
|
// 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.
|
// 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;
|
const worst = gaps.length ? Math.max.apply(null, gaps) : 0;
|
||||||
if (gap > 0 && gap < 60000 && (!worst || gap <= worst * 3)) {
|
if (gap > 0 && gap < 60000) {
|
||||||
gaps = gaps.concat(gap).slice(-8);
|
if (!worst || gap <= worst * 3 || rejected) {
|
||||||
|
gaps = gaps.concat(gap).slice(-8);
|
||||||
|
rejected = false;
|
||||||
|
} else {
|
||||||
|
rejected = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (lastNewAt !== undefined) changes++;
|
||||||
offsets = offsets.concat({ at: t, off });
|
offsets = offsets.concat({ at: t, off });
|
||||||
lastMedia = mediaMs;
|
lastMedia = mediaMs;
|
||||||
lastNewAt = t;
|
lastNewAt = t;
|
||||||
@@ -118,7 +133,9 @@ function createPlayhead(options) {
|
|||||||
// Only call it paused once the playhead has been still for clearly longer than
|
// 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
|
// 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.
|
// countdown that freezes mid-service is the worse failure of the two.
|
||||||
const pauseAfter = Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000));
|
// 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 playing = base !== undefined && t - lastNewAt < pauseAfter;
|
const playing = base !== undefined && t - lastNewAt < pauseAfter;
|
||||||
if (!playing) {
|
if (!playing) {
|
||||||
return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs };
|
return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs };
|
||||||
@@ -161,13 +178,43 @@ function createResiClient(options) {
|
|||||||
return h;
|
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() {
|
async function login() {
|
||||||
const res = await doFetch(`${RESI}/api/v3/login?newToken=true`, {
|
if (now() < loginBlockedUntil) {
|
||||||
method: 'POST',
|
const wait = Math.ceil((loginBlockedUntil - now()) / 1000);
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
throw new Error(`${loginError} — retrying in ${wait}s`);
|
||||||
body: JSON.stringify({ userName: opts.username, password: opts.password }),
|
}
|
||||||
});
|
let res;
|
||||||
if (!res.ok) throw new Error(`Resi login failed (${res.status})`);
|
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 setCookies = (res.headers.getSetCookie && res.headers.getSetCookie())
|
const setCookies = (res.headers.getSetCookie && res.headers.getSetCookie())
|
||||||
|| (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
|
|| (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
|
||||||
const jar = {};
|
const jar = {};
|
||||||
@@ -179,24 +226,49 @@ function createResiClient(options) {
|
|||||||
token = jar.livingas1 || '';
|
token = jar.livingas1 || '';
|
||||||
if (!token) throw new Error('Resi login returned no session cookie');
|
if (!token) throw new Error('Resi login returned no session cookie');
|
||||||
cookieHeader = Object.keys(jar).map((k) => `${k}=${jar[k]}`).join('; ');
|
cookieHeader = Object.keys(jar).map((k) => `${k}=${jar[k]}`).join('; ');
|
||||||
loggedInAt = Date.now();
|
loggedInAt = now();
|
||||||
const me = await get('/api_v2.svc/users/me');
|
const me = await get('/api_v2.svc/users/me');
|
||||||
customerId = (me && me.customerId) || '';
|
customerId = (me && me.customerId) || '';
|
||||||
if (!customerId) throw new Error('This Resi account has no customerId');
|
if (!customerId) throw new Error('This Resi account has no customerId');
|
||||||
return me;
|
return me;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureSession() {
|
function holdOff(reason, askedMs) {
|
||||||
if (!token || Date.now() - loggedInAt > 50 * 60 * 1000) await login();
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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) {
|
async function get(path) {
|
||||||
const res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
if (now() < loginBlockedUntil) {
|
||||||
if (res.status === 401 || res.status === 403) {
|
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();
|
await login();
|
||||||
const retry = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||||
if (!retry.ok) throw new Error(`Resi ${path} failed (${retry.status})`);
|
}
|
||||||
return retry.json();
|
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);
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new Error(`Resi ${path} failed (${res.status})`);
|
if (!res.ok) throw new Error(`Resi ${path} failed (${res.status})`);
|
||||||
return res.json();
|
return res.json();
|
||||||
@@ -216,7 +288,10 @@ function createResiClient(options) {
|
|||||||
try {
|
try {
|
||||||
return (await get(`${path}?_=${Date.now()}`)) || [];
|
return (await get(`${path}?_=${Date.now()}`)) || [];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!/\((4\d\d)\)/.test(String(err.message))) throw 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;
|
||||||
cacheBust = false;
|
cacheBust = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,6 +197,32 @@ test('surroundingCues puts a cue you are exactly on behind you', () => {
|
|||||||
assert.strictEqual(surroundingCues([], 1000).next, null);
|
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 ────────────────────────────────────────────────────────── */
|
/* ── the matrix ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const argv = process.argv.slice(2).map(Number);
|
const argv = process.argv.slice(2).map(Number);
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ function emptyTimers(now) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let current = null; // the live handle, shared with routes()
|
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 = {
|
module.exports = {
|
||||||
init({ config, log }) {
|
init({ config, log }) {
|
||||||
@@ -146,7 +147,9 @@ module.exports = {
|
|||||||
elapsedMs: null,
|
elapsedMs: null,
|
||||||
remainingMs,
|
remainingMs,
|
||||||
targetMs: Math.max(1000, state.next.ms - from),
|
targetMs: Math.max(1000, state.next.ms - from),
|
||||||
status: { text: state.next.name || 'Next cue', tone: state.playing ? 'ok' : 'warn' },
|
// 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' },
|
||||||
detail: state.event || '',
|
detail: state.event || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -271,6 +274,7 @@ module.exports = {
|
|||||||
stateStreams,
|
stateStreams,
|
||||||
timerStreams,
|
timerStreams,
|
||||||
resi,
|
resi,
|
||||||
|
credentialsKey: `${username}\n${password}`,
|
||||||
configured: Boolean(username && password),
|
configured: Boolean(username && password),
|
||||||
stop() {
|
stop() {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
@@ -332,9 +336,21 @@ module.exports = {
|
|||||||
return json(res, 200, { options: [], note: 'Enter the Resi login first' });
|
return json(res, 200, { options: [], note: 'Enter the Resi login first' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const client = createResiClient({
|
// The running module's client when it has these credentials, else one kept
|
||||||
username: String(config.username), password: String(config.password),
|
// 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 options = [];
|
const options = [];
|
||||||
for (const v of await client.venues()) {
|
for (const v of await client.venues()) {
|
||||||
for (const p of await client.players(v.uuid)) {
|
for (const p of await client.players(v.uuid)) {
|
||||||
|
|||||||
@@ -18,8 +18,9 @@
|
|||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
|
|
||||||
let passed = 0;
|
let passed = 0;
|
||||||
function test(name, fn) {
|
// Awaited, so an async test finishes before the next one changes the world under it.
|
||||||
fn();
|
async function test(name, fn) {
|
||||||
|
await fn();
|
||||||
passed++;
|
passed++;
|
||||||
console.log(` ok ${name}`);
|
console.log(` ok ${name}`);
|
||||||
}
|
}
|
||||||
@@ -101,7 +102,7 @@ function collect(route, req) {
|
|||||||
|
|
||||||
const routes = server.routes({ config: {}, log: (m) => logged.push(m) });
|
const routes = server.routes({ config: {}, log: (m) => logged.push(m) });
|
||||||
|
|
||||||
test('signs in once and finds the configured decoder', () => {
|
await test('signs in once and finds the configured decoder', () => {
|
||||||
assert.strictEqual(world.logins, 1, 'should sign in exactly once');
|
assert.strictEqual(world.logins, 1, 'should sign in exactly once');
|
||||||
const state = handle.state;
|
const state = handle.state;
|
||||||
assert.strictEqual(state.status, 'ok', state.message);
|
assert.strictEqual(state.status, 'ok', state.message);
|
||||||
@@ -110,20 +111,20 @@ function collect(route, req) {
|
|||||||
assert.strictEqual(state.cues.length, 3);
|
assert.strictEqual(state.cues.length, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('works out the cue either side of the playhead', () => {
|
await test('works out the cue either side of the playhead', () => {
|
||||||
const state = handle.state;
|
const state = handle.state;
|
||||||
assert.strictEqual(state.next.name, 'Campus Host');
|
assert.strictEqual(state.next.name, 'Campus Host');
|
||||||
assert.strictEqual(state.prev.name, 'Welcome');
|
assert.strictEqual(state.prev.name, 'Welcome');
|
||||||
assert.ok(Math.abs(state.estMs - 528000) < 2000, `playhead was ${state.estMs}`);
|
assert.ok(Math.abs(state.estMs - 528000) < 2000, `playhead was ${state.estMs}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('/state answers the same snapshot as the stream', () => {
|
await test('/state answers the same snapshot as the stream', () => {
|
||||||
const res = collect(routes['GET /state']);
|
const res = collect(routes['GET /state']);
|
||||||
assert.strictEqual(res.code, 200);
|
assert.strictEqual(res.code, 200);
|
||||||
assert.deepStrictEqual(JSON.parse(res.body), handle.state);
|
assert.deepStrictEqual(JSON.parse(res.body), handle.state);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('/stream opens with an event so a new tile paints at once', () => {
|
await test('/stream opens with an event so a new tile paints at once', () => {
|
||||||
const res = collect(routes['GET /stream']);
|
const res = collect(routes['GET /stream']);
|
||||||
assert.strictEqual(res.code, 200);
|
assert.strictEqual(res.code, 200);
|
||||||
assert.strictEqual(res.headers['Content-Type'], 'text/event-stream; charset=utf-8');
|
assert.strictEqual(res.headers['Content-Type'], 'text/event-stream; charset=utf-8');
|
||||||
@@ -131,7 +132,7 @@ function collect(route, req) {
|
|||||||
assert.ok(res.body.includes('event: state'), 'should send an initial state event');
|
assert.ok(res.body.includes('event: state'), 'should send an initial state event');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('/timers offers the countdown and the playhead, shaped as the guide says', () => {
|
await test('/timers offers the countdown and the playhead, shaped as the guide says', () => {
|
||||||
const snap = JSON.parse(collect(routes['GET /timers']).body);
|
const snap = JSON.parse(collect(routes['GET /timers']).body);
|
||||||
assert.strictEqual(snap.source, 'resi-cues');
|
assert.strictEqual(snap.source, 'resi-cues');
|
||||||
assert.strictEqual(snap.label, 'Resi');
|
assert.strictEqual(snap.label, 'Resi');
|
||||||
@@ -145,7 +146,7 @@ function collect(route, req) {
|
|||||||
assert.ok(Math.abs(next.remainingMs - 222000) < 2000, `remaining was ${next.remainingMs}`);
|
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
|
// the countdown runs from the cue before it, so a consumer can colour progress
|
||||||
assert.strictEqual(next.targetMs, 450000);
|
assert.strictEqual(next.targetMs, 450000);
|
||||||
assert.strictEqual(next.status.text, 'Campus Host');
|
assert.strictEqual(next.status.text, 'Next cue'); // the name is the label; not repeated
|
||||||
assert.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past');
|
assert.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past');
|
||||||
|
|
||||||
assert.strictEqual(playhead.id, 'playhead');
|
assert.strictEqual(playhead.id, 'playhead');
|
||||||
@@ -154,7 +155,7 @@ function collect(route, req) {
|
|||||||
assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000);
|
assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a cue fired at the host moves the countdown on the next poll', async () => {
|
await 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
|
world.position = '00:12:40.000'; // jumped past Campus Host
|
||||||
tickFn();
|
tickFn();
|
||||||
await settle();
|
await settle();
|
||||||
@@ -162,10 +163,10 @@ function collect(route, req) {
|
|||||||
assert.strictEqual(state.next.name, 'Message');
|
assert.strictEqual(state.next.name, 'Message');
|
||||||
assert.strictEqual(state.prev.name, 'Campus Host');
|
assert.strictEqual(state.prev.name, 'Campus Host');
|
||||||
const snap = JSON.parse(collect(routes['GET /timers']).body);
|
const snap = JSON.parse(collect(routes['GET /timers']).body);
|
||||||
assert.strictEqual(snap.timers[0].label, 'Message');
|
assert.strictEqual(snap.timers[0].label, 'Message'); // the card's name follows the cue
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a decoder with no event reports nothing playing rather than stale cues', async () => {
|
await test('a decoder with no event reports nothing playing rather than stale cues', async () => {
|
||||||
world.eventId = '';
|
world.eventId = '';
|
||||||
tickFn();
|
tickFn();
|
||||||
await settle();
|
await settle();
|
||||||
@@ -174,13 +175,13 @@ function collect(route, req) {
|
|||||||
assert.strictEqual(handle.state.event, '');
|
assert.strictEqual(handle.state.event, '');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('health speaks about the upstream, not about itself', () => {
|
await test('health speaks about the upstream, not about itself', () => {
|
||||||
const h = handle.health();
|
const h = handle.health();
|
||||||
assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status);
|
assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status);
|
||||||
assert.ok(h.message.length > 0, 'health should say something useful');
|
assert.ok(h.message.length > 0, 'health should say something useful');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an unconfigured module idles instead of hammering Resi', () => {
|
await test('an unconfigured module idles instead of hammering Resi', () => {
|
||||||
const before = world.logins;
|
const before = world.logins;
|
||||||
const idle = server.init({ config: {}, log: () => {} });
|
const idle = server.init({ config: {}, log: () => {} });
|
||||||
assert.strictEqual(world.logins, before, 'must not sign in with no credentials');
|
assert.strictEqual(world.logins, before, 'must not sign in with no credentials');
|
||||||
@@ -189,12 +190,90 @@ function collect(route, req) {
|
|||||||
idle.stop();
|
idle.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stop() leaves nothing running', () => {
|
await test('stop() leaves nothing running', () => {
|
||||||
handle.stop();
|
handle.stop();
|
||||||
const res = collect(routes['GET /state']);
|
const res = collect(routes['GET /state']);
|
||||||
assert.strictEqual(JSON.parse(res.body).status, 'error');
|
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.setInterval = realSetInterval;
|
||||||
globalThis.clearInterval = realClearInterval;
|
globalThis.clearInterval = realClearInterval;
|
||||||
console.log(`\n${passed} tests passed`);
|
console.log(`\n${passed} tests passed`);
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.13.0",
|
"version": "1.12.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
|||||||
+4
-93
@@ -188,10 +188,6 @@ function menuItem(label, onClick, { sub = '', danger = false, disabled = false,
|
|||||||
/* ── module registry ────────────────────────────────────────────────── */
|
/* ── module registry ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
let registry = new Map(); // module id -> manifest from GET /api/modules
|
let registry = new Map(); // module id -> manifest from GET /api/modules
|
||||||
/* The installed modules that are not running — disabled, or needing a newer
|
|
||||||
shell — id -> { id, name, reason }, from the same GET. Read for one thing:
|
|
||||||
to say why a tile one of them left in the layout is not drawn. */
|
|
||||||
let inactiveModules = new Map();
|
|
||||||
|
|
||||||
/** fetch that gives up after `ms`. A request the browser has queued behind
|
/** fetch that gives up after `ms`. A request the browser has queued behind
|
||||||
held connections would otherwise pend forever, and so would anything
|
held connections would otherwise pend forever, and so would anything
|
||||||
@@ -212,7 +208,6 @@ async function loadRegistry() {
|
|||||||
if (!res.ok) throw new Error('modules ' + res.status);
|
if (!res.ok) throw new Error('modules ' + res.status);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
registry = new Map((body.modules || []).map((m) => [m.id, m]));
|
registry = new Map((body.modules || []).map((m) => [m.id, m]));
|
||||||
inactiveModules = new Map((body.inactive || []).map((m) => [m.id, m]));
|
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -492,9 +487,7 @@ const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true
|
|||||||
registry offers them again: a Service timer waits for PCO Plan, a
|
registry offers them again: a Service timer waits for PCO Plan, a
|
||||||
transcript for its channel. Never the module's default in their place —
|
transcript for its channel. Never the module's default in their place —
|
||||||
a wall of "All timers" where five different timers were is worse than
|
a wall of "All timers" where five different timers were is worse than
|
||||||
five gaps that fill themselves in. For the one that is not coming back
|
five gaps that fill themselves in. */
|
||||||
(a module stopped offering that kind of tile) the Layouts menu lists them
|
|
||||||
under Not shown, each with a ✕ — the only way to let its cells go. */
|
|
||||||
|
|
||||||
function tileIsOffered(tile) {
|
function tileIsOffered(tile) {
|
||||||
const man = registry.get(tile.module);
|
const man = registry.get(tile.module);
|
||||||
@@ -528,7 +521,6 @@ function reconcileOfferedTiles() {
|
|||||||
if (changed) rebuildDividers();
|
if (changed) rebuildDividers();
|
||||||
scheduleOfferedRecheck();
|
scheduleOfferedRecheck();
|
||||||
refreshEmptyState(); // the registry may have gone from nothing to something
|
refreshEmptyState(); // the registry may have gone from nothing to something
|
||||||
repaintUnshown(); // a Not shown row goes the moment its tile is drawn again
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A module's entry list can change without anyone saving admin config: PCO
|
/* A module's entry list can change without anyone saving admin config: PCO
|
||||||
@@ -548,90 +540,10 @@ function scheduleOfferedRecheck() {
|
|||||||
}, OFFERED_RECHECK_MS);
|
}, OFFERED_RECHECK_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The tiles a layout holds that the board is not showing, each with what
|
|
||||||
to call it and, in a few words, why it is not drawn. */
|
|
||||||
function unshownTiles() {
|
|
||||||
return tiles.filter((t) => !tileEls.has(t.id)).map((tile) => {
|
|
||||||
const man = registry.get(tile.module);
|
|
||||||
if (man) {
|
|
||||||
const title = tile.title || (tile.variant ? `${man.name} · ${tile.variant}` : man.name);
|
|
||||||
return { tile, title, reason: 'not offered right now' };
|
|
||||||
}
|
|
||||||
const off = inactiveModules.get(tile.module);
|
|
||||||
if (off) {
|
|
||||||
const reason = off.reason === 'incompatible' ? 'module needs a newer ProdDash' : 'module disabled';
|
|
||||||
return { tile, title: tile.title || off.name || tile.module, reason };
|
|
||||||
}
|
|
||||||
return { tile, title: tile.title || tile.module, reason: 'module not installed' };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The tiles a layout holds that the board is not showing, by name. */
|
/** The tiles a layout holds that the board is not showing, by name. */
|
||||||
function unshownTileNames() {
|
function unshownTileNames() {
|
||||||
return unshownTiles().map((u) => u.title);
|
return tiles.filter((t) => !tileEls.has(t.id))
|
||||||
}
|
.map((t) => t.title || registry.get(t.module)?.name || t.module);
|
||||||
|
|
||||||
/* Repaints the Layouts menu's Not shown section while the menu is open —
|
|
||||||
set by unshownSection(), called whenever tiles become offered or stop
|
|
||||||
being, so the list never lags the board behind it. */
|
|
||||||
let repaintUnshown = () => {};
|
|
||||||
|
|
||||||
/** The Layouts menu's Not shown section: one row per tile the layout holds
|
|
||||||
that the board is not drawing — its name, why in a word or two, and a ✕
|
|
||||||
that removes it — present only while there is one. A tile that waits for
|
|
||||||
its module (a Service timer before PCO Plan is up) is listed but can stay;
|
|
||||||
this exists for the one that will never be back, which would otherwise
|
|
||||||
hold its cells with no control anywhere to let them go. */
|
|
||||||
function unshownSection() {
|
|
||||||
const wrap = document.createElement('div');
|
|
||||||
wrap.className = 'menu-unshown';
|
|
||||||
const signature = (list) => list.map((u) => `${u.tile.id}|${u.reason}`).join('\n');
|
|
||||||
let painted = '';
|
|
||||||
const paint = () => {
|
|
||||||
wrap.innerHTML = '';
|
|
||||||
const list = unshownTiles();
|
|
||||||
painted = signature(list);
|
|
||||||
wrap.hidden = !list.length;
|
|
||||||
if (!list.length) return;
|
|
||||||
const divider = document.createElement('div');
|
|
||||||
divider.className = 'menu-divider';
|
|
||||||
const title = document.createElement('div');
|
|
||||||
title.className = 'menu-title';
|
|
||||||
title.textContent = 'Not shown';
|
|
||||||
wrap.append(divider, title);
|
|
||||||
for (const { tile, title: name, reason } of list) {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'menu-item menu-unshown-row';
|
|
||||||
const label = document.createElement('span');
|
|
||||||
label.textContent = name;
|
|
||||||
const sub = document.createElement('span');
|
|
||||||
sub.className = 'sub';
|
|
||||||
sub.textContent = reason;
|
|
||||||
label.appendChild(sub);
|
|
||||||
const remove = document.createElement('button');
|
|
||||||
remove.type = 'button';
|
|
||||||
remove.className = 'menu-x';
|
|
||||||
remove.setAttribute('data-tip', 'Remove');
|
|
||||||
remove.setAttribute('aria-label', `Remove ${name}`);
|
|
||||||
remove.innerHTML = CLOSE_SVG;
|
|
||||||
remove.addEventListener('click', (e) => {
|
|
||||||
// The row goes with the tile, so the click must not reach the
|
|
||||||
// document as one from outside the menu — the menu stays open
|
|
||||||
// to show the next row (or none), as the stepper and Alerts do.
|
|
||||||
e.stopPropagation();
|
|
||||||
removeTile(tile.id); // saves the layout and frees the cells, as a drawn tile's ✕ does
|
|
||||||
paint();
|
|
||||||
});
|
|
||||||
row.append(label, remove);
|
|
||||||
wrap.appendChild(row);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
paint();
|
|
||||||
// Only when something changed: the registry is re-read every few seconds
|
|
||||||
// while a tile is folded away, and rows rebuilt under the pointer for
|
|
||||||
// nothing would lose the tooltip (and the click) the operator was on.
|
|
||||||
repaintUnshown = () => { if (wrap.isConnected && signature(unshownTiles()) !== painted) paint(); };
|
|
||||||
return wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTile(tile) {
|
function buildTile(tile) {
|
||||||
@@ -2341,7 +2253,6 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
|||||||
// how one is arranged.
|
// how one is arranged.
|
||||||
menu.appendChild(gridStepper());
|
menu.appendChild(gridStepper());
|
||||||
menu.appendChild(alertsFold());
|
menu.appendChild(alertsFold());
|
||||||
menu.appendChild(unshownSection()); // only while a tile the layout holds is not drawn
|
|
||||||
const stepperDivider = document.createElement('div');
|
const stepperDivider = document.createElement('div');
|
||||||
stepperDivider.className = 'menu-divider';
|
stepperDivider.className = 'menu-divider';
|
||||||
menu.appendChild(stepperDivider);
|
menu.appendChild(stepperDivider);
|
||||||
@@ -2433,7 +2344,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
|||||||
const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`;
|
const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`;
|
||||||
const gridNote = COLS !== gridBefore ? ` on a ${COLS}-column grid` : '';
|
const gridNote = COLS !== gridBefore ? ` on a ${COLS}-column grid` : '';
|
||||||
const note = unshown.length
|
const note = unshown.length
|
||||||
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now; the Layouts menu can remove ${unshown.length === 1 ? 'it' : 'them'}`
|
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now`
|
||||||
: '';
|
: '';
|
||||||
toast(`Loaded “${l.name}”: ${count}${gridNote}${note}`, false, {
|
toast(`Loaded “${l.name}”: ${count}${gridNote}${note}`, false, {
|
||||||
ms: unshown.length ? 10000 : 7000,
|
ms: unshown.length ? 10000 : 7000,
|
||||||
|
|||||||
@@ -368,33 +368,6 @@ body[data-alert="critical"] .tile.is-tinted {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* the Layouts menu's Not shown rows: a tile the layout holds but the board
|
|
||||||
is not drawing, why in a word or two, and the ✕ that removes it */
|
|
||||||
.menu-unshown[hidden] { display: none; }
|
|
||||||
.menu-unshown-row { cursor: default; }
|
|
||||||
.menu-unshown-row:hover { background: none; }
|
|
||||||
.menu-unshown-row > span:first-child {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.menu-x {
|
|
||||||
flex: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
border-radius: 5px;
|
|
||||||
color: var(--muted);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.menu-x:hover { background: var(--border); color: var(--danger); }
|
|
||||||
.menu-x svg { width: 12px; height: 12px; display: block; }
|
|
||||||
|
|
||||||
/* ── grid ──────────────────────────────────────────────────────────── */
|
/* ── grid ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
#gridwrap {
|
#gridwrap {
|
||||||
|
|||||||
@@ -2336,17 +2336,8 @@ function handleRequest(req, res) {
|
|||||||
|
|
||||||
/* — shell API — */
|
/* — shell API — */
|
||||||
if (urlPath === '/api/modules' && req.method === 'GET') {
|
if (urlPath === '/api/modules' && req.method === 'GET') {
|
||||||
// Beside what dashboards may load: the installed modules that are not
|
|
||||||
// running, by id and name only, so a tile one of them left in a layout
|
|
||||||
// can say why it is not drawn ("module disabled") instead of guessing at
|
|
||||||
// "not installed". Nothing of their config travels with it.
|
|
||||||
const inactive = [...manifests.keys()].filter((id) => !isActive(id)).map((id) => ({
|
|
||||||
id,
|
|
||||||
name: String(manifests.get(id).name || id),
|
|
||||||
reason: isEnabled(id) ? 'incompatible' : 'disabled',
|
|
||||||
}));
|
|
||||||
Promise.all([...manifests.keys()].filter(isActive).map(clientManifest))
|
Promise.all([...manifests.keys()].filter(isActive).map(clientManifest))
|
||||||
.then((list) => sendJson(res, 200, { modules: list, inactive }))
|
.then((list) => sendJson(res, 200, { modules: list }))
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('[proddash] /api/modules failed:', err);
|
console.error('[proddash] /api/modules failed:', err);
|
||||||
if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' });
|
if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' });
|
||||||
|
|||||||
Reference in New Issue
Block a user