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:
+10
-8
@@ -252,16 +252,18 @@ keep warning to reinstall on all devices.
|
||||
(`view.Owner = readerID == h.store.OwnerID()`): it gates a link, not an
|
||||
endpoint, so it is a rendering decision a registration-time wrapper cannot
|
||||
express. Do not "unify" it into the gate.
|
||||
- Lane figures come through the `web.LaneReporter` seam
|
||||
(`latest.Poller.LaneStatus`), never a table. `main.newRouter` takes the
|
||||
reporter as an interface and converts a nil `*Poller` to a nil interface — a
|
||||
typed nil would make the page claim a poller exists.
|
||||
- The Lanes page reads the pass log, never a running poller: `lanesView()` in
|
||||
`admin_lanes.go` projects `store.LatestLanePasses()` and
|
||||
`store.LanePassOutcomes()` (ADR-0012), so a restart answers the instant the
|
||||
database is up. Browser configuration is a config fact and reachability is
|
||||
derived from recent browser-Site passes inside `latest.RefuseBackoff` — no
|
||||
reporter interface exists to fake.
|
||||
- A pass that returns before computing figures (refusal backoff, sidecar down)
|
||||
carries the previous pass's numbers forward rather than recording zeroes.
|
||||
- **`Checked` next to `Due` is what separates a stopped Lane from a quiet one**,
|
||||
so neither may be dropped from the row.
|
||||
- Due-without-Checked is **not** by itself a stall: a browser Lane under both
|
||||
wake thresholds sets `LaneState.Asleep` and renders "browser asleep", and
|
||||
never counts toward `Attention`. That is the commonest healthy state for
|
||||
kagane, comix and novelfull, so spending the stall mark on it would train the
|
||||
owner to ignore the mark that matters.
|
||||
wake thresholds records its pass with the `SkipAsleep` skip and renders
|
||||
"browser asleep", and that never counts toward `Attention`. It is the
|
||||
commonest healthy state for kagane, comix and novelfull, so spending the
|
||||
stall mark on it would train the owner to ignore the mark that matters.
|
||||
|
||||
+5
-5
@@ -48,7 +48,7 @@ func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) }
|
||||
|
||||
func newTestServer(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
return newRouter(newTestStore(t), testConfig(), nil)
|
||||
return newRouter(newTestStore(t), testConfig())
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T) *store.Store {
|
||||
@@ -599,7 +599,7 @@ func TestLoadConfigDiscord(t *testing.T) {
|
||||
// cooldown and the poller would re-fetch that series on every single tick.
|
||||
func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
srv := newRouter(s, testConfig(), nil)
|
||||
srv := newRouter(s, testConfig())
|
||||
|
||||
seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", 777)
|
||||
|
||||
@@ -627,7 +627,7 @@ func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
|
||||
// series stops being due the moment the PUT lands.
|
||||
func TestPutRecordsASighting(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
srv := newRouter(s, testConfig(), nil)
|
||||
srv := newRouter(s, testConfig())
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
hour := time.Hour.Milliseconds()
|
||||
@@ -677,7 +677,7 @@ func TestUserscriptServedWithWebUIDisabled(t *testing.T) {
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/u/"+ownerCredential()+"/manga-bookmark.user.js", nil)
|
||||
newRouter(s, cfg, nil).ServeHTTP(rr, req)
|
||||
newRouter(s, cfg).ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
@@ -699,7 +699,7 @@ func TestNovelUserscriptServed(t *testing.T) {
|
||||
|
||||
cfg := testConfig()
|
||||
cfg.NovelUserscriptPath = novelPath
|
||||
srv := newRouter(s, cfg, nil)
|
||||
srv := newRouter(s, cfg)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
|
||||
|
||||
@@ -98,7 +98,7 @@ func TestPublicCoverNeverEchoesNonImage(t *testing.T) {
|
||||
if _, err := db.Exec(`UPDATE covers SET content_type = 'text/html' WHERE address = $1`, address); err != nil {
|
||||
t.Fatalf("poison row: %v", err)
|
||||
}
|
||||
rr := getCover(t, newRouter(st, testConfig(), nil), "/covers/"+address, nil)
|
||||
rr := getCover(t, newRouter(st, testConfig()), "/covers/"+address, nil)
|
||||
if rr.Code == http.StatusOK {
|
||||
t.Fatalf("status = 200, want a refusal for a non-image row (body %q)", rr.Body.String())
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+21
-14
@@ -54,6 +54,11 @@ type Config struct {
|
||||
// /u/{token}/novel-bookmark.user.js. Same bindmount, second script: the
|
||||
// two libraries are separate installs.
|
||||
NovelUserscriptPath string
|
||||
// BrowserWSURL is the CDP websocket the poller's browser Sites read
|
||||
// through. Set means a browser sidecar is configured in this deployment —
|
||||
// the Lanes page reports the fact and derives reachability from the pass
|
||||
// log rather than asking the poller (issue #145).
|
||||
BrowserWSURL string
|
||||
// LatestPoll configures the background latest-chapter fetcher.
|
||||
LatestPoll LatestPoll
|
||||
}
|
||||
@@ -108,6 +113,7 @@ func loadConfig() Config {
|
||||
OwnerDiscordID: os.Getenv("OWNER_DISCORD_ID"),
|
||||
UserscriptPath: envOr("USERSCRIPT_PATH", "/userscript/manga-bookmark.user.js"),
|
||||
NovelUserscriptPath: envOr("NOVEL_USERSCRIPT_PATH", "/userscript/novel-bookmark.user.js"),
|
||||
BrowserWSURL: os.Getenv("BROWSER_WS_URL"),
|
||||
LatestPoll: loadLatestPoll(),
|
||||
}
|
||||
c.Discord = web.DiscordConfig{
|
||||
@@ -130,9 +136,10 @@ func loadConfig() Config {
|
||||
// preflight OPTIONS short-circuits before auth; /bookmarks* is auth-protected,
|
||||
// /healthz is public.
|
||||
//
|
||||
// lanes may be nil — polling disabled, or its client could not be built. The
|
||||
// admin page reports that rather than pretending Lanes exist.
|
||||
func newRouter(s *store.Store, cfg Config, lanes web.LaneReporter) http.Handler {
|
||||
// The web layer learns the deployment's poller and browser config from cfg —
|
||||
// nothing of the running poller is wired through here; the Lanes page reads
|
||||
// the database (issue #145).
|
||||
func newRouter(s *store.Store, cfg Config) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
h := &api.Handler{Store: s}
|
||||
mux.HandleFunc("GET /healthz", api.Healthz)
|
||||
@@ -161,9 +168,12 @@ func newRouter(s *store.Store, cfg Config, lanes web.LaneReporter) http.Handler
|
||||
mux.Handle("/bookmarks/", auth)
|
||||
|
||||
// The browser UI is always registered; signing in is Discord OAuth, so
|
||||
// there is no password to forget and no gate to leave unset.
|
||||
// there is no password to forget and no gate to leave unset. The poller
|
||||
// and browser facts are config, not the poller's: the Lanes page reads
|
||||
// the pass log and reports the deployment as configured.
|
||||
wh, err := web.New(s, cfg.Discord, []byte(cfg.TokenKey),
|
||||
cfg.UserscriptPath, cfg.NovelUserscriptPath, lanes)
|
||||
cfg.UserscriptPath, cfg.NovelUserscriptPath,
|
||||
cfg.LatestPoll.Enabled, strings.TrimSpace(cfg.BrowserWSURL) != "")
|
||||
if err != nil {
|
||||
log.Fatalf("web handler: %v", err)
|
||||
}
|
||||
@@ -241,7 +251,7 @@ func main() {
|
||||
var browser latest.Fetcher
|
||||
pollCtx, stopPoll := context.WithCancel(context.Background())
|
||||
defer stopPoll()
|
||||
if ws := strings.TrimSpace(os.Getenv("BROWSER_WS_URL")); ws != "" {
|
||||
if ws := strings.TrimSpace(cfg.BrowserWSURL); ws != "" {
|
||||
bf, err := latest.NewBrowserFetcher(ws)
|
||||
if err != nil {
|
||||
log.Printf("browser fetcher disabled: %v", err)
|
||||
@@ -278,17 +288,14 @@ func main() {
|
||||
}
|
||||
s.OnSeriesCreated = acq.Acquire
|
||||
}
|
||||
// A nil *Poller must not become a non-nil interface holding a nil pointer:
|
||||
// the admin page tests the reporter for nil to decide whether anything is
|
||||
// polling at all.
|
||||
var lanes web.LaneReporter
|
||||
if poller := startLatestPoller(pollCtx, s, cfg.LatestPoll, browser); poller != nil {
|
||||
lanes = poller
|
||||
}
|
||||
// The poller's only connection to the web layer is the database now: it is
|
||||
// started for its own sake, and the Lanes page reads the pass rows it
|
||||
// records (issue #145).
|
||||
startLatestPoller(pollCtx, s, cfg.LatestPoll, browser)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: newRouter(s, cfg, lanes),
|
||||
Handler: newRouter(s, cfg),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ func withBody(req *http.Request, body string) *http.Request {
|
||||
// A refused credential is refused however plausible it looks: only a hash the
|
||||
// readers table holds authenticates anything.
|
||||
func TestUnknownCredentialRejected(t *testing.T) {
|
||||
srv := newRouter(newTestStore(t), testConfig(), nil)
|
||||
srv := newRouter(newTestStore(t), testConfig())
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", readerCredential("never-registered")))
|
||||
@@ -69,7 +69,7 @@ func TestUnknownCredentialRejected(t *testing.T) {
|
||||
func TestPerReaderIsolation(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
registerReader(t, s, "other-reader")
|
||||
srv := newRouter(s, testConfig(), nil)
|
||||
srv := newRouter(s, testConfig())
|
||||
|
||||
ownerKey := "asura:solo"
|
||||
putBookmark(t, srv, ownerKey, store.Bookmark{
|
||||
@@ -267,7 +267,7 @@ func TestRotateCredentialViaWebUI(t *testing.T) {
|
||||
}
|
||||
cfg := testConfig()
|
||||
cfg.UserscriptPath = path
|
||||
srv := newRouter(s, cfg, nil)
|
||||
srv := newRouter(s, cfg)
|
||||
|
||||
oldCred := ownerCredential()
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
+216
-86
@@ -28,17 +28,13 @@ import (
|
||||
const testOwnerID = "owner-snowflake"
|
||||
|
||||
// newWebTestServer returns the full router plus the store behind it, so tests
|
||||
// can seed rows and assert on what the handlers wrote back. An optional lane
|
||||
// reporter stands in for the running poller; omitted means none is running,
|
||||
// which is what every test that is not about the admin page wants.
|
||||
func newWebTestServer(t *testing.T, cfg Config, lanes ...web.LaneReporter) (http.Handler, *store.Store) {
|
||||
// can seed rows and assert on what the handlers wrote back. The Lanes page
|
||||
// reads the pass log (issue #145), so a test seeds store rows rather than
|
||||
// standing in for a poller.
|
||||
func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
var reporter web.LaneReporter
|
||||
if len(lanes) > 0 {
|
||||
reporter = lanes[0]
|
||||
}
|
||||
return newRouter(st, cfg, reporter), st
|
||||
return newRouter(st, cfg), st
|
||||
}
|
||||
|
||||
// sessionCookie mints a live session row for the owner and returns the cookie
|
||||
@@ -149,12 +145,12 @@ func discordConfig(stubURL string) web.DiscordConfig {
|
||||
|
||||
// oauthWebTestServer returns the full router, its store, and a Discord stub
|
||||
// wired as the configured API — the starting point for sign-in tests.
|
||||
func oauthWebTestServer(t *testing.T, lanes ...web.LaneReporter) (http.Handler, *store.Store, *discordStub) {
|
||||
func oauthWebTestServer(t *testing.T) (http.Handler, *store.Store, *discordStub) {
|
||||
t.Helper()
|
||||
stub, srv := newDiscordStub(t)
|
||||
cfg := testConfig()
|
||||
cfg.Discord = discordConfig(srv.URL)
|
||||
router, st := newWebTestServer(t, cfg, lanes...)
|
||||
router, st := newWebTestServer(t, cfg)
|
||||
return router, st, stub
|
||||
}
|
||||
|
||||
@@ -418,7 +414,7 @@ func TestDiscordLoginRefusesNonMember(t *testing.T) {
|
||||
cfg.Discord = discordConfig(srv.URL)
|
||||
cfg.Discord.RequiredRole = tc.require
|
||||
st := newTestStore(t)
|
||||
router := newRouter(st, cfg, nil)
|
||||
router := newRouter(st, cfg)
|
||||
|
||||
rr := completeSignIn(t, router, startSignIn(t, router))
|
||||
if rr.Code != http.StatusForbidden {
|
||||
@@ -634,11 +630,37 @@ func TestOwnerRevokesAnotherReadersSessions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// fakeLanes is the admin page's poller stand-in: one fixed snapshot, so the
|
||||
// page's tests need neither a poller nor a Site.
|
||||
type fakeLanes struct{ status latest.Status }
|
||||
// seedPass writes one durable pass row — the Lanes page's whole seam.
|
||||
// The page renders from the database with no poller running at all (issue
|
||||
// #145), so a test seeds rows instead of constructing a fake reporter.
|
||||
func seedPass(t *testing.T, st *store.Store, p store.LanePass) {
|
||||
t.Helper()
|
||||
if err := st.RecordLanePass(p, -1); err != nil {
|
||||
t.Fatalf("seed pass %s: %v", p.Site, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (f fakeLanes) LaneStatus() latest.Status { return f.status }
|
||||
// lanesConfig returns a config with latest-chapter polling switched on, so
|
||||
// the Lanes page's statusline speaks about the browser rather than about
|
||||
// polling being off.
|
||||
func lanesConfig() Config {
|
||||
cfg := testConfig()
|
||||
cfg.LatestPoll.Enabled = true
|
||||
return cfg
|
||||
}
|
||||
|
||||
// lanesBody fetches the Lanes fragment as the owner and returns the body.
|
||||
func lanesBody(t *testing.T, router http.Handler, st *store.Store) string {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /ui/admin/lanes status = %d, want 200", rr.Code)
|
||||
}
|
||||
return rr.Body.String()
|
||||
}
|
||||
|
||||
// Every admin address carries the same navigation, while the roster only lives
|
||||
// on its own page and the other pages keep their shells independent.
|
||||
@@ -769,32 +791,22 @@ func TestAdminRoutesAreOwnerOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The Lane block reports what the poller says, and marks the Lanes that need
|
||||
// attention — a clamped gap, a refusal, a Site whose pages can only be read
|
||||
// through a sidecar that is not there, and a Lane with Series waiting that its
|
||||
// last pass did not read.
|
||||
// The Lane block reports what the pass log says, and marks the Lanes that
|
||||
// need attention: a Site whose pages can only be read through a sidecar that
|
||||
// is not there, and a Lane with Series waiting that its last pass did not
|
||||
// read. Rows come from seeded database rows — no poller runs anywhere.
|
||||
func TestAdminPageShowsLaneStatus(t *testing.T) {
|
||||
lanes := fakeLanes{latest.Status{
|
||||
Lanes: []latest.LaneState{
|
||||
{Site: "asura", Due: 12, Checked: 12, LastRun: time.Now().Add(-90 * time.Second), Gap: 40 * time.Second},
|
||||
{Site: "kagane", Due: 3, Checked: 3, LastRun: time.Now().Add(-time.Minute), Gap: time.Minute, Browser: true},
|
||||
{Site: "demonic", Due: 400, Checked: 400, LastRun: time.Now(), Gap: 8 * time.Second, Clamped: true},
|
||||
{Site: "comix", Due: 7, LastRun: time.Now(), Gap: time.Minute, Browser: true},
|
||||
},
|
||||
BrowserConfigured: true,
|
||||
BrowserReachable: true,
|
||||
}}
|
||||
router, st, _ := oauthWebTestServer(t, lanes)
|
||||
cfg := lanesConfig()
|
||||
cfg.BrowserWSURL = "ws://browser:9222"
|
||||
router, st := newWebTestServer(t, cfg)
|
||||
now := time.Now()
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-90 * time.Second).UnixMilli(), Due: 12, Checked: 12, GapMS: 40_000})
|
||||
seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.Add(-time.Minute).UnixMilli(), Due: 3, Checked: 3, GapMS: 60_000})
|
||||
seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.UnixMilli(), Due: 400, Checked: 400, GapMS: 8_000})
|
||||
seedPass(t, st, store.LanePass{Site: "comix", RanAt: now.UnixMilli(), Due: 7, Checked: 0, GapMS: 60_000})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /ui/admin/lanes status = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"asura", "kagane", "12 due", "12 checked", "gap 40s", "ran 1m30s ago", "gap at floor", "not checking", "reachable"} {
|
||||
body := lanesBody(t, router, st)
|
||||
for _, want := range []string{"asura", "kagane", "demonic", "comix", "40s", "ran 1m30s ago", "not checking", "none observed", "reachable"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("lane status lacks %q:\n%s", want, body)
|
||||
}
|
||||
@@ -810,78 +822,75 @@ func TestAdminPageShowsLaneStatus(t *testing.T) {
|
||||
|
||||
// A browser Lane under both wake thresholds holds Chrome asleep (ADR-0005), so
|
||||
// Series due with none checked is the design working, not a stopped Lane. The
|
||||
// two must not render the same mark: "not checking" is the owner's cue to go
|
||||
// looking, and spending it on the commonest healthy browser-Lane state trains
|
||||
// them to ignore it.
|
||||
// pass row records it with the asleep skip, and the page must render its
|
||||
// sleeping sentence with no stall mark and no attention.
|
||||
func TestAsleepBrowserLaneIsNotMarkedStalled(t *testing.T) {
|
||||
lanes := fakeLanes{latest.Status{
|
||||
Lanes: []latest.LaneState{
|
||||
{Site: "kagane", Due: 1, LastRun: time.Now(), Gap: 10 * time.Second, Browser: true, Asleep: true},
|
||||
},
|
||||
BrowserConfigured: true,
|
||||
BrowserReachable: true,
|
||||
}}
|
||||
router, st, _ := oauthWebTestServer(t, lanes)
|
||||
cfg := lanesConfig()
|
||||
cfg.BrowserWSURL = "ws://browser:9222"
|
||||
router, st := newWebTestServer(t, cfg)
|
||||
seedPass(t, st, store.LanePass{
|
||||
Site: "kagane", RanAt: time.Now().UnixMilli(),
|
||||
Skip: latest.SkipAsleep, Due: 1, Checked: 0, GapMS: 10_000,
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
body := rr.Body.String()
|
||||
body := lanesBody(t, router, st)
|
||||
if strings.Contains(body, "not checking") {
|
||||
t.Errorf("an asleep browser Lane is marked as stalled:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "browser asleep") {
|
||||
t.Errorf("an asleep browser Lane says nothing about why it read nothing:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, `class="attention"`) {
|
||||
if strings.Contains(body, `class="trow attention"`) {
|
||||
t.Errorf("an asleep browser Lane is coloured as unhealthy:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A Lane whose pass never reached a figure must not have that figure drawn as
|
||||
// a zero: a refusing Lane still reports the due count and gap its last real
|
||||
// pass saw, and a Lane that has never reached one omits it entirely.
|
||||
// a zero: a refusing Lane that has never gathered figures draws "—" for the
|
||||
// gap rather than stating a zero it did not measure.
|
||||
func TestLaneStatusOmitsUnknownGap(t *testing.T) {
|
||||
lanes := fakeLanes{latest.Status{
|
||||
Lanes: []latest.LaneState{{Site: "comix", LastRun: time.Now(), Refusing: true, Browser: true}},
|
||||
BrowserConfigured: true,
|
||||
BrowserReachable: true,
|
||||
}}
|
||||
router, st, _ := oauthWebTestServer(t, lanes)
|
||||
cfg := lanesConfig()
|
||||
cfg.BrowserWSURL = "ws://browser:9222"
|
||||
st, dsn := newTestStoreURL(t)
|
||||
router := newRouter(st, cfg)
|
||||
now := time.Now()
|
||||
oldNow := now.Add(-time.Minute).UnixMilli()
|
||||
seedPass(t, st, store.LanePass{Site: "comix", RanAt: oldNow, Skip: latest.SkipRefusing})
|
||||
// Refusal expiry lives on the Lane (poll_lanes), not the pass row, so it
|
||||
// must be seeded there for the backs-off-until clause to render.
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(`INSERT INTO poll_lanes (site, refuse_until) VALUES ($1, $2)`, "comix", now.Add(10*time.Minute).UnixMilli()); err != nil {
|
||||
t.Fatalf("seed lane refusal: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
body := rr.Body.String()
|
||||
if strings.Contains(body, "gap 0s") {
|
||||
body := lanesBody(t, router, st)
|
||||
if strings.Contains(body, `>0s<`) {
|
||||
t.Errorf("a Lane with no pace yet states a zero gap:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "refusing") {
|
||||
t.Errorf("a refusing Lane is not marked as such:\n%s", body)
|
||||
if !strings.Contains(body, "refusing · backs off until") {
|
||||
t.Errorf("a refusing Lane is not marked with its backoff time:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// No poller and a poller that has not finished a pass both render "no data
|
||||
// yet" rather than zeroes that read as a stopped backend — but they are not
|
||||
// the same fact, so the page must not blame the sidecar when nothing polls.
|
||||
// Polling switched off and a browser not configured are different facts, and
|
||||
// the page must not blame the sidecar when nothing polls. Neither is a poller
|
||||
// running — the Lanes page answers entirely from config and the pass log.
|
||||
func TestAdminPageWithoutAPollerSaysSo(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
lanes []web.LaneReporter
|
||||
cfg Config
|
||||
want, unwant string
|
||||
}{
|
||||
{"no poller", nil, "Polling is switched off", "not configured"},
|
||||
{"poller, no pass yet", []web.LaneReporter{fakeLanes{}}, "not configured", "Polling is switched off"},
|
||||
{"polling switched off", testConfig(), "Polling is switched off", "not configured"},
|
||||
{"browser not configured", lanesConfig(), "not configured", "Polling is switched off"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
router, st, _ := oauthWebTestServer(t, tc.lanes...)
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/lanes", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
body := rr.Body.String()
|
||||
router, st := newWebTestServer(t, tc.cfg)
|
||||
body := lanesBody(t, router, st)
|
||||
if !strings.Contains(body, "No data yet") {
|
||||
t.Errorf("admin page with no Lane data does not say so:\n%s", body)
|
||||
}
|
||||
@@ -895,12 +904,133 @@ func TestAdminPageWithoutAPollerSaysSo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Restart survival is the point of the durable lane state: rows seeded into
|
||||
// poll_passes render with no poller running anywhere, complete thirty seconds
|
||||
// after a deploy. This is the test that proves the in-memory path is gone.
|
||||
func TestLanesRenderFromSeededRowsAfterRestart(t *testing.T) {
|
||||
router, st := newWebTestServer(t, lanesConfig())
|
||||
now := time.Now()
|
||||
// A pass a minute ago, another three hours ago: the latest per Site wins.
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-3 * time.Hour).UnixMilli(), Due: 1, Checked: 1, GapMS: 10_000})
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-time.Minute).UnixMilli(), Due: 5, Checked: 5, GapMS: 20_000})
|
||||
|
||||
body := lanesBody(t, router, st)
|
||||
if !strings.Contains(body, `class="c-site">asura</span>`) {
|
||||
t.Fatalf("asura row missing from a restart-read database:\n%s", body)
|
||||
}
|
||||
// The fragment carries its own single timer and answers the swap it asked
|
||||
// for, so the page keeps refreshing against the database.
|
||||
if !strings.Contains(body, `hx-get="/ui/admin/lanes"`) || !strings.Contains(body, `hx-trigger="every 30s"`) {
|
||||
t.Errorf("Lanes fragment lost its self-refresh:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A skip reason is the whole difference between a Lane resting and a Lane
|
||||
// stuck: a skipped pass says why it declined, and the one true stall — empty
|
||||
// skip with Series due and none read — is the only thing that draws the fault.
|
||||
func TestSkipReasonIsNotAStall(t *testing.T) {
|
||||
router, st := newWebTestServer(t, lanesConfig())
|
||||
now := time.Now()
|
||||
// Asleep: due but none checked, with a skip that says why — no stall, no
|
||||
// attention.
|
||||
seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.UnixMilli(), Skip: latest.SkipAsleep, Due: 2, Checked: 0, GapMS: 10_000})
|
||||
// The true stall: reached the loop, Series waiting, none read.
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Due: 3, Checked: 0, GapMS: 20_000})
|
||||
|
||||
body := lanesBody(t, router, st)
|
||||
if !strings.Contains(body, "browser asleep") {
|
||||
t.Errorf("the asleep row does not say why it declined:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "not checking") {
|
||||
t.Errorf("the true stall is not rendered as a fault:\n%s", body)
|
||||
}
|
||||
// Exactly the stall row wears attention; the asleep row never does.
|
||||
if strings.Count(body, `class="trow attention"`) != 1 {
|
||||
t.Errorf("attention is on %d rows, want exactly the stall:\n%s", strings.Count(body, `class="trow attention"`), body)
|
||||
}
|
||||
}
|
||||
|
||||
// The five outcome counts render named — the page never prints the word
|
||||
// "failures" — and a Site with none observed says so rather than drawing a
|
||||
// blank cell.
|
||||
func TestNamedOutcomeChips(t *testing.T) {
|
||||
router, st := newWebTestServer(t, lanesConfig())
|
||||
now := time.Now()
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Refused: 5, NoChapter: 2, Errors: 1})
|
||||
seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.UnixMilli(), Unreachable: 3, Unfetchable: 4})
|
||||
seedPass(t, st, store.LanePass{Site: "comix", RanAt: now.UnixMilli()})
|
||||
|
||||
body := lanesBody(t, router, st)
|
||||
for _, want := range []string{"refused 5", "no chapter 2", "errors 1", "unreachable 3", "unfetchable 4"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("lane chips lack %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "failures") {
|
||||
t.Errorf("the page prints the forbidden word \"failures\":\n%s", body)
|
||||
}
|
||||
// comix has no outcomes in the window: it must say none observed, and the
|
||||
// phrase must not be blank.
|
||||
if !strings.Contains(body, "none observed") {
|
||||
t.Errorf("a Site with no outcomes does not say none observed:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// Browser configuration is a deployment fact and reachability is derived from
|
||||
// the latest browser-Site passes inside the refusal backoff — no poller in any
|
||||
// of the three.
|
||||
func TestBrowserConfigAndReachabilityDerived(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
t.Run("not configured", func(t *testing.T) {
|
||||
router, st := newWebTestServer(t, lanesConfig())
|
||||
seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.UnixMilli(), Skip: latest.SkipNoFetcher})
|
||||
body := lanesBody(t, router, st)
|
||||
if !strings.Contains(body, "not configured") || strings.Contains(body, "unreachable") {
|
||||
t.Errorf("unset BROWSER_WS_URL must read as not configured:\n%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unreachable from recent sidecar-down", func(t *testing.T) {
|
||||
cfg := lanesConfig()
|
||||
cfg.BrowserWSURL = "ws://browser:9222"
|
||||
router, st := newWebTestServer(t, cfg)
|
||||
seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.Add(-time.Minute).UnixMilli(), Skip: latest.SkipSidecarDown})
|
||||
body := lanesBody(t, router, st)
|
||||
if !strings.Contains(body, "unreachable") {
|
||||
t.Errorf("recent sidecar-down passes must read as unreachable:\n%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reachable from clean passes", func(t *testing.T) {
|
||||
cfg := lanesConfig()
|
||||
cfg.BrowserWSURL = "ws://browser:9222"
|
||||
router, st := newWebTestServer(t, cfg)
|
||||
seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.Add(-time.Minute).UnixMilli(), Checked: 3})
|
||||
body := lanesBody(t, router, st)
|
||||
if !strings.Contains(body, "reachable") {
|
||||
t.Errorf("clean browser passes must read as reachable:\n%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sidecar-down outside the backoff is reachable again", func(t *testing.T) {
|
||||
cfg := lanesConfig()
|
||||
cfg.BrowserWSURL = "ws://browser:9222"
|
||||
router, st := newWebTestServer(t, cfg)
|
||||
seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.Add(-30 * time.Minute).UnixMilli(), Skip: latest.SkipSidecarDown})
|
||||
body := lanesBody(t, router, st)
|
||||
if !strings.Contains(body, "reachable") {
|
||||
t.Errorf("a sidecar-down older than the backoff must read as reachable:\n%s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A Reader past the disagreement threshold is rendered as blocked, and
|
||||
// clearing their marks both zeroes the counters and lifts the block in the
|
||||
// roster the response carries back.
|
||||
func TestOwnerClearsReaderMarks(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
router := newRouter(st, testConfig(), nil)
|
||||
router := newRouter(st, testConfig())
|
||||
cookie := sessionCookie(t, st)
|
||||
// The counters are filled by issue #103; until it lands the only way to
|
||||
// stand a marked Reader up is to write the columns directly.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# ADR-0012: Persisted lane state
|
||||
|
||||
Date: 2026-08-21
|
||||
Status: accepted
|
||||
|
||||
Supersedes the in-memory lane snapshot carried by `latest`'s `LaneState`/`Status`
|
||||
and the `web.LaneReporter` seam (ADR-0010 wrote the durable rows this page now
|
||||
reads).
|
||||
|
||||
## Decision
|
||||
|
||||
The admin Lanes page stops reading the poller's in-memory Lane state and
|
||||
becomes a read of `poll_passes`/`poll_lanes` in Postgres. There is no
|
||||
`LaneReporter` interface: `web/admin_lanes.go` walks `store.LatestLanePasses()`
|
||||
into one row per Site and adds the window's outcome sums from
|
||||
`store.LanePassOutcomes()`. The `latest` package's `LaneState`/`Status` snapshot
|
||||
and its `web.LaneReporter` seam are deleted.
|
||||
|
||||
The browser is a deployment configuration fact plus a reachability derived
|
||||
from the pass log: `BROWSER_WS_URL` set means "configured", and the browser is
|
||||
"reachable" unless a recent browser-Site pass inside `latest.RefuseBackoff` is
|
||||
a sidecar loss, a missing fetcher, or an interrupted read. A skip reason is
|
||||
the whole difference between a Lane resting and a Lane stuck: a skipped pass
|
||||
prints its sentence, and only an empty skip with Series due and none read
|
||||
draws the true-stall fault. Sleep skips never count toward `Attention`.
|
||||
|
||||
## Why
|
||||
|
||||
The old page lived on a poller snapshot. Because that state was in memory, a
|
||||
deploy erased it: the page read zeroes until a fresh pass ran, and browser
|
||||
reachability came through a reporter interface only a live poller could
|
||||
serve. Making the page answer from the database means a restart is complete
|
||||
the instant the store is up, the browser fact survives a poller restart, and
|
||||
a Lane that has not yet gathered figures shows a placeholder rather than a
|
||||
confident zero.
|
||||
|
||||
## Constraints
|
||||
|
||||
The poller still owns the writes: each pass exit records one row (ADR-0010),
|
||||
and a pass that returns before gathering figures carries the previous pass's
|
||||
numbers forward instead of recording zeroes. A skip is a stable wire string;
|
||||
`asleep` never counts toward `Attention`. When polling is switched off the
|
||||
page must say so, and the browser statusline appears only when a poller
|
||||
actually answers.
|
||||
Reference in New Issue
Block a user