diff --git a/README.md b/README.md index af46e81..c65f2dd 100644 --- a/README.md +++ b/README.md @@ -524,11 +524,13 @@ below. Nothing is installed without someone clicking. A slide with no keyword changes nothing. Keywords match as the helper matches them: as a whole word of the notes, any case, punctuation around it ignored (`give` fires on "Give!" and not on "forgiveness"). The tile is - one keyword, big: what Tap is on (and since when), its destination and the - groups moved, a chip saying whether the slide on screen carries a keyword, - and the last four switches with their times; a failed switch turns the - tile red with the reason, is retried, and — if switched on in admin — - flashes an alert channel. Admin config: the ProPresenter endpoint and an + one keyword, big: what Tap is on, its destination and the groups moved, a + chip saying whether the slide on screen carries a keyword, and the last + four switches with their times. How a switch went is the corner dot's + job: a switch Overflow refuses is retried three times with the dot left + alone, and only when those are spent does the dot go red (hover it for + the reason) and, if switched on in admin, an alert channel flash. Admin + config: the ProPresenter endpoint and an **Overflow Dashboard login** (email and password; Google sign-in has no password, so set one with "Forgot Your Password?"). The keywords, their destinations and their Tap groups stay where Overflow keeps them — diff --git a/modules/overflow-tap/client.js b/modules/overflow-tap/client.js index dee1221..2532d48 100644 --- a/modules/overflow-tap/client.js +++ b/modules/overflow-tap/client.js @@ -1,10 +1,12 @@ /* Overflow Tap — the tile. * - * One fact matters across a booth: which keyword Tap is on right now, and - * whether the switch actually landed. That is the big line. Under it, the - * destination it maps to and the groups it moved; a small line says whether - * the slide on screen carries a keyword at all; and the last four switches - * sit at the bottom with their times, so a tech can see the service's trail. + * One fact matters across a booth: which keyword Tap is on right now. That + * is the big line. Under it, the destination it maps to and the groups it + * moved; a small line says whether the slide on screen carries a keyword at + * all; and the last four switches sit at the bottom with their times, so a + * tech can see the service's trail. Whether the last switch landed is the + * corner dot's to say — red when Overflow refused it and the module's own + * retries did not fix that — never a line of text in the tile. * * Everything comes from the server's `state` stream. The tile never talks to * ProPresenter or Overflow itself. @@ -74,34 +76,21 @@ export default function create({ root, moduleApi }) { box.append(el('div', 'ot-offline', why)); } + // The keyword Tap is on, its destination and the groups moved. No status + // words here: how the switch went is the corner dot's job (its hover + // carries the reason), and the trail's ✓ / ✕ keeps the record. The + // keyword's colour follows along — dimmed while a switch is in flight, + // red once Overflow has refused it for good. const main = el('div', 'ot-main'); if (cur) { - // The label carries the state and, when there is one, the moment it - // became true; the small lines under the destination are the groups - // moved — or, when the switch failed, what went wrong, in their place - // (a tile has room for one or the other). - let label = `TAP IS ON · since ${clock(cur.at)}`; - let tone = 'ok'; - let note = ''; - if (cur.pending) { - label = cur.attempts > 1 ? `SWITCHING · attempt ${cur.attempts}` : 'SWITCHING'; - tone = 'muted'; - } else if (!cur.ok) { - label = `NOT SWITCHED · ${clock(cur.at)}`; - tone = 'alert'; - note = cur.error || ''; - } - main.dataset.tone = tone; - main.append(el('div', 'ot-label', label)); + main.dataset.tone = cur.pending ? 'muted' : (cur.ok ? 'ok' : 'alert'); main.append(el('div', 'ot-keyword', cur.keyword)); if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination || '')); - if (note) main.append(el('div', 'ot-error', note)); - else if (on('showGroups') && cur.groups && cur.groups.length) main.append(el('div', 'ot-groups', cur.groups.join(' · '))); + if (on('showGroups') && cur.groups && cur.groups.length) main.append(el('div', 'ot-groups', cur.groups.join(' · '))); } else { main.dataset.tone = 'muted'; - main.append(el('div', 'ot-label', 'NO KEYWORD YET')); main.append(el('div', 'ot-keyword is-none', '—')); - main.append(el('div', 'ot-dest', 'Tap stays where Overflow left it')); + main.append(el('div', 'ot-dest', 'No keyword sent yet')); } box.append(main); diff --git a/modules/overflow-tap/server.js b/modules/overflow-tap/server.js index 3036a76..c47544d 100644 --- a/modules/overflow-tap/server.js +++ b/modules/overflow-tap/server.js @@ -11,6 +11,11 @@ * tap a disc follows what is on screen. A slide with no keyword changes * nothing; the last destination stays until the next keyworded slide. * + * A switch Overflow refuses is retried three times, ten seconds apart, with + * the tile's dot left alone meanwhile; only when those are spent does the + * dot go red (and the alert fire, if switched on) — the booth hears about a + * problem the module could not fix itself, not about every hiccup. + * * GET /state → the view-model every tile draws * GET /stream → SSE, `state` events on every change (heartbeated) * POST /refresh → re-read the keyword mappings from the Dashboard now @@ -124,6 +129,14 @@ module.exports = { let retriesLeft = 0; let retryTimer = null; let alertRaised = false; + /** + * Refusals in a row, across keywords. The retry budget is per keyword, but + * a service keeps bringing new keywords: if Overflow refuses every one of + * them, each would start a fresh budget and the dot would never go red. + * So the budget is also spent by refusals as a whole, and a success + * clears it. + */ + let consecutiveFailures = 0; // Presentation layer: the slide /v1/status/slide reports. let lastSlideKey = null; @@ -171,10 +184,15 @@ module.exports = { if (!of.reachable) return ['error', of.error || 'Signing in to Overflow…']; if (!pp.reachable) return ['error', pp.error ? `ProPresenter unreachable: ${pp.error}` : 'Reaching ProPresenter…']; if (!state.keywords.length) return ['error', 'No keyword mappings in the Overflow Dashboard (Integrations › ProPresenter)']; - if (state.current && !state.current.pending && !state.current.ok) { - return ['error', `Tap not switched to ${state.current.keyword} — ${state.current.error}`]; + // A refused switch the module is still retrying on its own is amber — + // being worked on; one it has given up on is red — the booth's problem. + const cur = state.current; + if (cur && !cur.ok && !cur.pending && !cur.retrying) { + return ['error', `Tap not switched to ${cur.keyword} — ${cur.error}`]; + } + if (cur && !cur.ok && (cur.retrying || consecutiveFailures > 0)) { + return ['connecting', `Tap not switched to ${cur.keyword} yet — retrying (${consecutiveFailures} of ${SWITCH_RETRIES + 1} attempts failed): ${cur.error || 'in flight'}`]; } - if (state.current && state.current.pending) return ['connecting', `Switching Tap to ${state.current.keyword}…`]; const n = state.keywords.length; const who = forAdmin && session && session.email ? `${session.email}, ` : ''; return ['ok', `Following ${host}:${port} · Overflow: ${who}${n} keyword${n === 1 ? '' : 's'}`]; @@ -381,6 +399,7 @@ module.exports = { at: Date.now(), ok: false, pending: true, + retrying: false, // a retry is scheduled: the module is still trying to fix this itself error: '', attempts: 0, }; @@ -399,22 +418,33 @@ module.exports = { } entry.ok = true; entry.error = ''; + entry.retrying = false; entry.at = Date.now(); + consecutiveFailures = 0; clearAlert(); log(`Tap → ${entry.destination || '?'} (${target.keyword})${entry.groups.length ? ` on ${entry.groups.join(', ')}` : ''}`); } catch (err) { if (stopped) return; entry.ok = false; entry.error = describe(err); - log(`Tap not switched to ${target.keyword}: ${entry.error}`); - if (retriesLeft > 0) { + consecutiveFailures += 1; + if (retriesLeft > 0 && consecutiveFailures <= SWITCH_RETRIES) { + // Still the module's problem: try again shortly; the dot goes amber. retriesLeft -= 1; + entry.retrying = true; + log(`Tap not switched to ${target.keyword}: ${entry.error} — retrying in ${SWITCH_RETRY_MS / 1000} s`); retryTimer = later(() => { retryTimer = null; runSwitch(); }, SWITCH_RETRY_MS); + } else { + // Out of retries: now it is the booth's problem — red dot, and the + // alert if the operator asked for one (raised once per outage, not + // again for every further keyword Overflow refuses). + entry.retrying = false; + log(`Tap not switched to ${target.keyword}: ${entry.error} — giving up after ${consecutiveFailures} refusals`); + if (!alertRaised) raiseAlert(target.keyword); } - raiseAlert(target.keyword); } finally { entry.pending = false; inFlight = false; diff --git a/modules/overflow-tap/style.css b/modules/overflow-tap/style.css index 9a43d86..561c115 100644 --- a/modules/overflow-tap/style.css +++ b/modules/overflow-tap/style.css @@ -59,22 +59,9 @@ [data-module="overflow-tap"] .ot-main[data-tone="alert"] { --tone: var(--danger); } [data-module="overflow-tap"] .ot-main[data-tone="muted"] { --tone: var(--muted); } -[data-module="overflow-tap"] .ot-label { - color: var(--tone); - font-size: clamp(0.5rem, 9cqh, 0.95rem); - font-weight: 700; - letter-spacing: 0.14em; - line-height: 1.2; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-variant-numeric: tabular-nums; -} - [data-module="overflow-tap"] .ot-keyword { color: var(--tone); - font-size: min(clamp(1rem, 38cqh, 7rem), 18cqw); + font-size: min(clamp(1rem, 44cqh, 7rem), 18cqw); font-weight: 750; line-height: 1.15; max-width: 100%; @@ -114,18 +101,6 @@ white-space: nowrap; } -[data-module="overflow-tap"] .ot-error { - color: var(--danger); - font-size: clamp(0.5rem, 9cqh, 0.95rem); - line-height: 1.3; - max-width: 100%; - overflow: hidden; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} -[data-module="overflow-tap"] .ot-error.is-warn { color: var(--warn); } - /* ── this slide ─────────────────────────────────────────────────── */ [data-module="overflow-tap"] .ot-slide { @@ -192,12 +167,10 @@ [data-module="overflow-tap"] .ot-history { display: none; } } @container (max-height: 70px) { - [data-module="overflow-tap"] .ot-groups, - [data-module="overflow-tap"] .ot-error { display: none; } + [data-module="overflow-tap"] .ot-groups { display: none; } } @container (max-height: 46px) { - [data-module="overflow-tap"] .ot-dest, - [data-module="overflow-tap"] .ot-label { display: none; } + [data-module="overflow-tap"] .ot-dest { display: none; } } [data-module="overflow-tap"] .ot-empty {