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
@@ -12,6 +12,7 @@ import java.nio.file.StandardCopyOption
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeParseException
import java.util.concurrent.ConcurrentHashMap
/**
* One recorded scheduled-build trigger: the result pool [branch] (a branch, or
@@ -29,6 +30,9 @@ data class AutoBuildTrigger(
object AutoBuildSlots {
private val log = LoggerFactory.getLogger(AutoBuildSlots::class.java)
/** Slots already reported as invalid; the poll loop would otherwise warn about each one forever. */
private val warnedInvalidSlots = ConcurrentHashMap.newKeySet<String>()
/**
* The latest valid slot at or before [now], or null when no slot is due yet today.
* The returned slot is always a concrete `HH:MM` — an hourly pattern is expanded
@@ -44,7 +48,9 @@ object AutoBuildSlots {
try {
LocalTime.parse(slot) to slot
} catch (_: DateTimeParseException) {
log.warn("skipping invalid scheduled-build time slot '{}': expected HH:MM or ??:MM", slot)
if (warnedInvalidSlots.add(slot)) {
log.warn("skipping invalid scheduled-build time slot '{}': expected HH:MM or ??:MM", slot)
}
null
}
}.filter { (parsed, _) -> !parsed.isAfter(now) }
@@ -54,6 +54,15 @@ class Watcher(
@Volatile
private var warnedDeprecatedAutoBuild = false
/**
* The fetch failure last written to the log, so a lasting outage does not repeat the
* same warning on every poll — one wrong token produced 297 identical lines before
* this. Null while the last fetch succeeded, which is also what makes the recovery
* loggable.
*/
@Volatile
private var loggedFetchError: String? = null
/** Build definitions per branch, cached by the branch's head commit — see [definitionsFor]. */
private val branchDefinitions = ConcurrentHashMap<String, CachedDefinitions>()
@@ -125,8 +134,8 @@ class Watcher(
}
/**
* One poll cycle, never blocking on a build: fetch origin (on failure: log, expose
* in [state], retry next cycle), enqueue due branches — changed local branches
* One poll cycle, never blocking on a build: fetch origin (on failure: log once per
* message, expose in [state], retry next cycle), enqueue due branches — changed local branches
* first, then recent new origin branches, then due auto-build slots — then
* fast-forward the local branch refs, and finally prune results, artifacts, and
* worktrees of branches gone from origin.
@@ -135,9 +144,17 @@ class Watcher(
val startedAt = clock.instant()
try {
gitService.fetchOrigin(workingDir)
if (loggedFetchError != null) {
log.info("fetching origin succeeded again")
loggedFetchError = null
}
} catch (e: Exception) {
log.warn("fetching origin failed; retrying next cycle: {}", e.message)
state = state.copy(lastPollAt = startedAt, lastFetchError = e.message ?: e.javaClass.simpleName)
val failure = e.message ?: e.javaClass.simpleName
if (loggedFetchError != failure) {
log.warn("fetching origin failed; retrying every cycle until it succeeds: {}", failure)
loggedFetchError = failure
}
state = state.copy(lastPollAt = startedAt, lastFetchError = failure)
return
}
val config = configLoader.load(workingDir)