diff --git a/.claude/skills/architecture/SKILL.md b/.claude/skills/architecture/SKILL.md index f698443..0b9978f 100644 --- a/.claude/skills/architecture/SKILL.md +++ b/.claude/skills/architecture/SKILL.md @@ -36,6 +36,7 @@ The web application type is set to `none` in `application.yml`, so plain CLI run ## Web UI The UI is server-rendered Thymeleaf (`UiController`, templates under `src/main/resources/templates/`) plus one hand-written JavaScript file (`static/gittally.js`) — no SPA framework, no frontend build pipeline. Pages render the full state server-side; the script then polls the JSON API and re-renders table bodies from data. Every fetch has a timeout and failures flip an explicit error badge — never re-fetch and diff whole HTML pages, and never leave a spinner without an error path (the legacy defect). Polling pauses while the tab is hidden. `UiFormats`/`gittally.js` must produce the same display formats (timestamps, durations). +Two independent staleness signals, never merged: the `live-indicator` badge says whether *this browser* reaches the server, and the `watcher-banner` (fed from `/api/watcher`, in the shared `nav` fragment) says whether the *server* reaches origin — a watcher that cannot fetch leaves the server perfectly reachable and every row stale. ## Configuration System diff --git a/src/main/kotlin/de/hoennig/gittally/watcher/AutoBuildState.kt b/src/main/kotlin/de/hoennig/gittally/watcher/AutoBuildState.kt index 5d5f734..68e0507 100644 --- a/src/main/kotlin/de/hoennig/gittally/watcher/AutoBuildState.kt +++ b/src/main/kotlin/de/hoennig/gittally/watcher/AutoBuildState.kt @@ -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() + /** * 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) } diff --git a/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt b/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt index aca65f1..f5d07e4 100644 --- a/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt +++ b/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt @@ -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() @@ -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) diff --git a/src/main/resources/static/gittally.css b/src/main/resources/static/gittally.css index d727455..97d11af 100644 --- a/src/main/resources/static/gittally.css +++ b/src/main/resources/static/gittally.css @@ -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 */ diff --git a/src/main/resources/static/gittally.js b/src/main/resources/static/gittally.js index 58aa411..b2941b5 100644 --- a/src/main/resources/static/gittally.js +++ b/src/main/resources/static/gittally.js @@ -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(); diff --git a/src/main/resources/templates/fragments.html b/src/main/resources/templates/fragments.html index 0a8e195..bc283e5 100644 --- a/src/main/resources/templates/fragments.html +++ b/src/main/resources/templates/fragments.html @@ -16,7 +16,8 @@ owner/repo -
+ +
+ + +