Merge ticket/145-lanes-from-db
This commit is contained in:
+11
-9
@@ -134,7 +134,7 @@ unchanged read is exactly the Sighting worth deferring a Poll on.
|
|||||||
rest.
|
rest.
|
||||||
|
|
||||||
**Refusals and browser loss are Lane-local.** Two `errChallengeHeld` in a pass
|
**Refusals and browser loss are Lane-local.** Two `errChallengeHeld` in a pass
|
||||||
stop that Site for `refuseBackoff` while other Lanes continue. An
|
stop that Site for `RefuseBackoff` while other Lanes continue. An
|
||||||
`errBrowserInterrupted` (remote Chrome restarted) sets a shared Poller flag so
|
`errBrowserInterrupted` (remote Chrome restarted) sets a shared Poller flag so
|
||||||
the *other* browser Lanes skip their passes for the same window — otherwise a
|
the *other* browser Lanes skip their passes for the same window — otherwise a
|
||||||
restarting Chrome stamps one Series per Lane per pass, burning rests on
|
restarting Chrome stamps one Series per Lane per pass, burning rests on
|
||||||
@@ -252,16 +252,18 @@ keep warning to reinstall on all devices.
|
|||||||
(`view.Owner = readerID == h.store.OwnerID()`): it gates a link, not an
|
(`view.Owner = readerID == h.store.OwnerID()`): it gates a link, not an
|
||||||
endpoint, so it is a rendering decision a registration-time wrapper cannot
|
endpoint, so it is a rendering decision a registration-time wrapper cannot
|
||||||
express. Do not "unify" it into the gate.
|
express. Do not "unify" it into the gate.
|
||||||
- Lane figures come through the `web.LaneReporter` seam
|
- The Lanes page reads the pass log, never a running poller: `lanesView()` in
|
||||||
(`latest.Poller.LaneStatus`), never a table. `main.newRouter` takes the
|
`admin_lanes.go` projects `store.LatestLanePasses()` and
|
||||||
reporter as an interface and converts a nil `*Poller` to a nil interface — a
|
`store.LanePassOutcomes()` (ADR-0012), so a restart answers the instant the
|
||||||
typed nil would make the page claim a poller exists.
|
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)
|
- A pass that returns before computing figures (refusal backoff, sidecar down)
|
||||||
carries the previous pass's numbers forward rather than recording zeroes.
|
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**,
|
- **`Checked` next to `Due` is what separates a stopped Lane from a quiet one**,
|
||||||
so neither may be dropped from the row.
|
so neither may be dropped from the row.
|
||||||
- Due-without-Checked is **not** by itself a stall: a browser Lane under both
|
- Due-without-Checked is **not** by itself a stall: a browser Lane under both
|
||||||
wake thresholds sets `LaneState.Asleep` and renders "browser asleep", and
|
wake thresholds records its pass with the `SkipAsleep` skip and renders
|
||||||
never counts toward `Attention`. That is the commonest healthy state for
|
"browser asleep", and that never counts toward `Attention`. It is the
|
||||||
kagane, comix and novelfull, so spending the stall mark on it would train the
|
commonest healthy state for kagane, comix and novelfull, so spending the
|
||||||
owner to ignore the mark that matters.
|
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 {
|
func newTestServer(t *testing.T) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
return newRouter(newTestStore(t), testConfig(), nil)
|
return newRouter(newTestStore(t), testConfig())
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestStore(t *testing.T) *store.Store {
|
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.
|
// cooldown and the poller would re-fetch that series on every single tick.
|
||||||
func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
|
func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
srv := newRouter(s, testConfig(), nil)
|
srv := newRouter(s, testConfig())
|
||||||
|
|
||||||
seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", 777)
|
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.
|
// series stops being due the moment the PUT lands.
|
||||||
func TestPutRecordsASighting(t *testing.T) {
|
func TestPutRecordsASighting(t *testing.T) {
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
srv := newRouter(s, testConfig(), nil)
|
srv := newRouter(s, testConfig())
|
||||||
|
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
hour := time.Hour.Milliseconds()
|
hour := time.Hour.Milliseconds()
|
||||||
@@ -677,7 +677,7 @@ func TestUserscriptServedWithWebUIDisabled(t *testing.T) {
|
|||||||
|
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodGet, "/u/"+ownerCredential()+"/manga-bookmark.user.js", nil)
|
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 {
|
if rr.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200", rr.Code)
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
}
|
}
|
||||||
@@ -699,7 +699,7 @@ func TestNovelUserscriptServed(t *testing.T) {
|
|||||||
|
|
||||||
cfg := testConfig()
|
cfg := testConfig()
|
||||||
cfg.NovelUserscriptPath = novelPath
|
cfg.NovelUserscriptPath = novelPath
|
||||||
srv := newRouter(s, cfg, nil)
|
srv := newRouter(s, cfg)
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
|
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 {
|
if _, err := db.Exec(`UPDATE covers SET content_type = 'text/html' WHERE address = $1`, address); err != nil {
|
||||||
t.Fatalf("poison row: %v", err)
|
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 {
|
if rr.Code == http.StatusOK {
|
||||||
t.Fatalf("status = 200, want a refusal for a non-image row (body %q)", rr.Body.String())
|
t.Fatalf("status = 200, want a refusal for a non-image row (body %q)", rr.Body.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,16 +56,13 @@ type Poller struct {
|
|||||||
|
|
||||||
// refuseUntil gates a Site's Lane after it refused twice in one run: no
|
// 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).
|
// Series of that Site is attempted again before this time (issue #100).
|
||||||
|
// The stamp is durable — the pass gate reads it from the store, so a
|
||||||
|
// restart does not forget the refusal; nothing of it lives in memory.
|
||||||
// browserDownAt is when a browser Lane last lost the sidecar; the other
|
// 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).
|
// restarting Chrome does not stamp one Series per pass per Lane (story 20).
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
refuseUntil map[string]time.Time
|
|
||||||
browserDownAt 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
|
// 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
|
// slow cover host cannot delay the next Series-page Poll; tests join it
|
||||||
// before asserting on cover fetches.
|
// before asserting on cover fetches.
|
||||||
@@ -218,14 +215,16 @@ func (p *Poller) runOnce(ctx context.Context) {
|
|||||||
// empty means the pass reached the loop. The values are wire strings — stored
|
// 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.
|
// in poll_passes and read by the Lanes page — so they are stable, not prose.
|
||||||
const (
|
const (
|
||||||
skipPaused = "paused" // the pause row was read at the top
|
// Exported so the web layer renders a skip's reason without retyping the
|
||||||
skipRefusing = "refusing" // refusal backoff
|
// wire string (issue #145); the values are storage and page-stable.
|
||||||
skipSidecarDown = "sidecar-down" // a sibling browser Lane lost Chrome
|
SkipPaused = "paused" // the pause row was read at the top
|
||||||
skipNoFetcher = "no-fetcher" // browser Site, no browser configured, no fallback
|
SkipRefusing = "refusing" // refusal backoff
|
||||||
skipDueQuery = "due-query" // the due query failed
|
SkipSidecarDown = "sidecar-down" // a sibling browser Lane lost Chrome
|
||||||
skipAsleep = "asleep" // under both browser wake thresholds
|
SkipNoFetcher = "no-fetcher" // browser Site, no browser configured, no fallback
|
||||||
skipEligibleCount = "eligible-count" // the eligible count failed
|
SkipDueQuery = "due-query" // the due query failed
|
||||||
skipNothingEligible = "nothing-eligible" // nothing eligible; sleeps a full rest
|
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
|
// readOutcome classifies one Series read for the pass row's outcome counts
|
||||||
@@ -288,14 +287,12 @@ const lanePassRetention = 14 * 24 * time.Hour
|
|||||||
// production Lane's rate limit; the deterministic test entry runs back to back.
|
// 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 {
|
func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.Duration {
|
||||||
now := p.Now()
|
now := p.Now()
|
||||||
// Snapshot this pass for the owner's page (issue #102). Recorded on every
|
// Durable pass log (issue #141): one row per exit. The figures are filled
|
||||||
// return path, with the figures filled in where the pass computes them.
|
// in as the pass measures them; a pass that returns before measuring
|
||||||
st := LaneState{Site: name, LastRun: now, Browser: isBrowserSite(name)}
|
// carries the previous pass's forward inside recordPass.
|
||||||
defer func() { p.recordLaneState(st) }()
|
fig := passFigures{}
|
||||||
// Durable pass log (issue #141): one row per exit, written from the same
|
|
||||||
// snapshot so the two recordings cannot disagree.
|
|
||||||
rec := passRecord{site: name, ranAt: now.UnixMilli()}
|
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).
|
// 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
|
// Both stamps outlive our process, so the gates read the durable row
|
||||||
@@ -310,24 +307,24 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
|||||||
if pausedUntil > now.UnixMilli() {
|
if pausedUntil > now.UnixMilli() {
|
||||||
// Paused ahead of the refusal check: no Series is touched, so the
|
// Paused ahead of the refusal check: no Series is touched, so the
|
||||||
// queue stays intact for when the pause lifts (issue #141, #147).
|
// 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))
|
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
|
return time.Duration(pausedUntil-now.UnixMilli()) * time.Millisecond
|
||||||
}
|
}
|
||||||
if refuseUntil > now.UnixMilli() {
|
if refuseUntil > now.UnixMilli() {
|
||||||
// Cooling down after a refusal: do not attempt this Site at all.
|
// 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
|
return time.Duration(refuseUntil-now.UnixMilli()) * time.Millisecond
|
||||||
}
|
}
|
||||||
if isBrowserSite(name) {
|
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
|
// A sibling browser Lane lost the sidecar within the backoff
|
||||||
// window: skip this pass, so a restarting Chrome does not stamp
|
// 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).
|
// 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)
|
log.Printf("latest poll %s: browser lane skipping pass (sidecar down %s ago)", name, downFor)
|
||||||
return refuseBackoff - downFor
|
return RefuseBackoff - downFor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s := sites[name]
|
s := sites[name]
|
||||||
@@ -336,27 +333,27 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
|||||||
// No fetcher at all right now (browser absent, no fallback): every
|
// No fetcher at all right now (browser absent, no fallback): every
|
||||||
// Series stays unstamped and due, so a browser that appears after a
|
// Series stays unstamped and due, so a browser that appears after a
|
||||||
// restart finds its full queue waiting (issue #100).
|
// restart finds its full queue waiting (issue #100).
|
||||||
rec.skip = skipNoFetcher
|
rec.skip = SkipNoFetcher
|
||||||
st.Gap = defaultGap
|
fig.Gap = defaultGap
|
||||||
return defaultGap
|
return defaultGap
|
||||||
}
|
}
|
||||||
|
|
||||||
due, err := p.Store.DueForLatestCheck(name, now.Add(-s.Rest).UnixMilli(),
|
due, err := p.Store.DueForLatestCheck(name, now.Add(-s.Rest).UnixMilli(),
|
||||||
now.Add(-sightingCeilingRests*s.Rest).UnixMilli())
|
now.Add(-sightingCeilingRests*s.Rest).UnixMilli())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rec.skip = skipDueQuery
|
rec.skip = SkipDueQuery
|
||||||
log.Printf("latest poll %s: due query: %v", name, err)
|
log.Printf("latest poll %s: due query: %v", name, err)
|
||||||
st.Gap = defaultGap
|
fig.Gap = defaultGap
|
||||||
return defaultGap
|
return defaultGap
|
||||||
}
|
}
|
||||||
st.Due = len(due)
|
fig.Due = len(due)
|
||||||
if s.Browser != nil && f == p.BrowserFetch && !browserWakeDue(due, now, s.Rest) {
|
if s.Browser != nil && f == p.BrowserFetch && !browserWakeDue(due, now, s.Rest) {
|
||||||
// Below both thresholds Chrome stays asleep (ADR-0005 on-demand
|
// Below both thresholds Chrome stays asleep (ADR-0005 on-demand
|
||||||
// browser): waking it for a single Poll would cost a challenge solve
|
// 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
|
// per request. The Lane still paces at the default gap, which is what
|
||||||
// the owner's page must show rather than a zero.
|
// the owner's page must show rather than a zero.
|
||||||
rec.skip = skipAsleep
|
rec.skip = SkipAsleep
|
||||||
st.Gap, st.Asleep = defaultGap, true
|
fig.Gap = defaultGap
|
||||||
return defaultGap
|
return defaultGap
|
||||||
}
|
}
|
||||||
if s.Browser != nil {
|
if s.Browser != nil {
|
||||||
@@ -371,20 +368,20 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
|||||||
|
|
||||||
eligible, err := p.countEligible(name)
|
eligible, err := p.countEligible(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rec.skip = skipEligibleCount
|
rec.skip = SkipEligibleCount
|
||||||
log.Printf("latest poll %s: eligible count: %v", name, err)
|
log.Printf("latest poll %s: eligible count: %v", name, err)
|
||||||
st.Gap = defaultGap
|
fig.Gap = defaultGap
|
||||||
return defaultGap
|
return defaultGap
|
||||||
}
|
}
|
||||||
gap, clamped := effectiveGap(s, eligible)
|
gap, clamped := effectiveGap(s, eligible)
|
||||||
st.Gap, st.Clamped = gap, clamped
|
fig.Gap, fig.Clamped = gap, clamped
|
||||||
if clamped {
|
if clamped {
|
||||||
log.Printf("latest poll %s: gap clamped to %s floor (eligible series=%d)", name, minGap, eligible)
|
log.Printf("latest poll %s: gap clamped to %s floor (eligible series=%d)", name, minGap, eligible)
|
||||||
}
|
}
|
||||||
if eligible == 0 {
|
if eligible == 0 {
|
||||||
// Nothing to poll for the foreseeable future; sleep a full rest instead
|
// Nothing to poll for the foreseeable future; sleep a full rest instead
|
||||||
// of re-querying every gap.
|
// of re-querying every gap.
|
||||||
rec.skip = skipNothingEligible
|
rec.skip = SkipNothingEligible
|
||||||
return s.Rest
|
return s.Rest
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,7 +392,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
|||||||
}
|
}
|
||||||
if refusals >= 2 {
|
if refusals >= 2 {
|
||||||
// This Site refused twice in a row: the remaining Series are left
|
// 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.
|
// trying it again.
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -418,7 +415,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
|||||||
// make it legible but is deliberately not invented here.
|
// make it legible but is deliberately not invented here.
|
||||||
rec.counts.add(outcome)
|
rec.counts.add(outcome)
|
||||||
p.setBrowserDown(now)
|
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
|
return gap
|
||||||
}
|
}
|
||||||
if outcome == outcomeRefused {
|
if outcome == outcomeRefused {
|
||||||
@@ -427,27 +424,35 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
|||||||
refusals = 0
|
refusals = 0
|
||||||
}
|
}
|
||||||
rec.counts.add(outcome)
|
rec.counts.add(outcome)
|
||||||
st.Checked++
|
fig.Checked++
|
||||||
}
|
}
|
||||||
if st.Checked > 0 {
|
if fig.Checked > 0 {
|
||||||
log.Printf("latest poll %s: due=%d checked=%d", name, len(due), st.Checked)
|
log.Printf("latest poll %s: due=%d checked=%d", name, len(due), fig.Checked)
|
||||||
}
|
}
|
||||||
if refusals >= 2 {
|
if refusals >= 2 {
|
||||||
p.setRefusalBackoff(name, now.Add(refuseBackoff))
|
|
||||||
// The refusal outlives the process: the durable stamp gates a restart,
|
// 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
|
// so a Site that just told us to back off is not re-probed.
|
||||||
// in-memory twin is still written because the Lane status block reads
|
if err := p.Store.SetLaneRefusal(name, now.Add(RefuseBackoff).UnixMilli()); err != nil {
|
||||||
// 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 {
|
|
||||||
log.Printf("latest poll %s: persist refusal: %v", name, err)
|
log.Printf("latest poll %s: persist refusal: %v", name, err)
|
||||||
}
|
}
|
||||||
log.Printf("latest poll %s: refused twice this run, waiting %s", name, refuseBackoff)
|
log.Printf("latest poll %s: refused twice this run, waiting %s", name, RefuseBackoff)
|
||||||
return refuseBackoff
|
return RefuseBackoff
|
||||||
}
|
}
|
||||||
return gap
|
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
|
// 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
|
// 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
|
// never computed its own figures — its gap is zero — carries the previous
|
||||||
@@ -455,15 +460,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
|
// did not measure; the skip column says why it declined, so the zeroes that
|
||||||
// remain (due-query, no-fetcher) read as explanations rather than
|
// remain (due-query, no-fetcher) read as explanations rather than
|
||||||
// measurements.
|
// measurements.
|
||||||
func (p *Poller) recordPass(rec passRecord, st LaneState) {
|
func (p *Poller) recordPass(rec passRecord, fig passFigures) {
|
||||||
row := store.LanePass{
|
row := store.LanePass{
|
||||||
Site: rec.site,
|
Site: rec.site,
|
||||||
RanAt: rec.ranAt,
|
RanAt: rec.ranAt,
|
||||||
Skip: rec.skip,
|
Skip: rec.skip,
|
||||||
Due: st.Due,
|
Due: fig.Due,
|
||||||
Checked: st.Checked,
|
Checked: fig.Checked,
|
||||||
GapMS: st.Gap.Milliseconds(),
|
GapMS: fig.Gap.Milliseconds(),
|
||||||
Clamped: st.Clamped,
|
Clamped: fig.Clamped,
|
||||||
Refused: rec.counts.refused,
|
Refused: rec.counts.refused,
|
||||||
Unreachable: rec.counts.unreachable,
|
Unreachable: rec.counts.unreachable,
|
||||||
NoChapter: rec.counts.noChapter,
|
NoChapter: rec.counts.noChapter,
|
||||||
@@ -495,15 +500,6 @@ func (p *Poller) countEligible(site string) (int, error) {
|
|||||||
return p.Store.EligibleSeriesCount(site)
|
return p.Store.EligibleSeriesCount(site)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Poller) setRefusalBackoff(name string, until time.Time) {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
if p.refuseUntil == nil {
|
|
||||||
p.refuseUntil = make(map[string]time.Time)
|
|
||||||
}
|
|
||||||
p.refuseUntil[name] = until
|
|
||||||
}
|
|
||||||
|
|
||||||
// setBrowserDown records when a browser Lane lost the sidecar. It is Poller
|
// setBrowserDown records when a browser Lane lost the sidecar. It is Poller
|
||||||
// state rather than pass state so the other browser Lanes see it too.
|
// state rather than pass state so the other browser Lanes see it too.
|
||||||
func (p *Poller) setBrowserDown(now time.Time) {
|
func (p *Poller) setBrowserDown(now time.Time) {
|
||||||
@@ -514,7 +510,7 @@ func (p *Poller) setBrowserDown(now time.Time) {
|
|||||||
|
|
||||||
// browserDownFor reports how long the sidecar has been down and that it is
|
// 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
|
// 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.
|
// fresh loss, Lanes probe again.
|
||||||
func (p *Poller) browserDownFor(now time.Time) (time.Duration, bool) {
|
func (p *Poller) browserDownFor(now time.Time) (time.Duration, bool) {
|
||||||
p.mu.Lock()
|
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
|
// 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).
|
// again. One hostile Site burns only its own Lane's budget (issue #100).
|
||||||
func TestRunOnceSiteRefusalSkipsRestOfLaneAndBacksOff(t *testing.T) {
|
func TestRunOnceSiteRefusalSkipsRestOfLaneAndBacksOff(t *testing.T) {
|
||||||
s, _ := newTestStore(t)
|
s, _ := newTestStore(t)
|
||||||
@@ -1391,11 +1391,9 @@ func TestBrowserLaneWakeThresholds(t *testing.T) {
|
|||||||
if got := browser.callCount(); got != 0 {
|
if got := browser.callCount(); got != 0 {
|
||||||
t.Fatalf("browser fetches with 3 freshly-due series = %d, want 0 (Chrome stays asleep)", got)
|
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-
|
// The skipped pass still records its row, carrying the due count it never
|
||||||
// Checked has to be distinguishable there from a Lane that has stopped.
|
// read; the Lanes page (issue #145) reads that row — see
|
||||||
if lane := laneByName(t, p, "kagane"); !lane.Asleep || lane.Due != 3 || lane.Checked != 0 {
|
// TestRunLanePassRecordsEveryExit/"asleep".
|
||||||
t.Fatalf("asleep kagane lane = %+v, want Asleep with 3 due and 0 checked", lane)
|
|
||||||
}
|
|
||||||
// 5 due crosses the count threshold.
|
// 5 due crosses the count threshold.
|
||||||
for i := 3; i < 5; i++ {
|
for i := 3; i < 5; i++ {
|
||||||
seed(i)
|
seed(i)
|
||||||
@@ -1404,9 +1402,6 @@ func TestBrowserLaneWakeThresholds(t *testing.T) {
|
|||||||
if got := browser.callCount(); got != 5 {
|
if got := browser.callCount(); got != 5 {
|
||||||
t.Fatalf("browser fetches with 5 due series = %d, want 5", got)
|
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.
|
// A single long-neglected series wakes the browser by age alone.
|
||||||
seedForCheck(t, s, "kagane:ancient", "https://kagane.to/series/ancient", 0)
|
seedForCheck(t, s, "kagane:ancient", "https://kagane.to/series/ancient", 0)
|
||||||
p.runOnce(context.Background())
|
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
|
// 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
|
// Lanes are skipped: every fetch would fail anyway, and their Series must not
|
||||||
// burn their stamps on a dead Chrome (issue #100).
|
// 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
|
// again. comix's Series is resting (stamped last round), so the probe
|
||||||
// falls to kagane — the only Lane with something due — and its fresh
|
// falls to kagane — the only Lane with something due — and its fresh
|
||||||
// loss re-gates the Lanes behind it.
|
// 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.
|
// The Lanes page (issue #145) reads the durable pass log, so the poller's
|
||||||
// Before any pass the snapshot is empty — a restart must render "no data
|
// only duty to it is that every return path writes its row; the snapshot it
|
||||||
// yet", not zeroes — and each pass records what it saw: the frozen clock,
|
// used to mirror into memory is gone. A refusing pass still records why it
|
||||||
// the due count and the pace, with refusal backoff derived at snapshot time.
|
// declined, and the latest row per Site is what the page renders — nothing
|
||||||
func TestLaneStatus(t *testing.T) {
|
// 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)
|
s, _ := newTestStore(t)
|
||||||
now := time.UnixMilli(5_000_000)
|
now := time.UnixMilli(5_000_000)
|
||||||
p := newTestPoller(t, s, &fakeFetcher{body: asuraSeriesFixture, status: 200}, now)
|
p := newTestPoller(t, s, &fakeFetcher{body: asuraSeriesFixture, status: 200}, now)
|
||||||
|
|
||||||
if st := p.LaneStatus(); len(st.Lanes) != 0 {
|
// No pass has run: the log is empty, which the page renders as "none
|
||||||
t.Fatalf("lanes before any pass = %d, want 0 (nothing has run)", len(st.Lanes))
|
// observed" rather than confident zeroes.
|
||||||
} else if st.BrowserConfigured || st.BrowserReachable {
|
if _, ok, err := s.LatestLanePass("asura"); err != nil || ok {
|
||||||
t.Fatalf("browser before any pass = configured=%v reachable=%v, want false without a browser fetcher", st.BrowserConfigured, st.BrowserReachable)
|
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)
|
seedForCheck(t, s, "asura:chronicles", "https://asurascans.com/series/chronicles", 0)
|
||||||
@@ -1630,66 +1614,33 @@ func TestLaneStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
p.runOnce(context.Background())
|
p.runOnce(context.Background())
|
||||||
|
|
||||||
st := p.LaneStatus()
|
// Both lanes wrote their rows: asura the pass it read; kagane the
|
||||||
var asura, kagane LaneState
|
// mid-loop double-refusal exit, which records an empty skip by design
|
||||||
for _, lane := range st.Lanes {
|
// with its two refused counts — the durable row is the whole record, and
|
||||||
switch lane.Site {
|
// the page reads it rather than a snapshot.
|
||||||
case "asura":
|
asura := latestPassFor(t, s, "asura")
|
||||||
asura = lane
|
if asura.Due != 1 || asura.Checked != 1 || asura.GapMS == 0 {
|
||||||
case "kagane":
|
t.Fatalf("asura row = %+v, want due 1 checked 1 with the Lane's pace", asura)
|
||||||
kagane = lane
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if st.Lanes[0].Site != "asura" {
|
kagane := latestPassFor(t, s, "kagane")
|
||||||
t.Fatalf("first lane = %q, want asura (snapshot sorted by Site)", st.Lanes[0].Site)
|
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)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A pass that declines to look (kagane is now in backoff) must not restate
|
// With the refusal now durable, the next pass declines ahead of the loop:
|
||||||
// the figures it never gathered as zeroes: the last real pass's due count
|
// it records the refusing skip and carries the previous pass's figures
|
||||||
// and pace stand until a pass replaces them.
|
// forward rather than restating zeroes it never gathered (the carry logic
|
||||||
|
// itself is driven in TestRunLanePassCarryForwardOnlyWhenGapZero).
|
||||||
before := kagane
|
before := kagane
|
||||||
if before.Due == 0 || before.Gap == 0 {
|
p.Now = func() time.Time { return now.Add(time.Minute) }
|
||||||
t.Fatalf("kagane after its refusing pass = %+v, want the figures that pass gathered", before)
|
|
||||||
}
|
|
||||||
p.runOnce(context.Background())
|
p.runOnce(context.Background())
|
||||||
for _, lane := range p.LaneStatus().Lanes {
|
again := latestPassFor(t, s, "kagane")
|
||||||
if lane.Site != "kagane" {
|
if again.Skip != SkipRefusing {
|
||||||
continue
|
t.Fatalf("kagane refusing pass skip = %q, want %q", again.Skip, SkipRefusing)
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if again.Due != before.Due || again.GapMS != before.GapMS || again.Checked != before.Checked {
|
||||||
// A lost sidecar reads as unreachable for the same window the Lanes skip.
|
t.Fatalf("kagane after a skipped pass = due %d gap %d, want the previous pass's %d / %d",
|
||||||
p.setBrowserDown(now)
|
again.Due, again.GapMS, before.Due, before.GapMS)
|
||||||
if st := p.LaneStatus(); !st.BrowserConfigured || st.BrowserReachable {
|
|
||||||
t.Fatalf("browser after loss = configured=%v reachable=%v, want true/false", st.BrowserConfigured, st.BrowserReachable)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1744,8 +1695,8 @@ func TestRunLanePassRecordsEveryExit(t *testing.T) {
|
|||||||
if got := readLatestCheckedAt(t, s, "asura:x"); got != 0 {
|
if got := readLatestCheckedAt(t, s, "asura:x"); got != 0 {
|
||||||
t.Fatalf("stamp while paused = %d, want 0 (Series stay due and unstamped)", got)
|
t.Fatalf("stamp while paused = %d, want 0 (Series stay due and unstamped)", got)
|
||||||
}
|
}
|
||||||
if pass := latestPassFor(t, s, "asura"); pass.Skip != skipPaused {
|
if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipPaused {
|
||||||
t.Fatalf("skip = %q, want %q", pass.Skip, skipPaused)
|
t.Fatalf("skip = %q, want %q", pass.Skip, SkipPaused)
|
||||||
}
|
}
|
||||||
if got := countPassRows(t, dbURL, "asura"); got != 1 {
|
if got := countPassRows(t, dbURL, "asura"); got != 1 {
|
||||||
t.Fatalf("pass rows = %d, want exactly 1", got)
|
t.Fatalf("pass rows = %d, want exactly 1", got)
|
||||||
@@ -1767,8 +1718,8 @@ func TestRunLanePassRecordsEveryExit(t *testing.T) {
|
|||||||
if browser.callCount() != 0 {
|
if browser.callCount() != 0 {
|
||||||
t.Fatalf("fetches while refusing = %d, want 0", browser.callCount())
|
t.Fatalf("fetches while refusing = %d, want 0", browser.callCount())
|
||||||
}
|
}
|
||||||
if pass := latestPassFor(t, s, "kagane"); pass.Skip != skipRefusing {
|
if pass := latestPassFor(t, s, "kagane"); pass.Skip != SkipRefusing {
|
||||||
t.Fatalf("skip = %q, want %q", pass.Skip, skipRefusing)
|
t.Fatalf("skip = %q, want %q", pass.Skip, SkipRefusing)
|
||||||
}
|
}
|
||||||
if got := countPassRows(t, dbURL, "kagane"); got != 1 {
|
if got := countPassRows(t, dbURL, "kagane"); got != 1 {
|
||||||
t.Fatalf("pass rows = %d, want exactly 1", got)
|
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 := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
|
||||||
p.BrowserFetch = browser
|
p.BrowserFetch = browser
|
||||||
p.setBrowserDown(now) // a sibling Lane lost Chrome within the backoff window
|
p.setBrowserDown(now) // a sibling Lane lost Chrome within the backoff window
|
||||||
if pace := p.runLanePass(context.Background(), "kagane", false); pace != refuseBackoff {
|
if pace := p.runLanePass(context.Background(), "kagane", false); pace != RefuseBackoff {
|
||||||
t.Fatalf("sidecar-down pace = %s, want %s", pace, refuseBackoff)
|
t.Fatalf("sidecar-down pace = %s, want %s", pace, RefuseBackoff)
|
||||||
}
|
}
|
||||||
if browser.callCount() != 0 {
|
if browser.callCount() != 0 {
|
||||||
t.Fatalf("fetches with the sidecar down = %d, want 0", browser.callCount())
|
t.Fatalf("fetches with the sidecar down = %d, want 0", browser.callCount())
|
||||||
}
|
}
|
||||||
if pass := latestPassFor(t, s, "kagane"); pass.Skip != skipSidecarDown {
|
if pass := latestPassFor(t, s, "kagane"); pass.Skip != SkipSidecarDown {
|
||||||
t.Fatalf("skip = %q, want %q", pass.Skip, skipSidecarDown)
|
t.Fatalf("skip = %q, want %q", pass.Skip, SkipSidecarDown)
|
||||||
}
|
}
|
||||||
if got := countPassRows(t, dbURL, "kagane"); got != 1 {
|
if got := countPassRows(t, dbURL, "kagane"); got != 1 {
|
||||||
t.Fatalf("pass rows = %d, want exactly 1", got)
|
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)
|
t.Fatalf("no-fetcher pace = %s, want %s", pace, defaultGap)
|
||||||
}
|
}
|
||||||
pass := latestPassFor(t, s, "comix")
|
pass := latestPassFor(t, s, "comix")
|
||||||
if pass.Skip != skipNoFetcher || pass.GapMS != defaultGap.Milliseconds() {
|
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)
|
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 {
|
if got := countPassRows(t, dbURL, "comix"); got != 1 {
|
||||||
t.Fatalf("pass rows = %d, want exactly 1", got)
|
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 {
|
if pace := p.runLanePass(context.Background(), "asura", false); pace != defaultGap {
|
||||||
t.Fatalf("due-query pace = %s, want %s", pace, defaultGap)
|
t.Fatalf("due-query pace = %s, want %s", pace, defaultGap)
|
||||||
}
|
}
|
||||||
if pass := latestPassFor(t, s, "asura"); pass.Skip != skipDueQuery {
|
if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipDueQuery {
|
||||||
t.Fatalf("skip = %q, want %q", pass.Skip, skipDueQuery)
|
t.Fatalf("skip = %q, want %q", pass.Skip, SkipDueQuery)
|
||||||
}
|
}
|
||||||
if got := countPassRows(t, dbURL, "asura"); got != 1 {
|
if got := countPassRows(t, dbURL, "asura"); got != 1 {
|
||||||
t.Fatalf("pass rows = %d, want exactly 1", got)
|
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())
|
t.Fatalf("fetches while Chrome is asleep = %d, want 0", browser.callCount())
|
||||||
}
|
}
|
||||||
pass := latestPassFor(t, s, "kagane")
|
pass := latestPassFor(t, s, "kagane")
|
||||||
if pass.Skip != skipAsleep || pass.Due != 3 || pass.GapMS != defaultGap.Milliseconds() {
|
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)
|
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 {
|
if got := countPassRows(t, dbURL, "kagane"); got != 1 {
|
||||||
t.Fatalf("pass rows = %d, want exactly 1", got)
|
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 {
|
if pace := p.runLanePass(context.Background(), "asura", false); pace != defaultGap {
|
||||||
t.Fatalf("eligible-count pace = %s, want %s", pace, defaultGap)
|
t.Fatalf("eligible-count pace = %s, want %s", pace, defaultGap)
|
||||||
}
|
}
|
||||||
if pass := latestPassFor(t, s, "asura"); pass.Skip != skipEligibleCount {
|
if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipEligibleCount {
|
||||||
t.Fatalf("skip = %q, want %q", pass.Skip, skipEligibleCount)
|
t.Fatalf("skip = %q, want %q", pass.Skip, SkipEligibleCount)
|
||||||
}
|
}
|
||||||
if got := countPassRows(t, dbURL, "asura"); got != 1 {
|
if got := countPassRows(t, dbURL, "asura"); got != 1 {
|
||||||
t.Fatalf("pass rows = %d, want exactly 1", got)
|
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 {
|
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)
|
t.Fatalf("nothing-eligible pace = %s, want a full rest %s", pace, sites["asura"].Rest)
|
||||||
}
|
}
|
||||||
if pass := latestPassFor(t, s, "asura"); pass.Skip != skipNothingEligible {
|
if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipNothingEligible {
|
||||||
t.Fatalf("skip = %q, want %q", pass.Skip, skipNothingEligible)
|
t.Fatalf("skip = %q, want %q", pass.Skip, SkipNothingEligible)
|
||||||
}
|
}
|
||||||
if got := countPassRows(t, dbURL, "asura"); got != 1 {
|
if got := countPassRows(t, dbURL, "asura"); got != 1 {
|
||||||
t.Fatalf("pass rows = %d, want exactly 1", got)
|
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.Now = func() time.Time { return now.Add(time.Minute) }
|
||||||
p.runLanePass(context.Background(), "kagane", false)
|
p.runLanePass(context.Background(), "kagane", false)
|
||||||
second := latestPassFor(t, s, "kagane")
|
second := latestPassFor(t, s, "kagane")
|
||||||
if second.Skip != skipRefusing {
|
if second.Skip != SkipRefusing {
|
||||||
t.Fatalf("second pass skip = %q, want %q", second.Skip, skipRefusing)
|
t.Fatalf("second pass skip = %q, want %q", second.Skip, SkipRefusing)
|
||||||
}
|
}
|
||||||
if second.Due != first.Due || second.Checked != first.Checked ||
|
if second.Due != first.Due || second.Checked != first.Checked ||
|
||||||
second.GapMS != first.GapMS || second.Clamped != first.Clamped {
|
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.Now = func() time.Time { return now.Add(time.Minute) }
|
||||||
p.runLanePass(context.Background(), "comix", false)
|
p.runLanePass(context.Background(), "comix", false)
|
||||||
second := latestPassFor(t, s, "comix")
|
second := latestPassFor(t, s, "comix")
|
||||||
if second.Skip != skipNoFetcher {
|
if second.Skip != SkipNoFetcher {
|
||||||
t.Fatalf("second pass skip = %q, want %q", second.Skip, skipNoFetcher)
|
t.Fatalf("second pass skip = %q, want %q", second.Skip, SkipNoFetcher)
|
||||||
}
|
}
|
||||||
if second.GapMS != defaultGap.Milliseconds() {
|
if second.GapMS != defaultGap.Milliseconds() {
|
||||||
t.Fatalf("second pass gap = %d, want its own %d (not carried)", 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 {
|
if got := fresh.BrowserFetch.(*fakeFetcher).callCount(); got != 0 {
|
||||||
t.Fatalf("fetches by a fresh poller inside the backoff = %d, want 0", got)
|
t.Fatalf("fetches by a fresh poller inside the backoff = %d, want 0", got)
|
||||||
}
|
}
|
||||||
if pass := latestPassFor(t, s, "kagane"); 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)
|
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
|
// 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);
|
// express (docs/research/cloudflare-bot-scoring-and-poll-cadence.md);
|
||||||
// below it the Lane is outrunning its own plan and says so loudly.
|
// below it the Lane is outrunning its own plan and says so loudly.
|
||||||
minGap = time.Second
|
minGap = time.Second
|
||||||
// refuseBackoff is how long a Lane waits after its Site refused twice in
|
// RefuseBackoff is how long a Lane waits after its Site refused twice in
|
||||||
// one run before attempting it again.
|
// one run before attempting it again. Exported so the web layer can derive
|
||||||
refuseBackoff = 15 * time.Minute
|
// 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
|
// 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
|
// more due Series, or any one of them waiting this long, or Chrome stays
|
||||||
// asleep (ADR-0005 on-demand browser).
|
// 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"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"bookmarkmanager/backend/internal/latest"
|
|
||||||
"bookmarkmanager/backend/internal/store"
|
"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
|
|
||||||
}
|
|
||||||
|
|
||||||
// ownerWindow is the staleness boundary the Series list's "not checked in
|
// ownerWindow is the staleness boundary the Series list's "not checked in
|
||||||
// 12h" filter compares against. Declared once; later admin tickets read it.
|
// 12h" filter compares against. Declared once; later admin tickets read it.
|
||||||
const ownerWindow = 12 * time.Hour
|
const ownerWindow = 12 * time.Hour
|
||||||
@@ -36,50 +27,6 @@ type adminView struct {
|
|||||||
Detail seriesDetailView
|
Detail seriesDetailView
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// adminRoute pairs a route pattern with its handler so the route list and the
|
||||||
// gate cannot drift apart.
|
// gate cannot drift apart.
|
||||||
type adminRoute struct {
|
type adminRoute struct {
|
||||||
@@ -134,11 +81,6 @@ func (h *Handler) admin(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.renderAdmin(w, adminView{Page: "overview"})
|
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.
|
// adminReaders renders the Reader roster on its own bookmarkable page.
|
||||||
func (h *Handler) adminReaders(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) adminReaders(w http.ResponseWriter, r *http.Request) {
|
||||||
readers, err := h.store.Readers()
|
readers, err := h.store.Readers()
|
||||||
@@ -154,65 +96,6 @@ func (h *Handler) renderAdmin(w http.ResponseWriter, view adminView) {
|
|||||||
h.render(w, http.StatusOK, "admin", view)
|
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
|
// revokeReaderSessions logs one Reader out of every browser they are signed in
|
||||||
// on. The owner gate is the route's, not this handler's.
|
// on. The owner gate is the route's, not this handler's.
|
||||||
func (h *Handler) revokeReaderSessions(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) revokeReaderSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
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)
|
||||||
|
// No evidence of a lost sidecar reads as reachable, per the same rule
|
||||||
|
// browserReachable applies: a store failure must not condemn the
|
||||||
|
// browser. The empty table already says no Lane has recorded a pass.
|
||||||
|
v.BrowserReachable = true
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
outcomes, err := h.store.LanePassOutcomes(now.Add(-ownerWindow).UnixMilli())
|
||||||
|
if err != nil {
|
||||||
|
// The rows are complete without the chips, so a failed outcome sum
|
||||||
|
// must not blank the table into "no data yet" — that is the confident
|
||||||
|
// wrong statement the page exists to avoid. Every row renders "none
|
||||||
|
// observed" instead, which is honest.
|
||||||
|
log.Printf("admin lanes: outcomes: %v", err)
|
||||||
|
outcomes = nil
|
||||||
|
}
|
||||||
|
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:
|
||||||
|
phrase = "due query failed"
|
||||||
|
attention = true
|
||||||
|
case latest.SkipEligibleCount:
|
||||||
|
phrase = "eligible count 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. */
|
/* 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 {
|
.admin-sheet .tbl {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(240px, 1fr) 156px 92px 110px 76px minmax(150px, 220px) 140px;
|
grid-template-columns: minmax(240px, 1fr) 156px 92px 110px 76px minmax(150px, 220px) 140px;
|
||||||
@@ -317,6 +340,23 @@
|
|||||||
color: var(--mute-2);
|
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 {
|
.admin-sheet .mark.bad {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
@@ -343,6 +383,10 @@
|
|||||||
color: var(--danger);
|
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 .thead > *:nth-child(n+2):nth-child(-n+5),
|
||||||
.admin-sheet .tbl.sites .trow > *:nth-child(n+2):nth-child(-n+5) {
|
.admin-sheet .tbl.sites .trow > *:nth-child(n+2):nth-child(-n+5) {
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
|
|||||||
@@ -1,42 +1,43 @@
|
|||||||
{{/* Poll Lane status: one row per Site, refreshing itself so a run can be
|
{{/* Poll Lane status: one row per Site's latest durable pass, refreshing
|
||||||
watched rather than sampled by reloading. The refresh is one attribute on
|
itself so a run can be watched rather than sampled by reloading. The
|
||||||
the fragment root and the endpoint answers with this same fragment, so the
|
refresh is one attribute on the fragment root and the endpoint answers
|
||||||
swap replaces the element that asked for it.
|
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:
|
Every figure is read from poll_passes, never from a running poller: a
|
||||||
a Site absent from Rows has not completed a pass since the last restart,
|
restart answers from the database the moment it is up (issue #145). The
|
||||||
which the empty state must say — zeroes would read as a stopped Lane. */}}
|
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"}}
|
{{define "lanes"}}
|
||||||
<section class="lanes" id="lanes"
|
<section class="lanes" id="lanes"
|
||||||
hx-get="/ui/admin/lanes" hx-trigger="every 30s" hx-swap="outerHTML">
|
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: <span class="mark-faint">off</span>
|
||||||
|
{{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 bad">unreachable</span>{{end}}{{end}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{{if .Rows}}
|
{{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}}
|
{{range .Rows}}
|
||||||
<li{{if .Attention}} class="attention"{{end}}>
|
<div class="trow{{if .Attention}} attention{{end}}">
|
||||||
<span class="lane-site">{{.Site}}</span>
|
<span class="c-site">{{.Site}}</span>
|
||||||
<span class="lane-fact">{{.Due}} due</span>
|
<span>{{.Due}}</span>
|
||||||
<span class="lane-fact">{{.Checked}} checked</span>
|
<span>{{.Checked}}</span>
|
||||||
<span class="lane-fact">ran {{.Ran}}</span>
|
<span>{{.Gap}}</span>
|
||||||
{{if .Gap}}<span class="lane-fact">gap {{.Gap}}</span>{{end}}
|
<span>ran {{.Ran}}</span>
|
||||||
{{if .Clamped}}<span class="lane-mark">gap at floor</span>{{end}}
|
<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>
|
||||||
{{if .Refusing}}<span class="lane-mark">refusing</span>{{end}}
|
<span class="c-ctrl"></span>
|
||||||
{{if .BrowserLost}}<span class="lane-mark">no browser</span>{{end}}
|
</div>
|
||||||
{{if .Stalled}}<span class="lane-mark">not checking</span>{{end}}
|
|
||||||
{{if .Asleep}}<span class="lane-mark">browser asleep</span>{{end}}
|
|
||||||
</li>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="setup-copy">No data yet — no Lane has completed a pass since the
|
<p class="empty">No data yet — no Lane has recorded a pass.</p>
|
||||||
backend started.</p>
|
|
||||||
{{end}}
|
{{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>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -50,9 +50,14 @@ type Handler struct {
|
|||||||
// httpClient is the plain stdlib client that talks to Discord. It is not
|
// httpClient is the plain stdlib client that talks to Discord. It is not
|
||||||
// an injected interface: tests point APIBase at a stub server instead.
|
// an injected interface: tests point APIBase at a stub server instead.
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
// lanes is the Poll Lane snapshot source the administrative page reads.
|
// pollerEnabled reports whether latest-chapter polling is switched on in
|
||||||
// Nil is a running deployment with no poller, not a bug.
|
// this deployment (LATEST_CHAPTER_POLL_ENABLED) and browserConfigured
|
||||||
lanes LaneReporter
|
// 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.
|
// 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
|
// New parses every template up front so a broken one kills the process at
|
||||||
// startup rather than the first request that touches it.
|
// startup rather than the first request that touches it.
|
||||||
//
|
//
|
||||||
// lanes is the administrative page's window onto the running Poller; nil means
|
// pollerEnabled and browserConfigured are deployment facts the composition
|
||||||
// nothing is polling, which the page reports rather than hides.
|
// root resolves from LATEST_CHAPTER_POLL_ENABLED and BROWSER_WS_URL: the Lanes
|
||||||
func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string, lanes LaneReporter) (*Handler, error) {
|
// 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")
|
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -127,7 +134,8 @@ func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, nove
|
|||||||
states: newOAuthStates(),
|
states: newOAuthStates(),
|
||||||
limiter: session.NewLoginLimiter(),
|
limiter: session.NewLoginLimiter(),
|
||||||
httpClient: &http.Client{Timeout: discordTimeout},
|
httpClient: &http.Client{Timeout: discordTimeout},
|
||||||
lanes: lanes,
|
pollerEnabled: pollerEnabled,
|
||||||
|
browserConfigured: browserConfigured,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-15
@@ -54,6 +54,11 @@ type Config struct {
|
|||||||
// /u/{token}/novel-bookmark.user.js. Same bindmount, second script: the
|
// /u/{token}/novel-bookmark.user.js. Same bindmount, second script: the
|
||||||
// two libraries are separate installs.
|
// two libraries are separate installs.
|
||||||
NovelUserscriptPath string
|
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 configures the background latest-chapter fetcher.
|
||||||
LatestPoll LatestPoll
|
LatestPoll LatestPoll
|
||||||
}
|
}
|
||||||
@@ -108,6 +113,7 @@ func loadConfig() Config {
|
|||||||
OwnerDiscordID: os.Getenv("OWNER_DISCORD_ID"),
|
OwnerDiscordID: os.Getenv("OWNER_DISCORD_ID"),
|
||||||
UserscriptPath: envOr("USERSCRIPT_PATH", "/userscript/manga-bookmark.user.js"),
|
UserscriptPath: envOr("USERSCRIPT_PATH", "/userscript/manga-bookmark.user.js"),
|
||||||
NovelUserscriptPath: envOr("NOVEL_USERSCRIPT_PATH", "/userscript/novel-bookmark.user.js"),
|
NovelUserscriptPath: envOr("NOVEL_USERSCRIPT_PATH", "/userscript/novel-bookmark.user.js"),
|
||||||
|
BrowserWSURL: os.Getenv("BROWSER_WS_URL"),
|
||||||
LatestPoll: loadLatestPoll(),
|
LatestPoll: loadLatestPoll(),
|
||||||
}
|
}
|
||||||
c.Discord = web.DiscordConfig{
|
c.Discord = web.DiscordConfig{
|
||||||
@@ -130,9 +136,10 @@ func loadConfig() Config {
|
|||||||
// preflight OPTIONS short-circuits before auth; /bookmarks* is auth-protected,
|
// preflight OPTIONS short-circuits before auth; /bookmarks* is auth-protected,
|
||||||
// /healthz is public.
|
// /healthz is public.
|
||||||
//
|
//
|
||||||
// lanes may be nil — polling disabled, or its client could not be built. The
|
// The web layer learns the deployment's poller and browser config from cfg —
|
||||||
// admin page reports that rather than pretending Lanes exist.
|
// nothing of the running poller is wired through here; the Lanes page reads
|
||||||
func newRouter(s *store.Store, cfg Config, lanes web.LaneReporter) http.Handler {
|
// the database (issue #145).
|
||||||
|
func newRouter(s *store.Store, cfg Config) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h := &api.Handler{Store: s}
|
h := &api.Handler{Store: s}
|
||||||
mux.HandleFunc("GET /healthz", api.Healthz)
|
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)
|
mux.Handle("/bookmarks/", auth)
|
||||||
|
|
||||||
// The browser UI is always registered; signing in is Discord OAuth, so
|
// 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),
|
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 {
|
if err != nil {
|
||||||
log.Fatalf("web handler: %v", err)
|
log.Fatalf("web handler: %v", err)
|
||||||
}
|
}
|
||||||
@@ -241,7 +251,7 @@ func main() {
|
|||||||
var browser latest.Fetcher
|
var browser latest.Fetcher
|
||||||
pollCtx, stopPoll := context.WithCancel(context.Background())
|
pollCtx, stopPoll := context.WithCancel(context.Background())
|
||||||
defer stopPoll()
|
defer stopPoll()
|
||||||
if ws := strings.TrimSpace(os.Getenv("BROWSER_WS_URL")); ws != "" {
|
if ws := strings.TrimSpace(cfg.BrowserWSURL); ws != "" {
|
||||||
bf, err := latest.NewBrowserFetcher(ws)
|
bf, err := latest.NewBrowserFetcher(ws)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("browser fetcher disabled: %v", err)
|
log.Printf("browser fetcher disabled: %v", err)
|
||||||
@@ -278,17 +288,14 @@ func main() {
|
|||||||
}
|
}
|
||||||
s.OnSeriesCreated = acq.Acquire
|
s.OnSeriesCreated = acq.Acquire
|
||||||
}
|
}
|
||||||
// A nil *Poller must not become a non-nil interface holding a nil pointer:
|
// The poller's only connection to the web layer is the database now: it is
|
||||||
// the admin page tests the reporter for nil to decide whether anything is
|
// started for its own sake, and the Lanes page reads the pass rows it
|
||||||
// polling at all.
|
// records (issue #145).
|
||||||
var lanes web.LaneReporter
|
startLatestPoller(pollCtx, s, cfg.LatestPoll, browser)
|
||||||
if poller := startLatestPoller(pollCtx, s, cfg.LatestPoll, browser); poller != nil {
|
|
||||||
lanes = poller
|
|
||||||
}
|
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":" + cfg.Port,
|
Addr: ":" + cfg.Port,
|
||||||
Handler: newRouter(s, cfg, lanes),
|
Handler: newRouter(s, cfg),
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,7 +343,8 @@ func newLatestPoller(s *store.Store, cfg LatestPoll, fetch, browser latest.Fetch
|
|||||||
// HTTP client cannot be built. Any problem here is logged and skipped: this
|
// HTTP client cannot be built. Any problem here is logged and skipped: this
|
||||||
// feature going missing degrades the service to userscript-only latest-chapter
|
// feature going missing degrades the service to userscript-only latest-chapter
|
||||||
// tracking, which is exactly how it behaved before. It returns the running
|
// tracking, which is exactly how it behaved before. It returns the running
|
||||||
// Poller, or nil when there is none — the admin page's Lane status reads it.
|
// Poller, or nil when there is none; the caller starts it for its own sake —
|
||||||
|
// the Lanes page reads the pass log, so no return value is wired anywhere.
|
||||||
func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll, browser latest.Fetcher) *latest.Poller {
|
func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll, browser latest.Fetcher) *latest.Poller {
|
||||||
if !cfg.Enabled {
|
if !cfg.Enabled {
|
||||||
log.Println("latest-chapter poller: disabled by config")
|
log.Println("latest-chapter poller: disabled by config")
|
||||||
|
|||||||
@@ -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
|
// A refused credential is refused however plausible it looks: only a hash the
|
||||||
// readers table holds authenticates anything.
|
// readers table holds authenticates anything.
|
||||||
func TestUnknownCredentialRejected(t *testing.T) {
|
func TestUnknownCredentialRejected(t *testing.T) {
|
||||||
srv := newRouter(newTestStore(t), testConfig(), nil)
|
srv := newRouter(newTestStore(t), testConfig())
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", readerCredential("never-registered")))
|
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", readerCredential("never-registered")))
|
||||||
@@ -69,7 +69,7 @@ func TestUnknownCredentialRejected(t *testing.T) {
|
|||||||
func TestPerReaderIsolation(t *testing.T) {
|
func TestPerReaderIsolation(t *testing.T) {
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
registerReader(t, s, "other-reader")
|
registerReader(t, s, "other-reader")
|
||||||
srv := newRouter(s, testConfig(), nil)
|
srv := newRouter(s, testConfig())
|
||||||
|
|
||||||
ownerKey := "asura:solo"
|
ownerKey := "asura:solo"
|
||||||
putBookmark(t, srv, ownerKey, store.Bookmark{
|
putBookmark(t, srv, ownerKey, store.Bookmark{
|
||||||
@@ -267,7 +267,7 @@ func TestRotateCredentialViaWebUI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
cfg := testConfig()
|
cfg := testConfig()
|
||||||
cfg.UserscriptPath = path
|
cfg.UserscriptPath = path
|
||||||
srv := newRouter(s, cfg, nil)
|
srv := newRouter(s, cfg)
|
||||||
|
|
||||||
oldCred := ownerCredential()
|
oldCred := ownerCredential()
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
|
|||||||
+223
-93
@@ -29,17 +29,13 @@ import (
|
|||||||
const testOwnerID = "owner-snowflake"
|
const testOwnerID = "owner-snowflake"
|
||||||
|
|
||||||
// newWebTestServer returns the full router plus the store behind it, so tests
|
// 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
|
// can seed rows and assert on what the handlers wrote back. The Lanes page
|
||||||
// reporter stands in for the running poller; omitted means none is running,
|
// reads the pass log (issue #145), so a test seeds store rows rather than
|
||||||
// which is what every test that is not about the admin page wants.
|
// standing in for a poller.
|
||||||
func newWebTestServer(t *testing.T, cfg Config, lanes ...web.LaneReporter) (http.Handler, *store.Store) {
|
func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *store.Store) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
var reporter web.LaneReporter
|
return newRouter(st, cfg), st
|
||||||
if len(lanes) > 0 {
|
|
||||||
reporter = lanes[0]
|
|
||||||
}
|
|
||||||
return newRouter(st, cfg, reporter), st
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sessionCookie mints a live session row for the owner and returns the cookie
|
// sessionCookie mints a live session row for the owner and returns the cookie
|
||||||
@@ -150,12 +146,12 @@ func discordConfig(stubURL string) web.DiscordConfig {
|
|||||||
|
|
||||||
// oauthWebTestServer returns the full router, its store, and a Discord stub
|
// oauthWebTestServer returns the full router, its store, and a Discord stub
|
||||||
// wired as the configured API — the starting point for sign-in tests.
|
// 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()
|
t.Helper()
|
||||||
stub, srv := newDiscordStub(t)
|
stub, srv := newDiscordStub(t)
|
||||||
cfg := testConfig()
|
cfg := testConfig()
|
||||||
cfg.Discord = discordConfig(srv.URL)
|
cfg.Discord = discordConfig(srv.URL)
|
||||||
router, st := newWebTestServer(t, cfg, lanes...)
|
router, st := newWebTestServer(t, cfg)
|
||||||
return router, st, stub
|
return router, st, stub
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,7 +415,7 @@ func TestDiscordLoginRefusesNonMember(t *testing.T) {
|
|||||||
cfg.Discord = discordConfig(srv.URL)
|
cfg.Discord = discordConfig(srv.URL)
|
||||||
cfg.Discord.RequiredRole = tc.require
|
cfg.Discord.RequiredRole = tc.require
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
router := newRouter(st, cfg, nil)
|
router := newRouter(st, cfg)
|
||||||
|
|
||||||
rr := completeSignIn(t, router, startSignIn(t, router))
|
rr := completeSignIn(t, router, startSignIn(t, router))
|
||||||
if rr.Code != http.StatusForbidden {
|
if rr.Code != http.StatusForbidden {
|
||||||
@@ -635,11 +631,37 @@ func TestOwnerRevokesAnotherReadersSessions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fakeLanes is the admin page's poller stand-in: one fixed snapshot, so the
|
// seedPass writes one durable pass row — the Lanes page's whole seam.
|
||||||
// page's tests need neither a poller nor a Site.
|
// The page renders from the database with no poller running at all (issue
|
||||||
type fakeLanes struct{ status latest.Status }
|
// #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
|
// Every admin address carries the same navigation, while the roster only lives
|
||||||
// on its own page and the other pages keep their shells independent.
|
// on its own page and the other pages keep their shells independent.
|
||||||
@@ -770,32 +792,22 @@ func TestAdminRoutesAreOwnerOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Lane block reports what the poller says, and marks the Lanes that need
|
// The Lane block reports what the pass log says, and marks the Lanes that
|
||||||
// attention — a clamped gap, a refusal, a Site whose pages can only be read
|
// need attention: a Site whose pages can only be read through a sidecar that
|
||||||
// through a sidecar that is not there, and a Lane with Series waiting that its
|
// is not there, and a Lane with Series waiting that its last pass did not
|
||||||
// last pass did not read.
|
// read. Rows come from seeded database rows — no poller runs anywhere.
|
||||||
func TestAdminPageShowsLaneStatus(t *testing.T) {
|
func TestAdminPageShowsLaneStatus(t *testing.T) {
|
||||||
lanes := fakeLanes{latest.Status{
|
cfg := lanesConfig()
|
||||||
Lanes: []latest.LaneState{
|
cfg.BrowserWSURL = "ws://browser:9222"
|
||||||
{Site: "asura", Due: 12, Checked: 12, LastRun: time.Now().Add(-90 * time.Second), Gap: 40 * time.Second},
|
router, st := newWebTestServer(t, cfg)
|
||||||
{Site: "kagane", Due: 3, Checked: 3, LastRun: time.Now().Add(-time.Minute), Gap: time.Minute, Browser: true},
|
now := time.Now()
|
||||||
{Site: "demonic", Due: 400, Checked: 400, LastRun: time.Now(), Gap: 8 * time.Second, Clamped: true},
|
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-90 * time.Second).UnixMilli(), Due: 12, Checked: 12, GapMS: 40_000})
|
||||||
{Site: "comix", Due: 7, LastRun: time.Now(), Gap: time.Minute, Browser: true},
|
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})
|
||||||
BrowserConfigured: true,
|
seedPass(t, st, store.LanePass{Site: "comix", RanAt: now.UnixMilli(), Due: 7, Checked: 0, GapMS: 60_000})
|
||||||
BrowserReachable: true,
|
|
||||||
}}
|
|
||||||
router, st, _ := oauthWebTestServer(t, lanes)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil)
|
body := lanesBody(t, router, st)
|
||||||
req.AddCookie(sessionCookie(t, st))
|
for _, want := range []string{"asura", "kagane", "demonic", "comix", "40s", "ran 1m30s ago", "not checking", "none observed", "reachable"} {
|
||||||
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"} {
|
|
||||||
if !strings.Contains(body, want) {
|
if !strings.Contains(body, want) {
|
||||||
t.Errorf("lane status lacks %q:\n%s", want, body)
|
t.Errorf("lane status lacks %q:\n%s", want, body)
|
||||||
}
|
}
|
||||||
@@ -811,78 +823,75 @@ func TestAdminPageShowsLaneStatus(t *testing.T) {
|
|||||||
|
|
||||||
// A browser Lane under both wake thresholds holds Chrome asleep (ADR-0005), so
|
// 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
|
// 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
|
// pass row records it with the asleep skip, and the page must render its
|
||||||
// looking, and spending it on the commonest healthy browser-Lane state trains
|
// sleeping sentence with no stall mark and no attention.
|
||||||
// them to ignore it.
|
|
||||||
func TestAsleepBrowserLaneIsNotMarkedStalled(t *testing.T) {
|
func TestAsleepBrowserLaneIsNotMarkedStalled(t *testing.T) {
|
||||||
lanes := fakeLanes{latest.Status{
|
cfg := lanesConfig()
|
||||||
Lanes: []latest.LaneState{
|
cfg.BrowserWSURL = "ws://browser:9222"
|
||||||
{Site: "kagane", Due: 1, LastRun: time.Now(), Gap: 10 * time.Second, Browser: true, Asleep: true},
|
router, st := newWebTestServer(t, cfg)
|
||||||
},
|
seedPass(t, st, store.LanePass{
|
||||||
BrowserConfigured: true,
|
Site: "kagane", RanAt: time.Now().UnixMilli(),
|
||||||
BrowserReachable: true,
|
Skip: latest.SkipAsleep, Due: 1, Checked: 0, GapMS: 10_000,
|
||||||
}}
|
})
|
||||||
router, st, _ := oauthWebTestServer(t, lanes)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil)
|
body := lanesBody(t, router, st)
|
||||||
req.AddCookie(sessionCookie(t, st))
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
body := rr.Body.String()
|
|
||||||
if strings.Contains(body, "not checking") {
|
if strings.Contains(body, "not checking") {
|
||||||
t.Errorf("an asleep browser Lane is marked as stalled:\n%s", body)
|
t.Errorf("an asleep browser Lane is marked as stalled:\n%s", body)
|
||||||
}
|
}
|
||||||
if !strings.Contains(body, "browser asleep") {
|
if !strings.Contains(body, "browser asleep") {
|
||||||
t.Errorf("an asleep browser Lane says nothing about why it read nothing:\n%s", body)
|
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)
|
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 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
|
// a zero: a refusing Lane that has never gathered figures draws "—" for the
|
||||||
// pass saw, and a Lane that has never reached one omits it entirely.
|
// gap rather than stating a zero it did not measure.
|
||||||
func TestLaneStatusOmitsUnknownGap(t *testing.T) {
|
func TestLaneStatusOmitsUnknownGap(t *testing.T) {
|
||||||
lanes := fakeLanes{latest.Status{
|
cfg := lanesConfig()
|
||||||
Lanes: []latest.LaneState{{Site: "comix", LastRun: time.Now(), Refusing: true, Browser: true}},
|
cfg.BrowserWSURL = "ws://browser:9222"
|
||||||
BrowserConfigured: true,
|
st, dsn := newTestStoreURL(t)
|
||||||
BrowserReachable: true,
|
router := newRouter(st, cfg)
|
||||||
}}
|
now := time.Now()
|
||||||
router, st, _ := oauthWebTestServer(t, lanes)
|
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)
|
body := lanesBody(t, router, st)
|
||||||
req.AddCookie(sessionCookie(t, st))
|
if strings.Contains(body, `>0s<`) {
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
body := rr.Body.String()
|
|
||||||
if strings.Contains(body, "gap 0s") {
|
|
||||||
t.Errorf("a Lane with no pace yet states a zero gap:\n%s", body)
|
t.Errorf("a Lane with no pace yet states a zero gap:\n%s", body)
|
||||||
}
|
}
|
||||||
if !strings.Contains(body, "refusing") {
|
if !strings.Contains(body, "refusing · backs off until") {
|
||||||
t.Errorf("a refusing Lane is not marked as such:\n%s", body)
|
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
|
// Polling switched off and a browser not configured are different facts, and
|
||||||
// yet" rather than zeroes that read as a stopped backend — but they are not
|
// the page must not blame the sidecar when nothing polls. Neither is a poller
|
||||||
// the same fact, so the page must not blame the sidecar when nothing polls.
|
// running — the Lanes page answers entirely from config and the pass log.
|
||||||
func TestAdminPageWithoutAPollerSaysSo(t *testing.T) {
|
func TestAdminPageWithoutAPollerSaysSo(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
lanes []web.LaneReporter
|
cfg Config
|
||||||
want, unwant string
|
want, unwant string
|
||||||
}{
|
}{
|
||||||
{"no poller", nil, "Polling is switched off", "not configured"},
|
{"polling switched off", testConfig(), `Polling: <span class="mark-faint">off</span>`, "not configured"},
|
||||||
{"poller, no pass yet", []web.LaneReporter{fakeLanes{}}, "not configured", "Polling is switched off"},
|
{"browser not configured", lanesConfig(), "not configured", "Polling is switched off"},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
router, st, _ := oauthWebTestServer(t, tc.lanes...)
|
router, st := newWebTestServer(t, tc.cfg)
|
||||||
req := httptest.NewRequest(http.MethodGet, "/admin/lanes", nil)
|
body := lanesBody(t, router, st)
|
||||||
req.AddCookie(sessionCookie(t, st))
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
body := rr.Body.String()
|
|
||||||
if !strings.Contains(body, "No data yet") {
|
if !strings.Contains(body, "No data yet") {
|
||||||
t.Errorf("admin page with no Lane data does not say so:\n%s", body)
|
t.Errorf("admin page with no Lane data does not say so:\n%s", body)
|
||||||
}
|
}
|
||||||
@@ -896,12 +905,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, `mark-faint">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, `bad">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, `mark-strong">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, `mark-strong">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
|
// A Reader past the disagreement threshold is rendered as blocked, and
|
||||||
// clearing their marks both zeroes the counters and lifts the block in the
|
// clearing their marks both zeroes the counters and lifts the block in the
|
||||||
// roster the response carries back.
|
// roster the response carries back.
|
||||||
func TestOwnerClearsReaderMarks(t *testing.T) {
|
func TestOwnerClearsReaderMarks(t *testing.T) {
|
||||||
st, dsn := newTestStoreURL(t)
|
st, dsn := newTestStoreURL(t)
|
||||||
router := newRouter(st, testConfig(), nil)
|
router := newRouter(st, testConfig())
|
||||||
cookie := sessionCookie(t, st)
|
cookie := sessionCookie(t, st)
|
||||||
// The counters are filled by issue #103; until it lands the only way to
|
// 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.
|
// stand a marked Reader up is to write the columns directly.
|
||||||
@@ -1943,7 +2073,7 @@ func TestSeriesListFilterWiring(t *testing.T) {
|
|||||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:healthy", url: "https://asurascans.com/comics/healthy", cover: "aaa", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(10), bookmarks: 1})
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:healthy", url: "https://asurascans.com/comics/healthy", cover: "aaa", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(10), bookmarks: 1})
|
||||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:nocover", url: "https://asurascans.com/comics/nocover", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(3), bookmarks: 1})
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:nocover", url: "https://asurascans.com/comics/nocover", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(3), bookmarks: 1})
|
||||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:stale", url: "https://asurascans.com/comics/stale", cover: "bbb", checkedAt: time.Now().Add(-24 * time.Hour).UnixMilli(), latestNum: floatPtr(4), bookmarks: 1})
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:stale", url: "https://asurascans.com/comics/stale", cover: "bbb", checkedAt: time.Now().Add(-24 * time.Hour).UnixMilli(), latestNum: floatPtr(4), bookmarks: 1})
|
||||||
srv := newRouter(st, testConfig(), nil)
|
srv := newRouter(st, testConfig())
|
||||||
|
|
||||||
body := adminSeriesPage(t, srv, st, "?filter=no_cover")
|
body := adminSeriesPage(t, srv, st, "?filter=no_cover")
|
||||||
if !strings.Contains(body, "Title of asura:nocover") {
|
if !strings.Contains(body, "Title of asura:nocover") {
|
||||||
@@ -1992,7 +2122,7 @@ func TestSeriesListSiteAndKindComposeWithFilter(t *testing.T) {
|
|||||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "kagane:novel", url: "u", cover: "c", kind: store.KindNovel, checkedAt: stale, latestNum: floatPtr(1), bookmarks: 1})
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "kagane:novel", url: "u", cover: "c", kind: store.KindNovel, checkedAt: stale, latestNum: floatPtr(1), bookmarks: 1})
|
||||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "kagane:fresh", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "kagane:fresh", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:stale", url: "u", cover: "c", checkedAt: stale, latestNum: floatPtr(1), bookmarks: 1})
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:stale", url: "u", cover: "c", checkedAt: stale, latestNum: floatPtr(1), bookmarks: 1})
|
||||||
srv := newRouter(st, testConfig(), nil)
|
srv := newRouter(st, testConfig())
|
||||||
|
|
||||||
body := adminSeriesPage(t, srv, st, "?filter=stale&site=kagane&kind=manga")
|
body := adminSeriesPage(t, srv, st, "?filter=stale&site=kagane&kind=manga")
|
||||||
if !strings.Contains(body, "Title of kagane:want") {
|
if !strings.Contains(body, "Title of kagane:want") {
|
||||||
@@ -2051,7 +2181,7 @@ func TestSeriesListPagingIsStable(t *testing.T) {
|
|||||||
FROM generate_series(1, 55) AS g`, st.OwnerID()); err != nil {
|
FROM generate_series(1, 55) AS g`, st.OwnerID()); err != nil {
|
||||||
t.Fatalf("bulk seed bookmarks: %v", err)
|
t.Fatalf("bulk seed bookmarks: %v", err)
|
||||||
}
|
}
|
||||||
srv := newRouter(st, testConfig(), nil)
|
srv := newRouter(st, testConfig())
|
||||||
|
|
||||||
pageKeys := func(body string) map[string]bool {
|
pageKeys := func(body string) map[string]bool {
|
||||||
out := map[string]bool{}
|
out := map[string]bool{}
|
||||||
@@ -2110,7 +2240,7 @@ func TestSeriesListPagePastEndReReadsAtPageOne(t *testing.T) {
|
|||||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:a", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:a", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
|
||||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:b", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:b", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
|
||||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:c", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:c", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
|
||||||
srv := newRouter(st, testConfig(), nil)
|
srv := newRouter(st, testConfig())
|
||||||
|
|
||||||
body := adminSeriesPage(t, srv, st, "?page=2")
|
body := adminSeriesPage(t, srv, st, "?page=2")
|
||||||
if !strings.Contains(body, "1–3 of 3") {
|
if !strings.Contains(body, "1–3 of 3") {
|
||||||
@@ -2137,7 +2267,7 @@ func TestSeriesListEmptyStateNamesTheFilter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:covered", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:covered", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
|
||||||
srv := newRouter(st, testConfig(), nil)
|
srv := newRouter(st, testConfig())
|
||||||
|
|
||||||
body := adminSeriesPage(t, srv, st, "?filter=no_cover")
|
body := adminSeriesPage(t, srv, st, "?filter=no_cover")
|
||||||
if !strings.Contains(body, "No series") {
|
if !strings.Contains(body, "No series") {
|
||||||
@@ -2168,7 +2298,7 @@ func TestSeriesListEscapesHostileTitles(t *testing.T) {
|
|||||||
VALUES ('asura', 'xss', '<script>alert(1)</script>', 'u', 'c', 9000)`); err != nil {
|
VALUES ('asura', 'xss', '<script>alert(1)</script>', 'u', 'c', 9000)`); err != nil {
|
||||||
t.Fatalf("seed hostile title: %v", err)
|
t.Fatalf("seed hostile title: %v", err)
|
||||||
}
|
}
|
||||||
srv := newRouter(st, testConfig(), nil)
|
srv := newRouter(st, testConfig())
|
||||||
|
|
||||||
body := adminSeriesPage(t, srv, st, "")
|
body := adminSeriesPage(t, srv, st, "")
|
||||||
if strings.Contains(body, "<script>alert") {
|
if strings.Contains(body, "<script>alert") {
|
||||||
@@ -2194,7 +2324,7 @@ func TestSeriesListRowShape(t *testing.T) {
|
|||||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:broken", checkedAt: 0, bookmarks: 0})
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:broken", checkedAt: 0, bookmarks: 0})
|
||||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:mid", url: "u", cover: "c", checkedAt: time.Now().Add(-24 * time.Hour).UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:mid", url: "u", cover: "c", checkedAt: time.Now().Add(-24 * time.Hour).UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:fine", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:fine", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||||
srv := newRouter(st, testConfig(), nil)
|
srv := newRouter(st, testConfig())
|
||||||
|
|
||||||
body := adminSeriesPage(t, srv, st, "")
|
body := adminSeriesPage(t, srv, st, "")
|
||||||
// Order is least-recently-checked first, then (site, series_id): broken
|
// Order is least-recently-checked first, then (site, series_id): broken
|
||||||
|
|||||||
@@ -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
|
||||||
|
(`LATEST_CHAPTER_POLL_ENABLED` unset) the page must say so, and the browser
|
||||||
|
statusline appears only when polling is switched on.
|
||||||
Reference in New Issue
Block a user