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
@@ -1,5 +1,8 @@
package de.hoennig.gittally.watcher
import ch.qos.logback.classic.Logger
import ch.qos.logback.classic.spi.ILoggingEvent
import ch.qos.logback.core.read.ListAppender
import de.hoennig.gittally.build.ArtifactKeys
import de.hoennig.gittally.build.ArtifactStore
import de.hoennig.gittally.build.BuildExecutor
@@ -24,6 +27,7 @@ import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
@@ -32,6 +36,7 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import io.mockk.verifyOrder
import org.slf4j.LoggerFactory
import java.nio.file.Files
import java.nio.file.Path
import java.time.Clock
@@ -172,6 +177,28 @@ class WatcherTest : FunSpec() {
.shouldBeNull()
}
test("a lasting fetch failure is logged once, and so is the recovery") {
val harness = Harness()
val logged = captureWatcherLog()
every { harness.gitService.fetchOrigin(any()) } throws RuntimeException("origin unreachable")
repeat(5) { harness.watcher.poll(harness.workingDir) }
// one wrong token used to write a warning every ten seconds, 297 of them in an hour
logged().filter { it.contains("fetching origin failed") } shouldHaveSize 1
every { harness.gitService.fetchOrigin(any()) } returns Unit
repeat(3) { harness.watcher.poll(harness.workingDir) }
logged().filter { it.contains("fetching origin succeeded again") } shouldHaveSize 1
// a different failure is a different message and is worth saying again
every { harness.gitService.fetchOrigin(any()) } throws RuntimeException("host is down")
harness.watcher.poll(harness.workingDir)
logged().filter { it.contains("fetching origin failed") } shouldHaveSize 2
}
test("poll enqueues changed local branches before recent new origin branches") {
val harness = Harness()
every { harness.gitService.originBranches(any()) } returns listOf("main", "feature/new")
@@ -745,3 +772,12 @@ class WatcherTest : FunSpec() {
}
}
}
/** Collects this spec's [Watcher] log messages; the returned lambda reads them at any point. */
private fun captureWatcherLog(): () -> List<String> {
val logger = LoggerFactory.getLogger(Watcher::class.java) as Logger
val appender = ListAppender<ILoggingEvent>()
appender.start()
logger.addAppender(appender)
return { appender.list.map { it.formattedMessage } }
}