Overflow Tap: the tile says what Tap is on; the corner dot says how it went

The "TAP IS ON · since …" line (and its SWITCHING / NOT SWITCHED forms, and
the red error line under the destination) are gone from the tile. A tile
shows the keyword Tap is on, its destination, the groups moved, the slide
chip and the trail; status is the corner dot's job, as everywhere else in
ProdDash.

The dot now follows the retries honestly: a switch Overflow refuses is
retried three times, ten seconds apart, with the dot amber ("retrying (n of
4 attempts failed)") while the module is still fixing it itself; only when
the attempts are spent does the dot go red with the reason on hover, and the
alert fire if it is switched on. The budget counts refusals in a row across
keywords, not per keyword — a service keeps bringing new keywords, and each
one restarting a fresh budget meant a dead Overflow never turned the dot
red. The alert is raised once per outage, and a success clears the streak.
Verified against the mock with --fail-switch: amber through three refusals,
red on the fourth with one alert, later refusals stay red without
re-alerting, and green again on the first success once the mock is back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-10-25 13:57:45 -04:00
co-authored by Claude Fable 5.1
parent 227e3bb846
commit 6fffefc6c0
4 changed files with 61 additions and 67 deletions
+7 -5
View File
@@ -524,11 +524,13 @@ below. Nothing is installed without someone clicking.
A slide with no keyword changes nothing. Keywords match as the helper 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 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 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 one keyword, big: what Tap is on, its destination and the groups moved, a
groups moved, a chip saying whether the slide on screen carries a keyword, chip saying whether the slide on screen carries a keyword, and the last
and the last four switches with their times; a failed switch turns the four switches with their times. How a switch went is the corner dot's
tile red with the reason, is retried, and — if switched on in admin — job: a switch Overflow refuses is retried three times with the dot left
flashes an alert channel. Admin config: the ProPresenter endpoint and an 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 **Overflow Dashboard login** (email and password; Google sign-in has no
password, so set one with "Forgot Your Password?"). The keywords, their password, so set one with "Forgot Your Password?"). The keywords, their
destinations and their Tap groups stay where Overflow keeps them — destinations and their Tap groups stay where Overflow keeps them —
+15 -26
View File
@@ -1,10 +1,12 @@
/* Overflow Tap — the tile. /* Overflow Tap — the tile.
* *
* One fact matters across a booth: which keyword Tap is on right now, and * One fact matters across a booth: which keyword Tap is on right now. That
* whether the switch actually landed. That is the big line. Under it, the * is the big line. Under it, the destination it maps to and the groups it
* destination it maps to and the groups it moved; a small line says whether * moved; a small line says whether the slide on screen carries a keyword at
* the slide on screen carries a keyword at all; and the last four switches * all; and the last four switches sit at the bottom with their times, so a
* sit at the bottom with their times, so a tech can see the service's trail. * 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 * Everything comes from the server's `state` stream. The tile never talks to
* ProPresenter or Overflow itself. * ProPresenter or Overflow itself.
@@ -74,34 +76,21 @@ export default function create({ root, moduleApi }) {
box.append(el('div', 'ot-offline', why)); 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'); const main = el('div', 'ot-main');
if (cur) { if (cur) {
// The label carries the state and, when there is one, the moment it main.dataset.tone = cur.pending ? 'muted' : (cur.ok ? 'ok' : 'alert');
// 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.append(el('div', 'ot-keyword', cur.keyword)); main.append(el('div', 'ot-keyword', cur.keyword));
if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination || '')); if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination || ''));
if (note) main.append(el('div', 'ot-error', note)); if (on('showGroups') && cur.groups && cur.groups.length) main.append(el('div', 'ot-groups', cur.groups.join(' · ')));
else if (on('showGroups') && cur.groups && cur.groups.length) main.append(el('div', 'ot-groups', cur.groups.join(' · ')));
} else { } else {
main.dataset.tone = 'muted'; 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-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); box.append(main);
+36 -6
View File
@@ -11,6 +11,11 @@
* tap a disc follows what is on screen. A slide with no keyword changes * tap a disc follows what is on screen. A slide with no keyword changes
* nothing; the last destination stays until the next keyworded slide. * 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 /state → the view-model every tile draws
* GET /stream → SSE, `state` events on every change (heartbeated) * GET /stream → SSE, `state` events on every change (heartbeated)
* POST /refresh → re-read the keyword mappings from the Dashboard now * POST /refresh → re-read the keyword mappings from the Dashboard now
@@ -124,6 +129,14 @@ module.exports = {
let retriesLeft = 0; let retriesLeft = 0;
let retryTimer = null; let retryTimer = null;
let alertRaised = false; 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. // Presentation layer: the slide /v1/status/slide reports.
let lastSlideKey = null; let lastSlideKey = null;
@@ -171,10 +184,15 @@ module.exports = {
if (!of.reachable) return ['error', of.error || 'Signing in to Overflow…']; 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 (!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.keywords.length) return ['error', 'No keyword mappings in the Overflow Dashboard (Integrations › ProPresenter)'];
if (state.current && !state.current.pending && !state.current.ok) { // A refused switch the module is still retrying on its own is amber —
return ['error', `Tap not switched to ${state.current.keyword} — ${state.current.error}`]; // 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 n = state.keywords.length;
const who = forAdmin && session && session.email ? `${session.email}, ` : ''; const who = forAdmin && session && session.email ? `${session.email}, ` : '';
return ['ok', `Following ${host}:${port} · Overflow: ${who}${n} keyword${n === 1 ? '' : 's'}`]; return ['ok', `Following ${host}:${port} · Overflow: ${who}${n} keyword${n === 1 ? '' : 's'}`];
@@ -381,6 +399,7 @@ module.exports = {
at: Date.now(), at: Date.now(),
ok: false, ok: false,
pending: true, pending: true,
retrying: false, // a retry is scheduled: the module is still trying to fix this itself
error: '', error: '',
attempts: 0, attempts: 0,
}; };
@@ -399,22 +418,33 @@ module.exports = {
} }
entry.ok = true; entry.ok = true;
entry.error = ''; entry.error = '';
entry.retrying = false;
entry.at = Date.now(); entry.at = Date.now();
consecutiveFailures = 0;
clearAlert(); clearAlert();
log(`Tap → ${entry.destination || '?'} (${target.keyword})${entry.groups.length ? ` on ${entry.groups.join(', ')}` : ''}`); log(`Tap → ${entry.destination || '?'} (${target.keyword})${entry.groups.length ? ` on ${entry.groups.join(', ')}` : ''}`);
} catch (err) { } catch (err) {
if (stopped) return; if (stopped) return;
entry.ok = false; entry.ok = false;
entry.error = describe(err); entry.error = describe(err);
log(`Tap not switched to ${target.keyword}: ${entry.error}`); consecutiveFailures += 1;
if (retriesLeft > 0) { if (retriesLeft > 0 && consecutiveFailures <= SWITCH_RETRIES) {
// Still the module's problem: try again shortly; the dot goes amber.
retriesLeft -= 1; retriesLeft -= 1;
entry.retrying = true;
log(`Tap not switched to ${target.keyword}: ${entry.error} — retrying in ${SWITCH_RETRY_MS / 1000} s`);
retryTimer = later(() => { retryTimer = later(() => {
retryTimer = null; retryTimer = null;
runSwitch(); runSwitch();
}, SWITCH_RETRY_MS); }, 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 { } finally {
entry.pending = false; entry.pending = false;
inFlight = false; inFlight = false;
+3 -30
View File
@@ -59,22 +59,9 @@
[data-module="overflow-tap"] .ot-main[data-tone="alert"] { --tone: var(--danger); } [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-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 { [data-module="overflow-tap"] .ot-keyword {
color: var(--tone); color: var(--tone);
font-size: min(clamp(1rem, 38cqh, 7rem), 18cqw); font-size: min(clamp(1rem, 44cqh, 7rem), 18cqw);
font-weight: 750; font-weight: 750;
line-height: 1.15; line-height: 1.15;
max-width: 100%; max-width: 100%;
@@ -114,18 +101,6 @@
white-space: nowrap; 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 ─────────────────────────────────────────────────── */ /* ── this slide ─────────────────────────────────────────────────── */
[data-module="overflow-tap"] .ot-slide { [data-module="overflow-tap"] .ot-slide {
@@ -192,12 +167,10 @@
[data-module="overflow-tap"] .ot-history { display: none; } [data-module="overflow-tap"] .ot-history { display: none; }
} }
@container (max-height: 70px) { @container (max-height: 70px) {
[data-module="overflow-tap"] .ot-groups, [data-module="overflow-tap"] .ot-groups { display: none; }
[data-module="overflow-tap"] .ot-error { display: none; }
} }
@container (max-height: 46px) { @container (max-height: 46px) {
[data-module="overflow-tap"] .ot-dest, [data-module="overflow-tap"] .ot-dest { display: none; }
[data-module="overflow-tap"] .ot-label { display: none; }
} }
[data-module="overflow-tap"] .ot-empty { [data-module="overflow-tap"] .ot-empty {