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
+15 -26
View File
@@ -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);
+36 -6
View File
@@ -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;
+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="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 {