Show an unreachable origin in the web UI (step 19)

A watcher that cannot fetch leaves the server perfectly reachable and
every branch row stale — a calm list that implies nothing changed. The
state was already recorded and served at /api/watcher; only nothing
rendered it.

The banner lives in the shared nav fragment, so every view inherits it,
and is fed from the view's own polling tick without being chained to it.
It stays separate from live-indicator: that one reports whether the
browser reaches the server, this one whether the server reaches origin.

Also caps the log volume the outage exposed: a lasting fetch failure is
logged when its message changes, not on every cycle, and the recovery is
logged once. Same for an invalid atTimes slot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-30 11:11:45 +02:00
co-authored by Claude Opus 5
parent 18a41e9ced
commit 0193386642
7 changed files with 127 additions and 6 deletions
+6
View File
@@ -86,6 +86,12 @@ tbody tr:nth-child(even) { background: var(--row); }
tbody tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: color-mix(in srgb, var(--link) 8%, transparent); }
tbody.is-stale { opacity: 0.55; }
/* Server-side staleness: the watcher cannot reach origin. Distinct from the live
indicator, which reports whether this browser reaches the server. */
.watcher-banner { display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px; margin: -8px 0 18px; padding: 9px 12px; border-radius: 8px; background: var(--failed-bg); color: var(--failed-text); font-size: 13px; }
.watcher-banner strong { text-transform: uppercase; letter-spacing: 0.03em; font-size: 12px; }
.watcher-banner[hidden] { display: none; }
.branch { font-weight: 650; }
.duration-cell { white-space: nowrap; }
/* queue wait time of a pending build — italic to distinguish it from real build time */
+50
View File
@@ -208,6 +208,9 @@ function startPolling(refresh, intervalMs) {
refresh()
.then(() => setLiveIndicator(true, "last update " + formatTimestamp(new Date().toISOString())))
.catch((error) => setLiveIndicator(false, String(error)));
// separate request, deliberately not chained: whether the watcher is healthy must
// not depend on this view's refresh, nor delay it
refreshWatcherBanner();
};
const start = () => {
stop();
@@ -248,6 +251,49 @@ function elem(tag, className, text) {
return element;
}
/**
* What the watcher's health means for the page in front of the reader, or null while
* nothing is wrong. Three different failures, one message: what you see is not current.
*
* This is not the `live-indicator`, which says whether *this browser* reaches the server.
* A watcher that cannot fetch leaves the server perfectly reachable and every row stale,
* which is exactly the outage that went unnoticed for 57 minutes on 2026-08-30.
*/
function watcherBannerText(state) {
const since = state.lastPollAt ? " Last attempt " + formatTimestamp(state.lastPollAt) + "." : "";
if (state.running === false) {
return ["watcher stopped", "No branch is being polled; nothing below will change." + since];
}
if (state.lastFetchError) {
return ["origin unreachable", "The list below is not updating." + since + " " + state.lastFetchError];
}
if (state.lastPollError) {
return ["poll cycle failed", "The list below may be incomplete." + since + " " + state.lastPollError];
}
return null;
}
/** Never rejects: an unreachable server is the live indicator's business, not the banner's. */
async function refreshWatcherBanner() {
const banner = document.getElementById("watcher-banner");
if (!banner) {
return;
}
let state;
try {
state = await fetchJson("/api/watcher");
} catch (error) {
// leave the banner as it stands rather than claiming health we could not confirm
return;
}
const text = watcherBannerText(state);
banner.replaceChildren();
banner.hidden = text === null;
if (text) {
banner.append(elem("strong", null, text[0]), elem("span", null, text[1]));
}
}
function externalLink(href, text) {
const anchor = elem("a", null, text);
anchor.href = href;
@@ -665,5 +711,9 @@ initBuildsTable();
initCurrentBuilds();
initSystemTable();
initReloadButton();
// pages with a poller update the banner from their own tick; the static ones ask once
if (!refreshNow) {
refreshWatcherBanner();
}
setInterval(tickRunningDurations, 1000);
tickRunningDurations();