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
@@ -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 —
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user