diff --git a/backend/internal/latest/poller.go b/backend/internal/latest/poller.go index 433b6f8..e0dbe12 100644 --- a/backend/internal/latest/poller.go +++ b/backend/internal/latest/poller.go @@ -57,6 +57,10 @@ type Poller struct { 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. @@ -218,6 +222,10 @@ func (p *Poller) runOnce(ctx context.Context) { // 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) }() if until := p.refusalBackoff(name); now.Before(until) { // Cooling down after a refusal: do not attempt this Site at all. return until.Sub(now) @@ -246,6 +254,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time. log.Printf("latest poll %s: due query: %v", name, err) return defaultGap } + st.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 @@ -268,6 +277,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time. return defaultGap } gap, clamped := effectiveGap(s, eligible) + st.Gap, st.Clamped = gap, clamped if clamped { log.Printf("latest poll %s: gap clamped to %s floor (eligible series=%d)", name, minGap, eligible) } diff --git a/backend/internal/latest/poller_test.go b/backend/internal/latest/poller_test.go index 4f9d8ce..7cff35e 100644 --- a/backend/internal/latest/poller_test.go +++ b/backend/internal/latest/poller_test.go @@ -1580,3 +1580,70 @@ func TestRunOnceClampWarningNamesTheSite(t *testing.T) { t.Fatalf("clamp warning = %q, want it to name asura and 3601", got) } } + +// 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) { + 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) + } + + seedForCheck(t, s, "asura:chronicles", "https://asurascans.com/series/chronicles", 0) + // Two refusals put kagane's Lane into backoff; asura sits on its own Lane. + browser := &fakeFetcher{status: 403} + p.BrowserFetch = browser + for i := range 2 { + key := fmt.Sprintf("kagane:s%d", i) + seedForCheck(t, s, key, "https://kagane.to/series/"+key[7:], 0) + } + 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 + } + } + 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) + } + + // 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) + } +} diff --git a/backend/internal/latest/status.go b/backend/internal/latest/status.go new file mode 100644 index 0000000..da8b417 --- /dev/null +++ b/backend/internal/latest/status.go @@ -0,0 +1,63 @@ +package latest + +import "time" + +// LaneState is the administrative page's view of one Poll Lane (issue #102): +// what the Lane's last completed pass saw. Due and Gap are filled in as the +// pass computes them, so a pass that returned before reaching a figure (Lane +// in refusal backoff, no fetcher) records a zero in its place. +type LaneState struct { + Site string + Due int + LastRun time.Time + Gap time.Duration + Clamped bool + Refusing bool + Browser 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. +func (p *Poller) recordLaneState(st LaneState) { + p.mu.Lock() + defer p.mu.Unlock() + if p.laneStates == nil { + p.laneStates = make(map[string]LaneState) + } + p.laneStates[st.Site] = st +}