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:
co-authored by
Claude Fable 5.1
parent
227e3bb846
commit
6fffefc6c0
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user