feat(web): read admin lanes from the durable pass log, not a poller snapshot (#145)

The Lanes page now projects store.LatestLanePasses and the owner-window
outcomes (store.LanePassOutcomes) into per-Site rows instead of reading an
in-memory Poller snapshot, so a deploy answers the instant the store is up.
Browser configuration is a config fact and reachability is derived from
recent browser-Site passes inside latest.RefuseBackoff.

This atomically deletes the in-memory path in the same commit that makes the
page read the DB: latest/status.go (LaneState, Status, LaneStatus,
recordLaneState) and the web.LaneReporter seam plus fakeLanes are gone, and
latest.refuseBackoff is renamed latest.RefuseBackoff at every callsite.
ownerWindow (#142) is referenced, never declared (contract C2)
This commit is contained in:
2026-08-21 20:25:38 +07:00
parent 503fb49d0a
commit cb2b104e63
16 changed files with 757 additions and 517 deletions
+60 -52
View File
@@ -58,15 +58,11 @@ type Poller struct {
// refuseUntil gates a Site's Lane after it refused twice in one run: no
// Series of that Site is attempted again before this time (issue #100).
// browserDownAt is when a browser Lane last lost the sidecar; the other
// browser Lanes skip their passes for the next refuseBackoff, so a
// browser Lanes skip their passes for the next RefuseBackoff, so a
// restarting Chrome does not stamp one Series per pass per Lane (story 20).
mu sync.Mutex
refuseUntil map[string]time.Time
browserDownAt time.Time
// laneStates is the owner's page snapshot of each Lane's last pass
// (issue #102), keyed by Site. Guarded by mu; a Site appears only after
// its first pass, so a restart renders "no data yet" rather than zeroes.
laneStates map[string]LaneState
// coverWG tracks in-flight cover work. Covers heal in the background so a
// slow cover host cannot delay the next Series-page Poll; tests join it
// before asserting on cover fetches.
@@ -226,14 +222,16 @@ func (p *Poller) runOnce(ctx context.Context) {
// empty means the pass reached the loop. The values are wire strings — stored
// in poll_passes and read by the Lanes page — so they are stable, not prose.
const (
skipPaused = "paused" // the pause row was read at the top
skipRefusing = "refusing" // refusal backoff
skipSidecarDown = "sidecar-down" // a sibling browser Lane lost Chrome
skipNoFetcher = "no-fetcher" // browser Site, no browser configured, no fallback
skipDueQuery = "due-query" // the due query failed
skipAsleep = "asleep" // under both browser wake thresholds
skipEligibleCount = "eligible-count" // the eligible count failed
skipNothingEligible = "nothing-eligible" // nothing eligible; sleeps a full rest
// Exported so the web layer renders a skip's reason without retyping the
// wire string (issue #145); the values are storage and page-stable.
SkipPaused = "paused" // the pause row was read at the top
SkipRefusing = "refusing" // refusal backoff
SkipSidecarDown = "sidecar-down" // a sibling browser Lane lost Chrome
SkipNoFetcher = "no-fetcher" // browser Site, no browser configured, no fallback
SkipDueQuery = "due-query" // the due query failed
SkipAsleep = "asleep" // under both browser wake thresholds
SkipEligibleCount = "eligible-count" // the eligible count failed
SkipNothingEligible = "nothing-eligible" // nothing eligible; sleeps a full rest
)
// readOutcome classifies one Series read for the pass row's outcome counts
@@ -296,14 +294,12 @@ const lanePassRetention = 14 * 24 * time.Hour
// production Lane's rate limit; the deterministic test entry runs back to back.
func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.Duration {
now := p.Now()
// Snapshot this pass for the owner's page (issue #102). Recorded on every
// return path, with the figures filled in where the pass computes them.
st := LaneState{Site: name, LastRun: now, Browser: isBrowserSite(name)}
defer func() { p.recordLaneState(st) }()
// Durable pass log (issue #141): one row per exit, written from the same
// snapshot so the two recordings cannot disagree.
// Durable pass log (issue #141): one row per exit. The figures are filled
// in as the pass measures them; a pass that returns before measuring
// carries the previous pass's forward inside recordPass.
fig := passFigures{}
rec := passRecord{site: name, ranAt: now.UnixMilli()}
defer func() { p.recordPass(rec, st) }()
defer func() { p.recordPass(rec, fig) }()
// One Lane row read at the top of a pass, serving two gates (issue #139).
// Both stamps outlive our process, so the gates read the durable row
@@ -318,24 +314,24 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
if pausedUntil > now.UnixMilli() {
// Paused ahead of the refusal check: no Series is touched, so the
// queue stays intact for when the pause lifts (issue #141, #147).
rec.skip = skipPaused
rec.skip = SkipPaused
log.Printf("latest poll %s: paused until %s, skipping pass", name, time.UnixMilli(pausedUntil).Format(time.RFC3339))
return time.Duration(pausedUntil-now.UnixMilli()) * time.Millisecond
}
if refuseUntil > now.UnixMilli() {
// Cooling down after a refusal: do not attempt this Site at all.
rec.skip = skipRefusing
rec.skip = SkipRefusing
return time.Duration(refuseUntil-now.UnixMilli()) * time.Millisecond
}
if isBrowserSite(name) {
if downFor, down := p.browserDownFor(now); down && downFor < refuseBackoff {
if downFor, down := p.browserDownFor(now); down && downFor < RefuseBackoff {
// A sibling browser Lane lost the sidecar within the backoff
// window: skip this pass, so a restarting Chrome does not stamp
// this Site's Series one pass at a time. After refuseBackoff the
// this Site's Series one pass at a time. After RefuseBackoff the
// flag decays and the Lane probes again (issue #100, story 20).
rec.skip = skipSidecarDown
rec.skip = SkipSidecarDown
log.Printf("latest poll %s: browser lane skipping pass (sidecar down %s ago)", name, downFor)
return refuseBackoff - downFor
return RefuseBackoff - downFor
}
}
s := sites[name]
@@ -344,27 +340,27 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
// No fetcher at all right now (browser absent, no fallback): every
// Series stays unstamped and due, so a browser that appears after a
// restart finds its full queue waiting (issue #100).
rec.skip = skipNoFetcher
st.Gap = defaultGap
rec.skip = SkipNoFetcher
fig.Gap = defaultGap
return defaultGap
}
due, err := p.Store.DueForLatestCheck(name, now.Add(-s.Rest).UnixMilli(),
now.Add(-sightingCeilingRests*s.Rest).UnixMilli())
if err != nil {
rec.skip = skipDueQuery
rec.skip = SkipDueQuery
log.Printf("latest poll %s: due query: %v", name, err)
st.Gap = defaultGap
fig.Gap = defaultGap
return defaultGap
}
st.Due = len(due)
fig.Due = len(due)
if s.Browser != nil && f == p.BrowserFetch && !browserWakeDue(due, now, s.Rest) {
// Below both thresholds Chrome stays asleep (ADR-0005 on-demand
// browser): waking it for a single Poll would cost a challenge solve
// per request. The Lane still paces at the default gap, which is what
// the owner's page must show rather than a zero.
rec.skip = skipAsleep
st.Gap, st.Asleep = defaultGap, true
rec.skip = SkipAsleep
fig.Gap = defaultGap
return defaultGap
}
if s.Browser != nil {
@@ -379,20 +375,20 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
eligible, err := p.countEligible(name)
if err != nil {
rec.skip = skipEligibleCount
rec.skip = SkipEligibleCount
log.Printf("latest poll %s: eligible count: %v", name, err)
st.Gap = defaultGap
fig.Gap = defaultGap
return defaultGap
}
gap, clamped := effectiveGap(s, eligible)
st.Gap, st.Clamped = gap, clamped
fig.Gap, fig.Clamped = gap, clamped
if clamped {
log.Printf("latest poll %s: gap clamped to %s floor (eligible series=%d)", name, minGap, eligible)
}
if eligible == 0 {
// Nothing to poll for the foreseeable future; sleep a full rest instead
// of re-querying every gap.
rec.skip = skipNothingEligible
rec.skip = SkipNothingEligible
return s.Rest
}
@@ -403,7 +399,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
}
if refusals >= 2 {
// This Site refused twice in a row: the remaining Series are left
// unstamped and due, and the Lane waits refuseBackoff before
// unstamped and due, and the Lane waits RefuseBackoff before
// trying it again.
break
}
@@ -426,7 +422,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
// make it legible but is deliberately not invented here.
rec.counts.add(outcome)
p.setBrowserDown(now)
log.Printf("latest poll %s: browser unreachable, browser lanes skipping passes for %s", name, refuseBackoff)
log.Printf("latest poll %s: browser unreachable, browser lanes skipping passes for %s", name, RefuseBackoff)
return gap
}
if outcome == outcomeRefused {
@@ -435,27 +431,39 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
refusals = 0
}
rec.counts.add(outcome)
st.Checked++
fig.Checked++
}
if st.Checked > 0 {
log.Printf("latest poll %s: due=%d checked=%d", name, len(due), st.Checked)
if fig.Checked > 0 {
log.Printf("latest poll %s: due=%d checked=%d", name, len(due), fig.Checked)
}
if refusals >= 2 {
p.setRefusalBackoff(name, now.Add(refuseBackoff))
p.setRefusalBackoff(name, now.Add(RefuseBackoff))
// The refusal outlives the process: the durable stamp gates a restart,
// so a Site that just told us to back off is not re-probed. The
// in-memory twin is still written because the Lane status block reads
// it directly; the gate reads the durable stamp, so a restart does not
// forget the refusal.
if err := p.Store.SetLaneRefusal(name, now.Add(refuseBackoff).UnixMilli()); err != nil {
if err := p.Store.SetLaneRefusal(name, now.Add(RefuseBackoff).UnixMilli()); err != nil {
log.Printf("latest poll %s: persist refusal: %v", name, err)
}
log.Printf("latest poll %s: refused twice this run, waiting %s", name, refuseBackoff)
return refuseBackoff
log.Printf("latest poll %s: refused twice this run, waiting %s", name, RefuseBackoff)
return RefuseBackoff
}
return gap
}
// passFigures are the numbers one pass measured for its durable row (issue
// #141): due and checked as the pass saw them, the pace it chose, and whether
// the gap sat on the floor. A pass that returned before measuring keeps the
// previous pass's figures via carry-forward in recordPass; the in-memory
// snapshot those once mirrored into is gone — the page reads the durable row
// now (issue #145).
type passFigures struct {
Due, Checked int
Gap time.Duration
Clamped bool
}
// recordPass writes the durable row for one pass (issue #141). Called deferred
// from runLanePass so every return path records exactly one row. A pass that
// never computed its own figures — its gap is zero — carries the previous
@@ -463,15 +471,15 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
// did not measure; the skip column says why it declined, so the zeroes that
// remain (due-query, no-fetcher) read as explanations rather than
// measurements.
func (p *Poller) recordPass(rec passRecord, st LaneState) {
func (p *Poller) recordPass(rec passRecord, fig passFigures) {
row := store.LanePass{
Site: rec.site,
RanAt: rec.ranAt,
Skip: rec.skip,
Due: st.Due,
Checked: st.Checked,
GapMS: st.Gap.Milliseconds(),
Clamped: st.Clamped,
Due: fig.Due,
Checked: fig.Checked,
GapMS: fig.Gap.Milliseconds(),
Clamped: fig.Clamped,
Refused: rec.counts.refused,
Unreachable: rec.counts.unreachable,
NoChapter: rec.counts.noChapter,
@@ -522,7 +530,7 @@ func (p *Poller) setBrowserDown(now time.Time) {
// browserDownFor reports how long the sidecar has been down and that it is
// down at all — the zero time means never down, which must not read as a
// zero-duration loss. The window decays: once refuseBackoff passes without a
// zero-duration loss. The window decays: once RefuseBackoff passes without a
// fresh loss, Lanes probe again.
func (p *Poller) browserDownFor(now time.Time) (time.Duration, bool) {
p.mu.Lock()
+61 -110
View File
@@ -1308,7 +1308,7 @@ func TestRunOnceOrdersBySharednessThenAge(t *testing.T) {
}
// Two refusals in one pass stop the Lane: the remaining Series stay unstamped
// and due, and the Lane backs off for refuseBackoff before trying the Site
// and due, and the Lane backs off for RefuseBackoff before trying the Site
// again. One hostile Site burns only its own Lane's budget (issue #100).
func TestRunOnceSiteRefusalSkipsRestOfLaneAndBacksOff(t *testing.T) {
s, _ := newTestStore(t)
@@ -1391,11 +1391,9 @@ func TestBrowserLaneWakeThresholds(t *testing.T) {
if got := browser.callCount(); got != 0 {
t.Fatalf("browser fetches with 3 freshly-due series = %d, want 0 (Chrome stays asleep)", got)
}
// The owner's page reads this state off the snapshot, and Due-without-
// Checked has to be distinguishable there from a Lane that has stopped.
if lane := laneByName(t, p, "kagane"); !lane.Asleep || lane.Due != 3 || lane.Checked != 0 {
t.Fatalf("asleep kagane lane = %+v, want Asleep with 3 due and 0 checked", lane)
}
// The skipped pass still records its row, carrying the due count it never
// read; the Lanes page (issue #145) reads that row — see
// TestRunLanePassRecordsEveryExit/"asleep".
// 5 due crosses the count threshold.
for i := 3; i < 5; i++ {
seed(i)
@@ -1404,9 +1402,6 @@ func TestBrowserLaneWakeThresholds(t *testing.T) {
if got := browser.callCount(); got != 5 {
t.Fatalf("browser fetches with 5 due series = %d, want 5", got)
}
if lane := laneByName(t, p, "kagane"); lane.Asleep {
t.Fatalf("woken kagane lane still reports Asleep: %+v", lane)
}
// A single long-neglected series wakes the browser by age alone.
seedForCheck(t, s, "kagane:ancient", "https://kagane.to/series/ancient", 0)
p.runOnce(context.Background())
@@ -1415,19 +1410,6 @@ func TestBrowserLaneWakeThresholds(t *testing.T) {
}
}
// laneByName pulls one Lane out of the poller's snapshot, failing rather than
// returning a zero LaneState a caller would assert against by accident.
func laneByName(t *testing.T, p *Poller, site string) LaneState {
t.Helper()
for _, lane := range p.LaneStatus().Lanes {
if lane.Site == site {
return lane
}
}
t.Fatalf("no %q lane in the snapshot", site)
return LaneState{}
}
// When one browser Lane loses the sidecar, the round's remaining browser
// Lanes are skipped: every fetch would fail anyway, and their Series must not
// burn their stamps on a dead Chrome (issue #100).
@@ -1459,7 +1441,7 @@ func TestRunOnceUnreachableBrowserStopsBrowserLanes(t *testing.T) {
}
}
// The shared flag decays after refuseBackoff: the next round probes
// The shared flag decays after RefuseBackoff: the next round probes
// again. comix's Series is resting (stamped last round), so the probe
// falls to kagane — the only Lane with something due — and its fresh
// loss re-gates the Lanes behind it.
@@ -1605,19 +1587,21 @@ func TestRunOnceClampWarningNamesTheSite(t *testing.T) {
}
}
// The owner's admin page (issue #102) reads Lane state out of the poller.
// Before any pass the snapshot is empty — a restart must render "no data
// yet", not zeroes — and each pass records what it saw: the frozen clock,
// the due count and the pace, with refusal backoff derived at snapshot time.
func TestLaneStatus(t *testing.T) {
// The Lanes page (issue #145) reads the durable pass log, so the poller's
// only duty to it is that every return path writes its row; the snapshot it
// used to mirror into memory is gone. A refusing pass still records why it
// declined, and the latest row per Site is what the page renders — nothing
// else is left to assert against here, because the page's seam moved into the
// web layer (web_test.go, seeded-row tests).
func TestPassLogIsTheLanesPagesOnlyWindow(t *testing.T) {
s, _ := newTestStore(t)
now := time.UnixMilli(5_000_000)
p := newTestPoller(t, s, &fakeFetcher{body: asuraSeriesFixture, status: 200}, now)
if st := p.LaneStatus(); len(st.Lanes) != 0 {
t.Fatalf("lanes before any pass = %d, want 0 (nothing has run)", len(st.Lanes))
} else if st.BrowserConfigured || st.BrowserReachable {
t.Fatalf("browser before any pass = configured=%v reachable=%v, want false without a browser fetcher", st.BrowserConfigured, st.BrowserReachable)
// No pass has run: the log is empty, which the page renders as "none
// observed" rather than confident zeroes.
if _, ok, err := s.LatestLanePass("asura"); err != nil || ok {
t.Fatalf("LatestLanePass before any pass = ok %v err %v, want no row", ok, err)
}
seedForCheck(t, s, "asura:chronicles", "https://asurascans.com/series/chronicles", 0)
@@ -1630,66 +1614,33 @@ func TestLaneStatus(t *testing.T) {
}
p.runOnce(context.Background())
st := p.LaneStatus()
var asura, kagane LaneState
for _, lane := range st.Lanes {
switch lane.Site {
case "asura":
asura = lane
case "kagane":
kagane = lane
}
// Both lanes wrote their rows: asura the pass it read; kagane the
// mid-loop double-refusal exit, which records an empty skip by design
// with its two refused counts — the durable row is the whole record, and
// the page reads it rather than a snapshot.
asura := latestPassFor(t, s, "asura")
if asura.Due != 1 || asura.Checked != 1 || asura.GapMS == 0 {
t.Fatalf("asura row = %+v, want due 1 checked 1 with the Lane's pace", asura)
}
if st.Lanes[0].Site != "asura" {
t.Fatalf("first lane = %q, want asura (snapshot sorted by Site)", st.Lanes[0].Site)
}
if asura.Site == "" {
t.Fatalf("asura missing from snapshot: %+v", st.Lanes)
}
if !asura.LastRun.Equal(now) {
t.Fatalf("asura LastRun = %s, want the frozen clock %s", asura.LastRun, now)
}
if asura.Due != 1 {
t.Fatalf("asura Due = %d, want 1", asura.Due)
}
if asura.Gap == 0 {
t.Fatal("asura Gap = 0, want the Lane's pace")
}
if asura.Browser || asura.Refusing {
t.Fatalf("asura = %+v, want a TLS Lane that is not refusing", asura)
}
if !kagane.Browser || !kagane.Refusing {
t.Fatalf("kagane = %+v, want a browser Lane in refusal backoff", kagane)
}
if !st.BrowserConfigured || !st.BrowserReachable {
t.Fatalf("browser after round = configured=%v reachable=%v, want true/true (sidecar never lost)", st.BrowserConfigured, st.BrowserReachable)
}
if asura.Checked != 1 {
t.Fatalf("asura Checked = %d, want the one Series it read", asura.Checked)
kagane := latestPassFor(t, s, "kagane")
if kagane.Skip != "" || kagane.Refused != 2 || kagane.Due != 2 || kagane.GapMS == 0 {
t.Fatalf("kagane row = %+v, want an empty-skip double-refusal pass with 2 refused", kagane)
}
// A pass that declines to look (kagane is now in backoff) must not restate
// the figures it never gathered as zeroes: the last real pass's due count
// and pace stand until a pass replaces them.
// With the refusal now durable, the next pass declines ahead of the loop:
// it records the refusing skip and carries the previous pass's figures
// forward rather than restating zeroes it never gathered (the carry logic
// itself is driven in TestRunLanePassCarryForwardOnlyWhenGapZero).
before := kagane
if before.Due == 0 || before.Gap == 0 {
t.Fatalf("kagane after its refusing pass = %+v, want the figures that pass gathered", before)
}
p.Now = func() time.Time { return now.Add(time.Minute) }
p.runOnce(context.Background())
for _, lane := range p.LaneStatus().Lanes {
if lane.Site != "kagane" {
continue
}
if lane.Due != before.Due || lane.Gap != before.Gap {
t.Fatalf("kagane after a skipped pass = due %d gap %s, want the previous pass's %d / %s",
lane.Due, lane.Gap, before.Due, before.Gap)
}
again := latestPassFor(t, s, "kagane")
if again.Skip != SkipRefusing {
t.Fatalf("kagane refusing pass skip = %q, want %q", again.Skip, SkipRefusing)
}
// A lost sidecar reads as unreachable for the same window the Lanes skip.
p.setBrowserDown(now)
if st := p.LaneStatus(); !st.BrowserConfigured || st.BrowserReachable {
t.Fatalf("browser after loss = configured=%v reachable=%v, want true/false", st.BrowserConfigured, st.BrowserReachable)
if again.Due != before.Due || again.GapMS != before.GapMS || again.Checked != before.Checked {
t.Fatalf("kagane after a skipped pass = due %d gap %d, want the previous pass's %d / %d",
again.Due, again.GapMS, before.Due, before.GapMS)
}
}
@@ -1744,8 +1695,8 @@ func TestRunLanePassRecordsEveryExit(t *testing.T) {
if got := readLatestCheckedAt(t, s, "asura:x"); got != 0 {
t.Fatalf("stamp while paused = %d, want 0 (Series stay due and unstamped)", got)
}
if pass := latestPassFor(t, s, "asura"); pass.Skip != skipPaused {
t.Fatalf("skip = %q, want %q", pass.Skip, skipPaused)
if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipPaused {
t.Fatalf("skip = %q, want %q", pass.Skip, SkipPaused)
}
if got := countPassRows(t, dbURL, "asura"); got != 1 {
t.Fatalf("pass rows = %d, want exactly 1", got)
@@ -1767,8 +1718,8 @@ func TestRunLanePassRecordsEveryExit(t *testing.T) {
if browser.callCount() != 0 {
t.Fatalf("fetches while refusing = %d, want 0", browser.callCount())
}
if pass := latestPassFor(t, s, "kagane"); pass.Skip != skipRefusing {
t.Fatalf("skip = %q, want %q", pass.Skip, skipRefusing)
if pass := latestPassFor(t, s, "kagane"); pass.Skip != SkipRefusing {
t.Fatalf("skip = %q, want %q", pass.Skip, SkipRefusing)
}
if got := countPassRows(t, dbURL, "kagane"); got != 1 {
t.Fatalf("pass rows = %d, want exactly 1", got)
@@ -1782,14 +1733,14 @@ func TestRunLanePassRecordsEveryExit(t *testing.T) {
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
p.BrowserFetch = browser
p.setBrowserDown(now) // a sibling Lane lost Chrome within the backoff window
if pace := p.runLanePass(context.Background(), "kagane", false); pace != refuseBackoff {
t.Fatalf("sidecar-down pace = %s, want %s", pace, refuseBackoff)
if pace := p.runLanePass(context.Background(), "kagane", false); pace != RefuseBackoff {
t.Fatalf("sidecar-down pace = %s, want %s", pace, RefuseBackoff)
}
if browser.callCount() != 0 {
t.Fatalf("fetches with the sidecar down = %d, want 0", browser.callCount())
}
if pass := latestPassFor(t, s, "kagane"); pass.Skip != skipSidecarDown {
t.Fatalf("skip = %q, want %q", pass.Skip, skipSidecarDown)
if pass := latestPassFor(t, s, "kagane"); pass.Skip != SkipSidecarDown {
t.Fatalf("skip = %q, want %q", pass.Skip, SkipSidecarDown)
}
if got := countPassRows(t, dbURL, "kagane"); got != 1 {
t.Fatalf("pass rows = %d, want exactly 1", got)
@@ -1804,8 +1755,8 @@ func TestRunLanePassRecordsEveryExit(t *testing.T) {
t.Fatalf("no-fetcher pace = %s, want %s", pace, defaultGap)
}
pass := latestPassFor(t, s, "comix")
if pass.Skip != skipNoFetcher || pass.GapMS != defaultGap.Milliseconds() {
t.Fatalf("no-fetcher pass = %+v, want skip %q with its own gap %s", pass, skipNoFetcher, defaultGap)
if pass.Skip != SkipNoFetcher || pass.GapMS != defaultGap.Milliseconds() {
t.Fatalf("no-fetcher pass = %+v, want skip %q with its own gap %s", pass, SkipNoFetcher, defaultGap)
}
if got := countPassRows(t, dbURL, "comix"); got != 1 {
t.Fatalf("pass rows = %d, want exactly 1", got)
@@ -1829,8 +1780,8 @@ func TestRunLanePassRecordsEveryExit(t *testing.T) {
if pace := p.runLanePass(context.Background(), "asura", false); pace != defaultGap {
t.Fatalf("due-query pace = %s, want %s", pace, defaultGap)
}
if pass := latestPassFor(t, s, "asura"); pass.Skip != skipDueQuery {
t.Fatalf("skip = %q, want %q", pass.Skip, skipDueQuery)
if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipDueQuery {
t.Fatalf("skip = %q, want %q", pass.Skip, SkipDueQuery)
}
if got := countPassRows(t, dbURL, "asura"); got != 1 {
t.Fatalf("pass rows = %d, want exactly 1", got)
@@ -1853,8 +1804,8 @@ func TestRunLanePassRecordsEveryExit(t *testing.T) {
t.Fatalf("fetches while Chrome is asleep = %d, want 0", browser.callCount())
}
pass := latestPassFor(t, s, "kagane")
if pass.Skip != skipAsleep || pass.Due != 3 || pass.GapMS != defaultGap.Milliseconds() {
t.Fatalf("asleep pass = %+v, want skip %q with 3 due and the default gap", pass, skipAsleep)
if pass.Skip != SkipAsleep || pass.Due != 3 || pass.GapMS != defaultGap.Milliseconds() {
t.Fatalf("asleep pass = %+v, want skip %q with 3 due and the default gap", pass, SkipAsleep)
}
if got := countPassRows(t, dbURL, "kagane"); got != 1 {
t.Fatalf("pass rows = %d, want exactly 1", got)
@@ -1871,8 +1822,8 @@ func TestRunLanePassRecordsEveryExit(t *testing.T) {
if pace := p.runLanePass(context.Background(), "asura", false); pace != defaultGap {
t.Fatalf("eligible-count pace = %s, want %s", pace, defaultGap)
}
if pass := latestPassFor(t, s, "asura"); pass.Skip != skipEligibleCount {
t.Fatalf("skip = %q, want %q", pass.Skip, skipEligibleCount)
if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipEligibleCount {
t.Fatalf("skip = %q, want %q", pass.Skip, SkipEligibleCount)
}
if got := countPassRows(t, dbURL, "asura"); got != 1 {
t.Fatalf("pass rows = %d, want exactly 1", got)
@@ -1885,8 +1836,8 @@ func TestRunLanePassRecordsEveryExit(t *testing.T) {
if pace := p.runLanePass(context.Background(), "asura", false); pace != sites["asura"].Rest {
t.Fatalf("nothing-eligible pace = %s, want a full rest %s", pace, sites["asura"].Rest)
}
if pass := latestPassFor(t, s, "asura"); pass.Skip != skipNothingEligible {
t.Fatalf("skip = %q, want %q", pass.Skip, skipNothingEligible)
if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipNothingEligible {
t.Fatalf("skip = %q, want %q", pass.Skip, SkipNothingEligible)
}
if got := countPassRows(t, dbURL, "asura"); got != 1 {
t.Fatalf("pass rows = %d, want exactly 1", got)
@@ -1959,8 +1910,8 @@ func TestRunLanePassCarryForwardOnlyWhenGapZero(t *testing.T) {
p.Now = func() time.Time { return now.Add(time.Minute) }
p.runLanePass(context.Background(), "kagane", false)
second := latestPassFor(t, s, "kagane")
if second.Skip != skipRefusing {
t.Fatalf("second pass skip = %q, want %q", second.Skip, skipRefusing)
if second.Skip != SkipRefusing {
t.Fatalf("second pass skip = %q, want %q", second.Skip, SkipRefusing)
}
if second.Due != first.Due || second.Checked != first.Checked ||
second.GapMS != first.GapMS || second.Clamped != first.Clamped {
@@ -1986,8 +1937,8 @@ func TestRunLanePassCarryForwardOnlyWhenGapZero(t *testing.T) {
p.Now = func() time.Time { return now.Add(time.Minute) }
p.runLanePass(context.Background(), "comix", false)
second := latestPassFor(t, s, "comix")
if second.Skip != skipNoFetcher {
t.Fatalf("second pass skip = %q, want %q", second.Skip, skipNoFetcher)
if second.Skip != SkipNoFetcher {
t.Fatalf("second pass skip = %q, want %q", second.Skip, SkipNoFetcher)
}
if second.GapMS != defaultGap.Milliseconds() {
t.Fatalf("second pass gap = %d, want its own %d (not carried)", second.GapMS, defaultGap.Milliseconds())
@@ -2089,8 +2040,8 @@ func TestDurableRefusalSurvivesFreshPoller(t *testing.T) {
if got := fresh.BrowserFetch.(*fakeFetcher).callCount(); got != 0 {
t.Fatalf("fetches by a fresh poller inside the backoff = %d, want 0", got)
}
if pass := latestPassFor(t, s, "kagane"); pass.Skip != skipRefusing {
t.Fatalf("fresh poller's pass skip = %q, want %q", pass.Skip, skipRefusing)
if pass := latestPassFor(t, s, "kagane"); pass.Skip != SkipRefusing {
t.Fatalf("fresh poller's pass skip = %q, want %q", pass.Skip, SkipRefusing)
}
// Past the backoff the fresh poller probes again — the two Series the
+4 -3
View File
@@ -401,9 +401,10 @@ const (
// express (docs/research/cloudflare-bot-scoring-and-poll-cadence.md);
// below it the Lane is outrunning its own plan and says so loudly.
minGap = time.Second
// refuseBackoff is how long a Lane waits after its Site refused twice in
// one run before attempting it again.
refuseBackoff = 15 * time.Minute
// RefuseBackoff is how long a Lane waits after its Site refused twice in
// one run before attempting it again. Exported so the web layer can derive
// browser reachability from the pass log over the same window (issue #145).
RefuseBackoff = 15 * time.Minute
// browserWakeCount and browserWakeAge gate a browser Lane's run: five or
// more due Series, or any one of them waiting this long, or Chrome stays
// asleep (ADR-0005 on-demand browser).
-79
View File
@@ -1,79 +0,0 @@
package latest
import "time"
// LaneState is the administrative page's view of one Poll Lane (issue #102):
// what the Lane's last pass saw. Due, Gap and Checked are filled in as the
// pass computes them; a pass that returned before reaching a figure (refusal
// backoff, sidecar down) carries the previous pass's figures forward rather
// than overwriting them with zeroes the page would state as fact.
type LaneState struct {
Site string
Due int
LastRun time.Time
Gap time.Duration
// Checked is how many Series this pass actually read. A Lane with Series
// due and nothing checked has stopped working; one with nothing due is
// merely quiet, and the page must not draw the two the same (story 13).
Checked int
Clamped bool
Refusing bool
Browser bool
// Asleep marks a browser Lane whose last pass declined to wake Chrome
// because it was under both wake thresholds (ADR-0005). Due without
// Checked then means "waiting for the group to gather", not "stopped", and
// the page must not draw it as a stall.
Asleep bool
}
// Status is the owner's page snapshot of the whole poller (issue #102).
type Status struct {
Lanes []LaneState
BrowserConfigured bool
BrowserReachable bool
}
// LaneStatus returns a copy of the poller's Lane state for the owner's page.
// Only Sites that have completed a pass appear — a restart therefore renders
// "no data yet" instead of confident zeroes — in the same order Run iterates.
// Refusing is derived at snapshot time from the refusal backoff, not stored,
// so a Lane that cooled down between passes reports false without a new pass.
// BrowserReachable mirrors the Lanes' own gate: the sidecar is down only
// within the refuseBackoff window since its last loss.
func (p *Poller) LaneStatus() Status {
p.mu.Lock()
defer p.mu.Unlock()
lanes := make([]LaneState, 0, len(p.laneStates))
now := p.Now()
for _, name := range laneNames() {
st, ok := p.laneStates[name]
if !ok {
continue
}
st.Refusing = now.Before(p.refuseUntil[name])
lanes = append(lanes, st)
}
configured := p.BrowserFetch != nil
reachable := configured
if reachable && !p.browserDownAt.IsZero() && now.Sub(p.browserDownAt) < refuseBackoff {
reachable = false
}
return Status{Lanes: lanes, BrowserConfigured: configured, BrowserReachable: reachable}
}
// recordLaneState stores one Lane's last pass for LaneStatus. Called deferred
// from runLanePass so every return path records, even a pass that refused.
// A pass that never reached the pace (Gap zero) keeps the last pass's figures:
// the Lane's due count and gap did not become zero because this pass declined
// to look, and the row's own marks say why it declined.
func (p *Poller) recordLaneState(st LaneState) {
p.mu.Lock()
defer p.mu.Unlock()
if p.laneStates == nil {
p.laneStates = make(map[string]LaneState)
}
if prev, ok := p.laneStates[st.Site]; ok && st.Gap == 0 {
st.Due, st.Gap, st.Clamped, st.Checked = prev.Due, prev.Gap, prev.Clamped, prev.Checked
}
p.laneStates[st.Site] = st
}
-117
View File
@@ -6,18 +6,9 @@ import (
"strconv"
"time"
"bookmarkmanager/backend/internal/latest"
"bookmarkmanager/backend/internal/store"
)
// LaneReporter is the administrative page's whole window onto the running
// poller: one snapshot of Poll Lane state, copied out of memory on request.
// The Poller satisfies it in production and a fake with fixed values satisfies
// it in tests, so the page's tests need neither a poller nor a Site.
type LaneReporter interface {
LaneStatus() latest.Status
}
// adminView is the shared shell data for an administrative page and the roster
// fragment returned after a Reader action.
type adminView struct {
@@ -29,50 +20,6 @@ type adminView struct {
Lanes lanesView
}
// lanesView is the Lane status block: one row per Site that has run, plus the
// browser fact, which is shared by the three browser Sites rather than held
// once per Site.
type lanesView struct {
Rows []laneRow
// PollerOff means no poller is running at all (disabled by config, or its
// client could not be built). The browser line must not answer "not
// configured" then: the sidecar is not the reason nothing is polled.
PollerOff bool
BrowserConfigured bool
BrowserReachable bool
}
// laneRow is one Lane formatted for reading rather than for arithmetic: the
// template renders strings and flags, and every judgement about what they mean
// is made here.
type laneRow struct {
Site string
Due int
Ran string
// Checked is how many Series the last pass read. Due without Checked is a
// Lane that has stopped working; the two figures side by side are what
// separate that from a Lane with nothing to do.
Checked int
// Gap is empty when no pass has reached the pace yet, so the row omits the
// figure instead of stating a zero.
Gap string
Clamped bool
Refusing bool
// BrowserLost marks a Lane whose pages can only be read through the
// sidecar while the sidecar is unreachable — including the case where none
// is configured, which stops those Series just as completely.
BrowserLost bool
// Stalled marks a Lane with Series waiting that its last pass did not read
// — the difference between a stopped Lane and a quiet one (story 13). A
// browser Lane holding Chrome asleep under the wake thresholds is neither,
// so it carries Asleep instead and never Stalled.
Stalled bool
Asleep bool
// Attention is the one flag the template colours on, so an unhealthy Lane
// is found at a glance rather than read for.
Attention bool
}
// adminRoute pairs a route pattern with its handler so the route list and the
// gate cannot drift apart.
type adminRoute struct {
@@ -126,11 +73,6 @@ func (h *Handler) admin(w http.ResponseWriter, r *http.Request) {
h.renderAdmin(w, adminView{Page: "overview"})
}
// adminLanes renders the page that hosts the live Lane fragment.
func (h *Handler) adminLanes(w http.ResponseWriter, r *http.Request) {
h.renderAdmin(w, adminView{Page: "lanes", Lanes: h.lanesView()})
}
// adminReaders renders the Reader roster on its own bookmarkable page.
func (h *Handler) adminReaders(w http.ResponseWriter, r *http.Request) {
readers, err := h.store.Readers()
@@ -152,65 +94,6 @@ func (h *Handler) renderAdmin(w http.ResponseWriter, view adminView) {
h.render(w, http.StatusOK, "admin", view)
}
// uiLanes answers the status block's own refresh. Only the block refreshes on a
// timer; the roster re-renders after an action, as it always has.
func (h *Handler) uiLanes(w http.ResponseWriter, r *http.Request) {
h.render(w, http.StatusOK, "lanes", h.lanesView())
}
// lanesView copies the poller's snapshot into display form. A nil reporter (no
// poller running) and a poller no Lane has reported to yet are the same thing
// to the page: no data, which it must say rather than draw as confident zeroes
// — an empty page a few seconds after a restart must not read as a stopped one.
func (h *Handler) lanesView() lanesView {
if h.lanes == nil {
return lanesView{PollerOff: true}
}
snap := h.lanes.LaneStatus()
v := lanesView{
Rows: make([]laneRow, 0, len(snap.Lanes)),
BrowserConfigured: snap.BrowserConfigured,
BrowserReachable: snap.BrowserReachable,
}
now := time.Now()
for _, l := range snap.Lanes {
lost := l.Browser && !snap.BrowserReachable
// Series waiting and none read is the shape of a Lane that has stopped
// working, as distinct from one that is quiet for want of work — or one
// deliberately leaving Chrome asleep until its group gathers.
stalled := l.Due > 0 && l.Checked == 0 && !l.Asleep
gap := ""
if l.Gap > 0 {
gap = l.Gap.Truncate(time.Second).String()
}
v.Rows = append(v.Rows, laneRow{
Site: l.Site,
Due: l.Due,
Ran: since(now, l.LastRun),
Checked: l.Checked,
Gap: gap,
Clamped: l.Clamped,
Refusing: l.Refusing,
BrowserLost: lost,
Stalled: stalled,
Asleep: l.Asleep,
Attention: l.Clamped || l.Refusing || lost || stalled,
})
}
return v
}
// since formats how long ago a Lane last ran, at second resolution: the block
// refreshes every thirty seconds, so anything finer is noise the owner would
// have to ignore.
func since(now, then time.Time) string {
d := now.Sub(then).Truncate(time.Second)
if d < time.Second {
return "just now"
}
return d.String() + " ago"
}
// revokeReaderSessions logs one Reader out of every browser they are signed in
// on. The owner gate is the route's, not this handler's.
func (h *Handler) revokeReaderSessions(w http.ResponseWriter, r *http.Request) {
+239
View File
@@ -0,0 +1,239 @@
package web
import (
"fmt"
"log"
"net/http"
"time"
"bookmarkmanager/backend/internal/latest"
"bookmarkmanager/backend/internal/store"
)
// lanesView is the Lane status block: one row per Site's latest durable pass,
// plus the browser fact derived from that same log. No poller is consulted —
// the page answers from the database, so it is complete thirty seconds after
// a deploy (issue #145).
type lanesView struct {
Rows []laneRow
// PollerOff means latest-chapter polling is switched off in this
// deployment (LATEST_CHAPTER_POLL_ENABLED). It is a config fact, not a
// poller answering "absent": the browser line must not blame the sidecar
// when nothing polls.
PollerOff bool
BrowserConfigured bool
BrowserReachable bool
}
// laneRow is one Lane formatted for reading rather than for arithmetic: the
// template renders strings and flags, and every judgement about what they
// mean is made here.
type laneRow struct {
Site string
Due int
Checked int
// Gap is the last pass's pace, or "—" when no pass has reached one yet —
// a refused Lane still reports the pace its last real pass chose, so a
// zero here would be a figure the row never measured.
Gap string
Ran string
// Chips are the named outcome counts over the owner's window, in the
// taxonomy's fixed order. Empty writes "none observed".
Chips []chip
HasChips bool
// StatePhrase is the reason this Lane declined to work: a skipped pass's
// own sentence, or the one true stall. Empty means the pass reached its
// loop and read normally. StateGood marks a healthy way to do nothing
// (paused, browser asleep, nothing eligible) rather than a fault.
StatePhrase string
StateGood bool
// Attention is the one flag the template colours on, so a Lane that
// needs the owner is found at a glance rather than read for.
Attention bool
}
// chip is one named outcome count over the owner's window.
type chip struct {
Name string
Count int
}
// adminLanes renders the page that hosts the live Lane fragment.
func (h *Handler) adminLanes(w http.ResponseWriter, r *http.Request) {
h.renderAdmin(w, adminView{Page: "lanes", Lanes: h.lanesView()})
}
// uiLanes answers the status block's own refresh. Only the block refreshes on
// a timer; the roster re-renders after an action, as it always has.
func (h *Handler) uiLanes(w http.ResponseWriter, r *http.Request) {
h.render(w, http.StatusOK, "lanes", h.lanesView())
}
// lanesView builds the Lane status block from the durable pass log. Both
// reads are the store's latest-per-Site projection, so the page's seam is a
// seeded row rather than a fake poller; errors degrade to the empty state and
// are logged, never shown to the owner in detail.
func (h *Handler) lanesView() lanesView {
v := lanesView{
PollerOff: !h.pollerEnabled,
BrowserConfigured: h.browserConfigured,
}
passes, err := h.store.LatestLanePasses()
if err != nil {
log.Printf("admin lanes: latest passes: %v", err)
return v
}
now := time.Now()
outcomes, err := h.store.LanePassOutcomes(time.Now().Add(-ownerWindow).UnixMilli())
if err != nil {
log.Printf("admin lanes: outcomes: %v", err)
return lanesView{
PollerOff: v.PollerOff,
BrowserConfigured: v.BrowserConfigured,
}
}
bySite := make(map[string]store.SiteOutcomes, len(outcomes))
for _, o := range outcomes {
bySite[o.Site] = o
}
v.Rows = make([]laneRow, 0, len(passes))
v.BrowserReachable = browserReachable(passes, now)
for _, p := range passes {
v.Rows = append(v.Rows, buildLaneRow(p, bySite[p.Site], now))
}
return v
}
// browserReachable derives the sidecar's reachability from the pass log: a
// browser Site is down when its latest pass inside the refusal backoff is a
// sidecar loss, a missing fetcher, or an interrupted read. Only browser Sites
// ever produce those signals, so no Site registry leaks into the web layer.
// A configured browser with no such evidence reads as reachable; an unset
// BROWSER_WS_URL degrades identically to a browser that is down.
func browserReachable(passes []store.LanePass, now time.Time) bool {
backoff := latest.RefuseBackoff
for _, p := range passes {
ran := time.UnixMilli(p.RanAt)
if now.Sub(ran) >= backoff || ran.After(now) {
continue
}
if p.Skip == latest.SkipSidecarDown || p.Skip == latest.SkipNoFetcher || p.Unreachable > 0 {
return false
}
}
return true
}
// buildLaneRow turns one Site's latest pass and window outcome sums into the
// row the template prints. The skip column is the authority on why a pass did
// nothing; the outcomes render named and unlinked, because the pass row holds
// counts and never identities.
func buildLaneRow(p store.LanePass, o store.SiteOutcomes, now time.Time) laneRow {
row := laneRow{
Site: p.Site,
Due: p.Due,
Checked: p.Checked,
Gap: "—",
Ran: since(now, time.UnixMilli(p.RanAt)),
}
if p.GapMS > 0 {
row.Gap = (time.Duration(p.GapMS) * time.Millisecond).Truncate(time.Second).String()
}
row.Chips = outcomeChips(o)
row.HasChips = len(row.Chips) > 0
row.StatePhrase, row.StateGood, row.Attention = laneState(p, now)
return row
}
// outcomeChips lists a Site's nonzero window sums in the taxonomy's fixed
// order, so the chips never reorder as the window changes. None observed is
// written by the template, not drawn as a confident zero count.
func outcomeChips(o store.SiteOutcomes) []chip {
fixed := []struct {
name string
count int
}{
{"refused", o.Refused},
{"unreachable", o.Unreachable},
{"no chapter", o.NoChapter},
{"unfetchable", o.Unfetchable},
{"errors", o.Errors},
}
var out []chip
for _, f := range fixed {
if f.count > 0 {
out = append(out, chip{Name: f.name, Count: f.count})
}
}
return out
}
// laneState renders the reason a Lane's last pass did nothing, in one sentence
// per skip value with the one true stall kept apart from every Lane that
// declined and said why. Good states — a pause, a sleeping browser, nothing
// eligible — carry no Attention: the mark must stay spendable on the faults
// that actually need the owner.
func laneState(p store.LanePass, now time.Time) (phrase string, good, attention bool) {
switch p.Skip {
case latest.SkipPaused:
phrase = "paused · resumes in " + humanDuration(time.UnixMilli(p.PausedUntil).Sub(now))
good = true
case latest.SkipRefusing:
phrase = "refusing"
if until := time.UnixMilli(p.RefuseUntil); until.After(now) {
phrase += " · backs off until " + until.Format("15:04")
}
attention = true
case latest.SkipSidecarDown, latest.SkipNoFetcher:
// Known false positive shipped per spec: a sibling Lane's Chrome loss
// stamps this Site too, and the enum deliberately has no tenth value
// to separate it (issue #141). Render it as written.
phrase = "no browser"
attention = true
case latest.SkipAsleep:
phrase = "browser asleep"
good = true
case latest.SkipDueQuery, latest.SkipEligibleCount:
phrase = "check failed"
attention = true
case latest.SkipNothingEligible:
phrase = "nothing eligible"
good = true
}
if phrase == "" && p.Due > 0 && p.Checked == 0 {
// The one true stall: the pass reached its loop, Series were waiting,
// and none were read. Every skip above is a Lane that said why.
phrase = "not checking"
attention = true
}
return phrase, good, attention
}
// humanDuration renders a positive duration compactly for a "resumes in" clue
// at the pause and refusal scales — minutes under an hour, then h and h+m.
func humanDuration(d time.Duration) string {
d = d.Round(time.Minute)
if d <= 0 {
return "soon"
}
if d < time.Hour {
return fmt.Sprintf("%dm", int(d/time.Minute))
}
h := int(d / time.Hour)
if m := int(d%time.Hour) / int(time.Minute); m == 0 {
return fmt.Sprintf("%dh", h)
} else {
return fmt.Sprintf("%dh%dm", h, m)
}
}
// since formats how long ago a Lane last ran, at second resolution: the block
// refreshes every thirty seconds, so anything finer is noise the owner would
// have to ignore.
func since(now, then time.Time) string {
d := now.Sub(then).Truncate(time.Second)
if d < time.Second {
return "just now"
}
return d.String() + " ago"
}
+44
View File
@@ -191,6 +191,29 @@
}
/* A single grid keeps row rules continuous; cell padding supplies gutters. */
.admin-sheet .sechead {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
}
.admin-sheet .sechead .statusline {
padding: 0 0 8px;
font-size: 11px;
letter-spacing: .14em;
text-transform: uppercase;
}
.admin-sheet .statusline {
margin: 0;
padding: 0 0 10px;
font: 500 12px/1 var(--font-mono);
letter-spacing: .04em;
color: var(--mute-2);
}
.admin-sheet .tbl {
display: grid;
grid-template-columns: minmax(240px, 1fr) 156px 92px 110px 76px minmax(150px, 220px) 140px;
@@ -309,6 +332,23 @@
color: var(--mute-2);
}
.admin-sheet .mark.mark-strong {
font-size: 13px;
letter-spacing: .14em;
color: var(--patina);
}
.admin-sheet .mark.mark-strong::before {
content: "";
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--patina);
margin-right: 8px;
vertical-align: .08em;
}
.admin-sheet .mark.bad {
color: var(--danger);
}
@@ -335,6 +375,10 @@
color: var(--danger);
}
.admin-sheet .tbl.lanes .c-skip .ok {
color: var(--patina);
}
.admin-sheet .tbl.sites .thead > *:nth-child(n+2):nth-child(-n+5),
.admin-sheet .tbl.sites .trow > *:nth-child(n+2):nth-child(-n+5) {
padding-right: 0;
+34 -32
View File
@@ -1,42 +1,44 @@
{{/* Poll Lane status: one row per Site, refreshing itself so a run can be
watched rather than sampled by reloading. The refresh is one attribute on
the fragment root and the endpoint answers with this same fragment, so the
swap replaces the element that asked for it.
{{/* Poll Lane status: one row per Site's latest durable pass, refreshing
itself so a run can be watched rather than sampled by reloading. The
refresh is one attribute on the fragment root and the endpoint answers
with this same fragment, so the swap replaces the element that asked.
Every figure here is read out of the running poller, never out of a table:
a Site absent from Rows has not completed a pass since the last restart,
which the empty state must say — zeroes would read as a stopped Lane. */}}
Every figure is read from poll_passes, never from a running poller: a
restart answers from the database the moment it is up (issue #145). The
browser fact is a deployment-config fact plus a reachability derived from
the pass log; the cause chips and the state phrase are decided in Go,
this template only prints them. */}}
{{define "lanes"}}
<section class="lanes" id="lanes"
hx-get="/ui/admin/lanes" hx-trigger="every 30s" hx-swap="outerHTML">
<h2>Poll Lanes</h2>
<div class="sechead">
<h2 class="sec">Poll Lanes</h2>
<p class="statusline">
{{if .PollerOff}}Polling is switched off in this deployment: no Lane
runs, and Latest Chapter comes from the userscripts alone.
{{else}}Browser: {{if not .BrowserConfigured}}<span class="mark-faint">not configured</span>{{else if .BrowserReachable}}<span class="mark mark-strong">reachable</span>{{else}}<span class="mark mark-strong bad">unreachable</span>{{end}}{{end}}
</p>
</div>
{{if .Rows}}
<ul class="lanelist">
<div class="tbl lanes">
<div class="thead">
<span>Site</span><span>Due</span><span>Checked</span><span>Gap</span>
<span>Last pass</span><span>Outcomes · state</span><span></span>
</div>
{{range .Rows}}
<li{{if .Attention}} class="attention"{{end}}>
<span class="lane-site">{{.Site}}</span>
<span class="lane-fact">{{.Due}} due</span>
<span class="lane-fact">{{.Checked}} checked</span>
<span class="lane-fact">ran {{.Ran}}</span>
{{if .Gap}}<span class="lane-fact">gap {{.Gap}}</span>{{end}}
{{if .Clamped}}<span class="lane-mark">gap at floor</span>{{end}}
{{if .Refusing}}<span class="lane-mark">refusing</span>{{end}}
{{if .BrowserLost}}<span class="lane-mark">no browser</span>{{end}}
{{if .Stalled}}<span class="lane-mark">not checking</span>{{end}}
{{if .Asleep}}<span class="lane-mark">browser asleep</span>{{end}}
</li>
<div class="trow{{if .Attention}} attention{{end}}">
<span class="c-site">{{.Site}}</span>
<span>{{.Due}}</span>
<span>{{.Checked}}</span>
<span>{{.Gap}}</span>
<span>ran {{.Ran}}</span>
<span class="c-skip">{{if .HasChips}}{{range $i, $c := .Chips}}{{if $i}}<span class="mark-faint"> · </span>{{end}}<span class="mark">{{$c.Name}} {{$c.Count}}</span>{{end}}{{else}}<span class="mark-faint">none observed</span>{{end}}{{if .StatePhrase}} · <span class="{{if .StateGood}}ok{{else}}bad{{end}}">{{.StatePhrase}}</span>{{end}}</span>
<span class="c-ctrl"></span>
</div>
{{end}}
</ul>
</div>
{{else}}
<p class="setup-copy">No data yet — no Lane has completed a pass since the
backend started.</p>
<p class="empty">No data yet — no Lane has recorded a pass.</p>
{{end}}
<p class="setup-copy lane-browser">
{{if .PollerOff}}Polling is switched off in this deployment: no Lane runs,
and Latest Chapter comes from the userscripts alone.
{{else}}Browser sidecar:
{{if not .BrowserConfigured}}not configured — comix, kagane and novelfull
pages are not fetched through it{{else if .BrowserReachable}}reachable
{{else}}unreachable{{end}}.{{end}}</p>
</section>
{{end}}
{{end}}
+15 -7
View File
@@ -50,9 +50,14 @@ type Handler struct {
// httpClient is the plain stdlib client that talks to Discord. It is not
// an injected interface: tests point APIBase at a stub server instead.
httpClient *http.Client
// lanes is the Poll Lane snapshot source the administrative page reads.
// Nil is a running deployment with no poller, not a bug.
lanes LaneReporter
// pollerEnabled reports whether latest-chapter polling is switched on in
// this deployment (LATEST_CHAPTER_POLL_ENABLED) and browserConfigured
// whether a browser sidecar is configured (BROWSER_WS_URL set). Both are
// deployment facts resolved by the composition root; the Lanes page (issue
// #145) reports them from config and derives reachability from the pass
// log rather than from whether a poller goroutine happened to start.
pollerEnabled bool
browserConfigured bool
}
// listView is what every list-rendering template receives.
@@ -110,9 +115,11 @@ type loginView struct {
// New parses every template up front so a broken one kills the process at
// startup rather than the first request that touches it.
//
// lanes is the administrative page's window onto the running Poller; nil means
// nothing is polling, which the page reports rather than hides.
func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string, lanes LaneReporter) (*Handler, error) {
// pollerEnabled and browserConfigured are deployment facts the composition
// root resolves from LATEST_CHAPTER_POLL_ENABLED and BROWSER_WS_URL: the Lanes
// page (issue #145) reports them and derives browser reachability from the
// pass log, so no running poller is wired through here at all.
func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string, pollerEnabled, browserConfigured bool) (*Handler, error) {
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
if err != nil {
return nil, err
@@ -127,7 +134,8 @@ func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, nove
states: newOAuthStates(),
limiter: session.NewLoginLimiter(),
httpClient: &http.Client{Timeout: discordTimeout},
lanes: lanes,
pollerEnabled: pollerEnabled,
browserConfigured: browserConfigured,
}, nil
}