Admin dashboard: pages, Lane observability, poll pass log, per-Series intervention (#134) #148
+12
-11
@@ -31,8 +31,7 @@ image, TLS terminated by the reverse proxy so the service listens plain `:8080`.
|
|||||||
store must have that `TestMain` or it has no database at all.
|
store must have that `TestMain` or it has no database at all.
|
||||||
|
|
||||||
### Reader-owned store — `internal/store`, `internal/token`
|
### Reader-owned store — `internal/store`, `internal/token`
|
||||||
|
- The Reader-owned tables are `readers`, `bookmarks`, `series`, and `sessions`; auxiliary `covers`, `poll_lanes`, and `poll_passes` are also defined in the migrations.
|
||||||
Four tables; shape is in the migrations, behaviour in `Store`'s methods.
|
|
||||||
|
|
||||||
- **Credentials are derived, never stored.** `token.Token(TOKEN_KEY, discord_id, epoch)`
|
- **Credentials are derived, never stored.** `token.Token(TOKEN_KEY, discord_id, epoch)`
|
||||||
is an HMAC; only its SHA-256 reaches `readers.token_sha256`. So install URLs
|
is an HMAC; only its SHA-256 reaches `readers.token_sha256`. So install URLs
|
||||||
@@ -135,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
|
||||||
@@ -253,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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,33 @@ func TestAcquireFillsChapterAndCoverFromOneFetch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A pause governs the Lane only: a Reader's first bookmark of a Series on a
|
||||||
|
// paused Site still reads the page, because acquisition is the creation-time
|
||||||
|
// fetch, not the poll queue (issue #147).
|
||||||
|
func TestAcquireIgnoresLanePause(t *testing.T) {
|
||||||
|
s, _ := newTestStore(t)
|
||||||
|
if err := s.PauseLane("asura", time.Now().Add(6*time.Hour).UnixMilli()); err != nil {
|
||||||
|
t.Fatalf("PauseLane: %v", err)
|
||||||
|
}
|
||||||
|
page := &fakeFetcher{body: asuraSeriesAndCoverFixture, status: 200}
|
||||||
|
covers := &fakeBytesCoverFetcher{body: []byte("cover-bytes"), contentType: "image/jpeg"}
|
||||||
|
acq := newAcquirer(s, page, covers)
|
||||||
|
|
||||||
|
bookmarkNewSeries(t, s, acquireSeriesURL)
|
||||||
|
acq.Wait()
|
||||||
|
|
||||||
|
if got := page.callCount(); got != 1 {
|
||||||
|
t.Fatalf("series page fetches on a paused Site = %d, want 1", got)
|
||||||
|
}
|
||||||
|
if got := covers.callCount(); got != 1 {
|
||||||
|
t.Fatalf("cover fetches = %d, want 1", got)
|
||||||
|
}
|
||||||
|
got := readBookmark(t, s, acquireKey)
|
||||||
|
if got.LatestChapterNum == nil || *got.LatestChapterNum != 181 {
|
||||||
|
t.Fatalf("LatestChapterNum = %v, want 181", got.LatestChapterNum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A Series that already exists is not re-acquired: no fetch, and the Cover it
|
// A Series that already exists is not re-acquired: no fetch, and the Cover it
|
||||||
// already has is left alone.
|
// already has is left alone.
|
||||||
func TestAcquireSkipsAnExistingSeries(t *testing.T) {
|
func TestAcquireSkipsAnExistingSeries(t *testing.T) {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -48,19 +47,22 @@ type Poller struct {
|
|||||||
// same failure-isolated prefetch path.
|
// same failure-isolated prefetch path.
|
||||||
CoverBytesFetch CoverBytesFetcher
|
CoverBytesFetch CoverBytesFetcher
|
||||||
Now func() time.Time // injected so tests can freeze it
|
Now func() time.Time // injected so tests can freeze it
|
||||||
|
// eligibleCount reports how many of a Site's Series are eligible for
|
||||||
|
// polling, defaulting to Store.EligibleSeriesCount. Injected so tests can
|
||||||
|
// fail the count alone: the eligible query shares the due query's tables,
|
||||||
|
// so no real store failure can reach this path without breaking the due
|
||||||
|
// query first (issue #141).
|
||||||
|
eligibleCount func(site string) (int, error)
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -167,14 +169,7 @@ func fetcherFor(site string, browser, tls Fetcher) Fetcher {
|
|||||||
// laneNames returns every registry Site in the deterministic order both Run
|
// laneNames returns every registry Site in the deterministic order both Run
|
||||||
// and runOnce iterate: sorted, so lane behaviour and its tests agree on who
|
// and runOnce iterate: sorted, so lane behaviour and its tests agree on who
|
||||||
// runs first.
|
// runs first.
|
||||||
func laneNames() []string {
|
func laneNames() []string { return SiteNames() }
|
||||||
names := make([]string, 0, len(sites))
|
|
||||||
for name := range sites {
|
|
||||||
names = append(names, name)
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Poller) Run(ctx context.Context) {
|
func (p *Poller) Run(ctx context.Context) {
|
||||||
names := laneNames()
|
names := laneNames()
|
||||||
@@ -216,28 +211,120 @@ func (p *Poller) runOnce(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One skip value per way a Lane Pass can return before its loop (issue #141);
|
||||||
|
// empty means the pass reached the loop. The values are wire strings — stored
|
||||||
|
// in poll_passes and read by the Lanes page — so they are stable, not prose.
|
||||||
|
const (
|
||||||
|
// Exported so the web layer renders a skip's reason without retyping the
|
||||||
|
// wire string (issue #145); the values are storage and page-stable.
|
||||||
|
SkipPaused = "paused" // the pause row was read at the top
|
||||||
|
SkipRefusing = "refusing" // refusal backoff
|
||||||
|
SkipSidecarDown = "sidecar-down" // a sibling browser Lane lost Chrome
|
||||||
|
SkipNoFetcher = "no-fetcher" // browser Site, no browser configured, no fallback
|
||||||
|
SkipDueQuery = "due-query" // the due query failed
|
||||||
|
SkipAsleep = "asleep" // under both browser wake thresholds
|
||||||
|
SkipEligibleCount = "eligible-count" // the eligible count failed
|
||||||
|
SkipNothingEligible = "nothing-eligible" // nothing eligible; sleeps a full rest
|
||||||
|
)
|
||||||
|
|
||||||
|
// readOutcome classifies one Series read for the pass row's outcome counts
|
||||||
|
// (issue #141). The classification the read already makes is counted, never a
|
||||||
|
// second taxonomy: refused is the Site holding a challenge, unreachable the
|
||||||
|
// browser interrupting, noChapter a 200 with real HTML but no chapter links,
|
||||||
|
// unfetchable the host pin or a missing fetcher, and errors everything else.
|
||||||
|
type readOutcome int
|
||||||
|
|
||||||
|
const (
|
||||||
|
outcomeSuccess readOutcome = iota
|
||||||
|
outcomeRefused
|
||||||
|
outcomeUnreachable
|
||||||
|
outcomeNoChapter
|
||||||
|
outcomeUnfetchable
|
||||||
|
outcomeError
|
||||||
|
)
|
||||||
|
|
||||||
|
// outcomeCounts are the five named outcome counts of one pass. A success
|
||||||
|
// count is derived, never stored: checked minus the four, with unreachable
|
||||||
|
// excluded because the sidecar-loss path returns before the checked counter
|
||||||
|
// increments (issue #141).
|
||||||
|
type outcomeCounts struct {
|
||||||
|
refused, unreachable, noChapter, unfetchable, errors int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *outcomeCounts) add(o readOutcome) {
|
||||||
|
switch o {
|
||||||
|
case outcomeRefused:
|
||||||
|
c.refused++
|
||||||
|
case outcomeUnreachable:
|
||||||
|
c.unreachable++
|
||||||
|
case outcomeNoChapter:
|
||||||
|
c.noChapter++
|
||||||
|
case outcomeUnfetchable:
|
||||||
|
c.unfetchable++
|
||||||
|
case outcomeError:
|
||||||
|
c.errors++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// passRecord is what one pass's durable row will be: the skip value and
|
||||||
|
// outcome counts filled in along the pass's return path. recordPass assembles
|
||||||
|
// the row, so every exit records exactly once.
|
||||||
|
type passRecord struct {
|
||||||
|
site string
|
||||||
|
ranAt int64
|
||||||
|
skip string
|
||||||
|
counts outcomeCounts
|
||||||
|
}
|
||||||
|
|
||||||
|
// lanePassRetention is how far back a Lane's pass log is kept. It is not the
|
||||||
|
// display window: retention is how far back a question can reach, and the
|
||||||
|
// window is what the owner is shown (issue #139).
|
||||||
|
const lanePassRetention = 14 * 24 * time.Hour
|
||||||
|
|
||||||
// runLanePass processes one pass of one Site's Lane: select the due Series,
|
// runLanePass processes one pass of one Site's Lane: select the due Series,
|
||||||
// pace through them, and report how long the Lane should wait before its next
|
// pace through them, and report how long the Lane should wait before its next
|
||||||
// pass. paced spaces consecutive fetches by the Site's effective gap — the
|
// pass. paced spaces consecutive fetches by the Site's effective gap — the
|
||||||
// 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{}
|
||||||
if until := p.refusalBackoff(name); now.Before(until) {
|
rec := passRecord{site: name, ranAt: now.UnixMilli()}
|
||||||
|
defer func() { p.recordPass(rec, fig) }()
|
||||||
|
|
||||||
|
// One Lane row read at the top of a pass, serving two gates (issue #139).
|
||||||
|
// Both stamps outlive our process, so the gates read the durable row
|
||||||
|
// rather than memory: a refusal is the Site's mood and a pause the
|
||||||
|
// owner's order, and neither is lost to a restart.
|
||||||
|
pausedUntil, refuseUntil, err := p.Store.LaneGates(name)
|
||||||
|
if err != nil {
|
||||||
|
// Fail open: a store that cannot answer the gate cannot record the
|
||||||
|
// pass either, and one Lane must not stall on its own gate read.
|
||||||
|
log.Printf("latest poll %s: lane gates: %v", name, err)
|
||||||
|
}
|
||||||
|
if pausedUntil > now.UnixMilli() {
|
||||||
|
// Paused ahead of the refusal check: no Series is touched, so the
|
||||||
|
// queue stays intact for when the pause lifts (issue #141, #147).
|
||||||
|
rec.skip = SkipPaused
|
||||||
|
log.Printf("latest poll %s: paused until %s, skipping pass", name, time.UnixMilli(pausedUntil).Format(time.RFC3339))
|
||||||
|
return time.Duration(pausedUntil-now.UnixMilli()) * time.Millisecond
|
||||||
|
}
|
||||||
|
if refuseUntil > now.UnixMilli() {
|
||||||
// Cooling down after a refusal: do not attempt this Site at all.
|
// Cooling down after a refusal: do not attempt this Site at all.
|
||||||
return until.Sub(now)
|
rec.skip = SkipRefusing
|
||||||
|
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
|
||||||
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]
|
||||||
@@ -246,24 +333,29 @@ 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).
|
||||||
st.Gap = defaultGap
|
rec.skip = SkipNoFetcher
|
||||||
|
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
|
||||||
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) && !anyForced(due) {
|
||||||
// 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. A forced Series is the one exception — a human asking
|
||||||
// the owner's page must show rather than a zero.
|
// is not the machine waking itself (issue #146). The Lane still paces
|
||||||
st.Gap, st.Asleep = defaultGap, true
|
// at the default gap, which is what the owner's page must show rather
|
||||||
|
// than a zero.
|
||||||
|
rec.skip = SkipAsleep
|
||||||
|
fig.Gap = defaultGap
|
||||||
return defaultGap
|
return defaultGap
|
||||||
}
|
}
|
||||||
if s.Browser != nil {
|
if s.Browser != nil {
|
||||||
@@ -276,20 +368,22 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
eligible, err := p.Store.EligibleSeriesCount(name)
|
eligible, err := p.countEligible(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
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
|
||||||
return s.Rest
|
return s.Rest
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +394,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
|
||||||
}
|
}
|
||||||
@@ -314,46 +408,98 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := p.checkOne(ctx, sr); err != nil {
|
outcome := p.checkOne(ctx, sr)
|
||||||
switch {
|
if outcome == outcomeUnreachable {
|
||||||
case errors.Is(err, errChallengeHeld):
|
// The mid-loop browser loss writes an empty skip on purpose: the
|
||||||
refusals++
|
// pass returns before the checked counter increments, so its row
|
||||||
case errors.Is(err, errBrowserInterrupted):
|
// is stall-shaped (due > 0, checked 0, skip ''), and a stall is
|
||||||
|
// the exact signal this exit produces. A tenth skip value would
|
||||||
|
// make it legible but is deliberately not invented here.
|
||||||
|
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
|
||||||
default:
|
|
||||||
refusals = 0
|
|
||||||
}
|
}
|
||||||
|
if outcome == outcomeRefused {
|
||||||
|
refusals++
|
||||||
} else {
|
} else {
|
||||||
refusals = 0
|
refusals = 0
|
||||||
}
|
}
|
||||||
st.Checked++
|
rec.counts.add(outcome)
|
||||||
|
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,
|
||||||
log.Printf("latest poll %s: refused twice this run, waiting %s", name, refuseBackoff)
|
// so a Site that just told us to back off is not re-probed.
|
||||||
return refuseBackoff
|
if err := p.Store.SetLaneRefusal(name, now.Add(RefuseBackoff).UnixMilli()); err != nil {
|
||||||
|
log.Printf("latest poll %s: persist refusal: %v", name, err)
|
||||||
|
}
|
||||||
|
log.Printf("latest poll %s: refused twice this run, waiting %s", name, RefuseBackoff)
|
||||||
|
return RefuseBackoff
|
||||||
}
|
}
|
||||||
return gap
|
return gap
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Poller) refusalBackoff(name string) time.Time {
|
// passFigures are the numbers one pass measured for its durable row (issue
|
||||||
p.mu.Lock()
|
// #141): due and checked as the pass saw them, the pace it chose, and whether
|
||||||
defer p.mu.Unlock()
|
// the gap sat on the floor. A pass that returned before measuring keeps the
|
||||||
return p.refuseUntil[name]
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Poller) setRefusalBackoff(name string, until time.Time) {
|
// recordPass writes the durable row for one pass (issue #141). Called deferred
|
||||||
p.mu.Lock()
|
// from runLanePass so every return path records exactly one row. A pass that
|
||||||
defer p.mu.Unlock()
|
// never computed its own figures — its gap is zero — carries the previous
|
||||||
if p.refuseUntil == nil {
|
// pass's due, gap, clamped and checked forward rather than stating zeroes it
|
||||||
p.refuseUntil = make(map[string]time.Time)
|
// did not measure; the skip column says why it declined, so the zeroes that
|
||||||
|
// remain (due-query, no-fetcher) read as explanations rather than
|
||||||
|
// measurements.
|
||||||
|
func (p *Poller) recordPass(rec passRecord, fig passFigures) {
|
||||||
|
row := store.LanePass{
|
||||||
|
Site: rec.site,
|
||||||
|
RanAt: rec.ranAt,
|
||||||
|
Skip: rec.skip,
|
||||||
|
Due: fig.Due,
|
||||||
|
Checked: fig.Checked,
|
||||||
|
GapMS: fig.Gap.Milliseconds(),
|
||||||
|
Clamped: fig.Clamped,
|
||||||
|
Refused: rec.counts.refused,
|
||||||
|
Unreachable: rec.counts.unreachable,
|
||||||
|
NoChapter: rec.counts.noChapter,
|
||||||
|
Unfetchable: rec.counts.unfetchable,
|
||||||
|
Errors: rec.counts.errors,
|
||||||
}
|
}
|
||||||
p.refuseUntil[name] = until
|
if row.GapMS == 0 {
|
||||||
|
// The pass never computed a gap, so it has no figures of its own:
|
||||||
|
// carry the previous pass's, in one latest-per-Site read — the
|
||||||
|
// recorder needs one Site, not six (issue #139).
|
||||||
|
if prev, ok, err := p.Store.LatestLanePass(rec.site); err != nil {
|
||||||
|
log.Printf("latest poll %s: previous pass: %v", rec.site, err)
|
||||||
|
} else if ok {
|
||||||
|
row.Due, row.Checked = prev.Due, prev.Checked
|
||||||
|
row.GapMS, row.Clamped = prev.GapMS, prev.Clamped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := p.Store.RecordLanePass(row, rec.ranAt-lanePassRetention.Milliseconds()); err != nil {
|
||||||
|
log.Printf("latest poll %s: record lane pass: %v", rec.site, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// countEligible routes the eligible count through the test seam when one is
|
||||||
|
// set, else the store.
|
||||||
|
func (p *Poller) countEligible(site string) (int, error) {
|
||||||
|
if p.eligibleCount != nil {
|
||||||
|
return p.eligibleCount(site)
|
||||||
|
}
|
||||||
|
return p.Store.EligibleSeriesCount(site)
|
||||||
}
|
}
|
||||||
|
|
||||||
// setBrowserDown records when a browser Lane lost the sidecar. It is Poller
|
// setBrowserDown records when a browser Lane lost the sidecar. It is Poller
|
||||||
@@ -366,7 +512,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()
|
||||||
@@ -395,6 +541,19 @@ func browserWakeDue(due []store.Series, now time.Time, rest time.Duration) bool
|
|||||||
return maxSeriesWait(due, now, rest) >= browserWakeAge
|
return maxSeriesWait(due, now, rest) >= browserWakeAge
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// anyForced reports whether the due list holds a forced Series: one whose
|
||||||
|
// owner check-now request (issue #146) has not been answered yet. A human
|
||||||
|
// asking wakes a sleeping Chrome even below the wake thresholds; the request
|
||||||
|
// itself still ages visibly if the home machine is off.
|
||||||
|
func anyForced(due []store.Series) bool {
|
||||||
|
for _, sr := range due {
|
||||||
|
if sr.Forced {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// maxSeriesWait returns how long the most-overdue of the due Series has been
|
// maxSeriesWait returns how long the most-overdue of the due Series has been
|
||||||
// waiting past its due moment (0 when due is empty).
|
// waiting past its due moment (0 when due is empty).
|
||||||
func maxSeriesWait(due []store.Series, now time.Time, rest time.Duration) time.Duration {
|
func maxSeriesWait(due []store.Series, now time.Time, rest time.Duration) time.Duration {
|
||||||
@@ -409,13 +568,15 @@ func maxSeriesWait(due []store.Series, now time.Time, rest time.Duration) time.D
|
|||||||
|
|
||||||
// checkOne re-checks one series. Every failure path here is "log and move on":
|
// checkOne re-checks one series. Every failure path here is "log and move on":
|
||||||
// the poller is a best-effort enhancement, and no single bad series may stall a
|
// the poller is a best-effort enhancement, and no single bad series may stall a
|
||||||
// Lane or take down the process. The returned error is the page read's
|
// Lane or take down the process. The returned outcome classifies the read for
|
||||||
// classified outcome so the Lane can tell a refusal from a loss of the
|
// the pass row (issue #141), so the Lane can count a refusal, a lost browser,
|
||||||
// browser; non-classified failures still return nil-equivalent behaviour.
|
// a chapter-less page, an unfetchable address or a transport error without
|
||||||
func (p *Poller) checkOne(ctx context.Context, sr store.Series) error {
|
// re-deriving the taxonomy.
|
||||||
|
func (p *Poller) checkOne(ctx context.Context, sr store.Series) (outcome readOutcome) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
log.Printf("latest poll %q: recovered from panic: %v", sr.Key(), r)
|
log.Printf("latest poll %q: recovered from panic: %v", sr.Key(), r)
|
||||||
|
outcome = outcomeError
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -427,7 +588,7 @@ func (p *Poller) checkOne(ctx context.Context, sr store.Series) error {
|
|||||||
// the stamp means "attempted", and an untried Series stays due.
|
// the stamp means "attempted", and an untried Series stays due.
|
||||||
if err := p.Store.MarkLatestChecked(sr.Site, sr.SeriesID, p.Now().UnixMilli()); err != nil {
|
if err := p.Store.MarkLatestChecked(sr.Site, sr.SeriesID, p.Now().UnixMilli()); err != nil {
|
||||||
log.Printf("latest poll %q: mark checked: %v", sr.Key(), err)
|
log.Printf("latest poll %q: mark checked: %v", sr.Key(), err)
|
||||||
return nil
|
return outcomeError
|
||||||
}
|
}
|
||||||
|
|
||||||
facts, err := readSeriesPage(ctx, sr.Site, sr.SeriesURL, p.BrowserFetch, p.Fetch)
|
facts, err := readSeriesPage(ctx, sr.Site, sr.SeriesURL, p.BrowserFetch, p.Fetch)
|
||||||
@@ -438,17 +599,23 @@ func (p *Poller) checkOne(ctx context.Context, sr store.Series) error {
|
|||||||
// passes the gate is retried at rest pace rather than
|
// passes the gate is retried at rest pace rather than
|
||||||
// hot-looping.
|
// hot-looping.
|
||||||
log.Printf("latest poll %q: not fetchable: site=%q url=%q", sr.Key(), sr.Site, sr.SeriesURL)
|
log.Printf("latest poll %q: not fetchable: site=%q url=%q", sr.Key(), sr.Site, sr.SeriesURL)
|
||||||
return err
|
return outcomeUnfetchable
|
||||||
case errors.Is(err, errNoFetcher):
|
case errors.Is(err, errNoFetcher):
|
||||||
log.Printf("latest poll %q: no fetcher for site %q", sr.Key(), sr.Site)
|
log.Printf("latest poll %q: no fetcher for site %q", sr.Key(), sr.Site)
|
||||||
return err
|
return outcomeUnfetchable
|
||||||
}
|
}
|
||||||
// A legacy cover heals independently of the page read: its source may
|
// A legacy cover heals independently of the page read: its source may
|
||||||
// answer — a CDN — while the origin does not, so a fetch failure does
|
// answer — a CDN — while the origin does not, so a fetch failure does
|
||||||
// not skip the heal, matching the order the shared read replaced.
|
// not skip the heal, matching the order the shared read replaced.
|
||||||
p.healCover(ctx, sr)
|
p.healCover(ctx, sr)
|
||||||
log.Printf("latest poll %q: %v", sr.Key(), err)
|
log.Printf("latest poll %q: %v", sr.Key(), err)
|
||||||
return err
|
if errors.Is(err, errChallengeHeld) {
|
||||||
|
return outcomeRefused
|
||||||
|
}
|
||||||
|
if errors.Is(err, errBrowserInterrupted) {
|
||||||
|
return outcomeUnreachable
|
||||||
|
}
|
||||||
|
return outcomeError
|
||||||
}
|
}
|
||||||
// A legacy cover source is healed independently of the page read.
|
// A legacy cover source is healed independently of the page read.
|
||||||
p.healCover(ctx, sr)
|
p.healCover(ctx, sr)
|
||||||
@@ -459,7 +626,7 @@ func (p *Poller) checkOne(ctx context.Context, sr store.Series) error {
|
|||||||
// Most likely a challenge page or a layout change. Either way the row is
|
// Most likely a challenge page or a layout change. Either way the row is
|
||||||
// already stamped, so this waits out a rest instead of hot-looping.
|
// already stamped, so this waits out a rest instead of hot-looping.
|
||||||
log.Printf("latest poll %q: no chapter links in %d bytes", sr.Key(), facts.BodyLen)
|
log.Printf("latest poll %q: no chapter links in %d bytes", sr.Key(), facts.BodyLen)
|
||||||
return nil
|
return outcomeNoChapter
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Poll is the oracle for whatever Sighting last raised this Series
|
// The Poll is the oracle for whatever Sighting last raised this Series
|
||||||
@@ -472,7 +639,7 @@ func (p *Poller) checkOne(ctx context.Context, sr store.Series) error {
|
|||||||
// against the due-query snapshot; a concurrent write in between only costs
|
// against the due-query snapshot; a concurrent write in between only costs
|
||||||
// one redundant UPDATE of the same absolute value, never a wrong one.
|
// one redundant UPDATE of the same absolute value, never a wrong one.
|
||||||
if sr.LatestChapterNum != nil && *sr.LatestChapterNum == facts.Latest.Num {
|
if sr.LatestChapterNum != nil && *sr.LatestChapterNum == facts.Latest.Num {
|
||||||
return nil
|
return outcomeSuccess
|
||||||
}
|
}
|
||||||
|
|
||||||
// Series-level write: the row is shared, so one update refreshes every
|
// Series-level write: the row is shared, so one update refreshes every
|
||||||
@@ -481,10 +648,10 @@ func (p *Poller) checkOne(ctx context.Context, sr store.Series) error {
|
|||||||
// the list.
|
// the list.
|
||||||
if err := p.Store.SetLatestChapter(sr.Site, sr.SeriesID, facts.Latest.Label, facts.Latest.Num); err != nil {
|
if err := p.Store.SetLatestChapter(sr.Site, sr.SeriesID, facts.Latest.Label, facts.Latest.Num); err != nil {
|
||||||
log.Printf("latest poll %q: set latest chapter: %v", sr.Key(), err)
|
log.Printf("latest poll %q: set latest chapter: %v", sr.Key(), err)
|
||||||
return nil
|
return outcomeError
|
||||||
}
|
}
|
||||||
log.Printf("latest poll %q: latest is now %s", sr.Key(), facts.Latest.Label)
|
log.Printf("latest poll %q: latest is now %s", sr.Key(), facts.Latest.Label)
|
||||||
return nil
|
return outcomeSuccess
|
||||||
}
|
}
|
||||||
|
|
||||||
// judgeSighting settles the Sighting the Series' stored Latest Chapter is owed
|
// judgeSighting settles the Sighting the Series' stored Latest Chapter is owed
|
||||||
|
|||||||
@@ -427,9 +427,12 @@ func TestRunLogsLaneDefaults(t *testing.T) {
|
|||||||
log.SetOutput(&logs)
|
log.SetOutput(&logs)
|
||||||
t.Cleanup(func() { log.SetOutput(previous) })
|
t.Cleanup(func() { log.SetOutput(previous) })
|
||||||
|
|
||||||
|
// The pass log writes through the store, so Run needs a real one — a
|
||||||
|
// poller without a Store is not a poller.
|
||||||
|
s, _ := newTestStore(t)
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancel()
|
cancel()
|
||||||
(&Poller{Now: func() time.Time { return time.Now() }}).Run(ctx)
|
(&Poller{Store: s, Now: func() time.Time { return time.Now() }}).Run(ctx)
|
||||||
|
|
||||||
got := logs.String()
|
got := logs.String()
|
||||||
for _, want := range []string{"6 lanes", "rest=1h0m0s", "gap=10s"} {
|
for _, want := range []string{"6 lanes", "rest=1h0m0s", "gap=10s"} {
|
||||||
@@ -1305,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)
|
||||||
@@ -1388,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)
|
||||||
@@ -1401,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())
|
||||||
@@ -1412,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).
|
||||||
@@ -1456,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.
|
||||||
@@ -1602,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)
|
||||||
@@ -1627,65 +1614,726 @@ 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
|
|
||||||
}
|
}
|
||||||
}
|
kagane := latestPassFor(t, s, "kagane")
|
||||||
if st.Lanes[0].Site != "asura" {
|
if kagane.Skip != "" || kagane.Refused != 2 || kagane.Due != 2 || kagane.GapMS == 0 {
|
||||||
t.Fatalf("first lane = %q, want asura (snapshot sorted by Site)", st.Lanes[0].Site)
|
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())
|
||||||
|
again := latestPassFor(t, s, "kagane")
|
||||||
|
if again.Skip != SkipRefusing {
|
||||||
|
t.Fatalf("kagane refusing pass skip = %q, want %q", again.Skip, SkipRefusing)
|
||||||
|
}
|
||||||
|
if again.Due != before.Due || again.GapMS != before.GapMS || again.Checked != before.Checked {
|
||||||
|
t.Fatalf("kagane after a skipped pass = due %d gap %d, want the previous pass's %d / %d",
|
||||||
|
again.Due, again.GapMS, before.Due, before.GapMS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// latestPassFor reads a Site's newest durable pass row, failing rather than
|
||||||
|
// returning a zero LanePass a caller would assert against by accident.
|
||||||
|
func latestPassFor(t *testing.T, s *store.Store, site string) store.LanePass {
|
||||||
|
t.Helper()
|
||||||
|
pass, ok, err := s.LatestLanePass(site)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("LatestLanePass(%s): ok=%v err=%v", site, ok, err)
|
||||||
|
}
|
||||||
|
return pass
|
||||||
|
}
|
||||||
|
|
||||||
|
// countPassRows counts a Site's durable pass rows, for asserting that a pass
|
||||||
|
// records exactly one.
|
||||||
|
func countPassRows(t *testing.T, dbURL, site string) int {
|
||||||
|
t.Helper()
|
||||||
|
db, err := sql.Open("pgx", dbURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open %s: %v", dbURL, err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
var n int
|
||||||
|
if err := db.QueryRow(`SELECT count(*) FROM poll_passes WHERE site = $1`, site).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count passes for %s: %v", site, err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// One durable pass row per exit, with the skip value naming the exit. The
|
||||||
|
// mid-loop browser-unreachable return also writes one row — but with an empty
|
||||||
|
// skip, so the row is stall-shaped (due > 0, checked 0, skip ”), matching the
|
||||||
|
// deliberate absence of a tenth skip value.
|
||||||
|
func TestRunLanePassRecordsEveryExit(t *testing.T) {
|
||||||
|
now := time.UnixMilli(5_000_000)
|
||||||
|
|
||||||
|
t.Run("paused", func(t *testing.T) {
|
||||||
|
s, dbURL := newTestStore(t)
|
||||||
|
seedForCheck(t, s, "asura:x", "https://asurascans.com/series/x", 0)
|
||||||
|
if err := s.PauseLane("asura", now.Add(30*time.Minute).UnixMilli()); err != nil {
|
||||||
|
t.Fatalf("PauseLane: %v", err)
|
||||||
|
}
|
||||||
|
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
||||||
|
p := newTestPoller(t, s, f, now)
|
||||||
|
if pace := p.runLanePass(context.Background(), "asura", false); pace != 30*time.Minute {
|
||||||
|
t.Fatalf("paused pace = %s, want 30m (sleep until the expiry)", pace)
|
||||||
|
}
|
||||||
|
if f.callCount() != 0 {
|
||||||
|
t.Fatalf("fetches while paused = %d, want 0", f.callCount())
|
||||||
|
}
|
||||||
|
if got := readLatestCheckedAt(t, s, "asura:x"); got != 0 {
|
||||||
|
t.Fatalf("stamp while paused = %d, want 0 (Series stay due and unstamped)", got)
|
||||||
|
}
|
||||||
|
if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipPaused {
|
||||||
|
t.Fatalf("skip = %q, want %q", pass.Skip, SkipPaused)
|
||||||
|
}
|
||||||
|
if got := countPassRows(t, dbURL, "asura"); got != 1 {
|
||||||
|
t.Fatalf("pass rows = %d, want exactly 1", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("refusing", func(t *testing.T) {
|
||||||
|
s, dbURL := newTestStore(t)
|
||||||
|
seedForCheck(t, s, "kagane:x", "https://kagane.to/series/x", 0)
|
||||||
|
if err := s.SetLaneRefusal("kagane", now.Add(10*time.Minute).UnixMilli()); err != nil {
|
||||||
|
t.Fatalf("SetLaneRefusal: %v", err)
|
||||||
|
}
|
||||||
|
browser := &fakeFetcher{status: 200}
|
||||||
|
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
|
||||||
|
p.BrowserFetch = browser
|
||||||
|
if pace := p.runLanePass(context.Background(), "kagane", false); pace != 10*time.Minute {
|
||||||
|
t.Fatalf("refusing pace = %s, want 10m", pace)
|
||||||
|
}
|
||||||
|
if browser.callCount() != 0 {
|
||||||
|
t.Fatalf("fetches while refusing = %d, want 0", browser.callCount())
|
||||||
|
}
|
||||||
|
if pass := latestPassFor(t, s, "kagane"); pass.Skip != SkipRefusing {
|
||||||
|
t.Fatalf("skip = %q, want %q", pass.Skip, SkipRefusing)
|
||||||
|
}
|
||||||
|
if got := countPassRows(t, dbURL, "kagane"); got != 1 {
|
||||||
|
t.Fatalf("pass rows = %d, want exactly 1", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("sidecar-down", func(t *testing.T) {
|
||||||
|
s, dbURL := newTestStore(t)
|
||||||
|
seedForCheck(t, s, "kagane:x", "https://kagane.to/series/x", 0)
|
||||||
|
browser := &fakeFetcher{body: kaganeAPIFixture, status: 200}
|
||||||
|
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
|
||||||
|
p.BrowserFetch = browser
|
||||||
|
p.setBrowserDown(now) // a sibling Lane lost Chrome within the backoff window
|
||||||
|
if pace := p.runLanePass(context.Background(), "kagane", false); pace != RefuseBackoff {
|
||||||
|
t.Fatalf("sidecar-down pace = %s, want %s", pace, RefuseBackoff)
|
||||||
|
}
|
||||||
|
if browser.callCount() != 0 {
|
||||||
|
t.Fatalf("fetches with the sidecar down = %d, want 0", browser.callCount())
|
||||||
|
}
|
||||||
|
if pass := latestPassFor(t, s, "kagane"); pass.Skip != SkipSidecarDown {
|
||||||
|
t.Fatalf("skip = %q, want %q", pass.Skip, SkipSidecarDown)
|
||||||
|
}
|
||||||
|
if got := countPassRows(t, dbURL, "kagane"); got != 1 {
|
||||||
|
t.Fatalf("pass rows = %d, want exactly 1", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no-fetcher", func(t *testing.T) {
|
||||||
|
s, dbURL := newTestStore(t)
|
||||||
|
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
|
||||||
|
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
|
||||||
|
if pace := p.runLanePass(context.Background(), "comix", false); pace != defaultGap {
|
||||||
|
t.Fatalf("no-fetcher pace = %s, want %s", pace, defaultGap)
|
||||||
|
}
|
||||||
|
pass := latestPassFor(t, s, "comix")
|
||||||
|
if pass.Skip != SkipNoFetcher || pass.GapMS != defaultGap.Milliseconds() {
|
||||||
|
t.Fatalf("no-fetcher pass = %+v, want skip %q with its own gap %s", pass, SkipNoFetcher, defaultGap)
|
||||||
|
}
|
||||||
|
if got := countPassRows(t, dbURL, "comix"); got != 1 {
|
||||||
|
t.Fatalf("pass rows = %d, want exactly 1", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("due-query", func(t *testing.T) {
|
||||||
|
s, dbURL := newTestStore(t)
|
||||||
|
seedForCheck(t, s, "asura:x", "https://asurascans.com/series/x", 0)
|
||||||
|
// The due query is the pass's first store read after the gates; making
|
||||||
|
// it fail without touching poll_passes takes its tables away.
|
||||||
|
db, err := sql.Open("pgx", dbURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open %s: %v", dbURL, err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`DROP TABLE bookmarks`); err != nil {
|
||||||
|
t.Fatalf("drop bookmarks: %v", err)
|
||||||
|
}
|
||||||
|
db.Close()
|
||||||
|
p := newTestPoller(t, s, &fakeFetcher{body: asuraSeriesFixture, status: 200}, now)
|
||||||
|
if pace := p.runLanePass(context.Background(), "asura", false); pace != defaultGap {
|
||||||
|
t.Fatalf("due-query pace = %s, want %s", pace, defaultGap)
|
||||||
|
}
|
||||||
|
if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipDueQuery {
|
||||||
|
t.Fatalf("skip = %q, want %q", pass.Skip, SkipDueQuery)
|
||||||
|
}
|
||||||
|
if got := countPassRows(t, dbURL, "asura"); got != 1 {
|
||||||
|
t.Fatalf("pass rows = %d, want exactly 1", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("asleep", func(t *testing.T) {
|
||||||
|
s, dbURL := newTestStore(t)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
key := fmt.Sprintf("kagane:w%d", i)
|
||||||
|
seedForCheck(t, s, key, "https://kagane.to/series/"+key[7:], now.Add(-62*time.Minute).UnixMilli())
|
||||||
|
}
|
||||||
|
browser := &fakeFetcher{body: kaganeAPIFixture, status: 200}
|
||||||
|
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
|
||||||
|
p.BrowserFetch = browser
|
||||||
|
if pace := p.runLanePass(context.Background(), "kagane", false); pace != defaultGap {
|
||||||
|
t.Fatalf("asleep pace = %s, want %s", pace, defaultGap)
|
||||||
|
}
|
||||||
|
if browser.callCount() != 0 {
|
||||||
|
t.Fatalf("fetches while Chrome is asleep = %d, want 0", browser.callCount())
|
||||||
|
}
|
||||||
|
pass := latestPassFor(t, s, "kagane")
|
||||||
|
if pass.Skip != SkipAsleep || pass.Due != 3 || pass.GapMS != defaultGap.Milliseconds() {
|
||||||
|
t.Fatalf("asleep pass = %+v, want skip %q with 3 due and the default gap", pass, SkipAsleep)
|
||||||
|
}
|
||||||
|
if got := countPassRows(t, dbURL, "kagane"); got != 1 {
|
||||||
|
t.Fatalf("pass rows = %d, want exactly 1", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("eligible-count", func(t *testing.T) {
|
||||||
|
s, dbURL := newTestStore(t)
|
||||||
|
// The eligible query shares the due query's tables, so no real store
|
||||||
|
// failure reaches it after a successful due read; the seam is how the
|
||||||
|
// path is driven at all (see Poller.eligibleCount).
|
||||||
|
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
|
||||||
|
p.eligibleCount = func(string) (int, error) { return 0, errors.New("count failed") }
|
||||||
|
if pace := p.runLanePass(context.Background(), "asura", false); pace != defaultGap {
|
||||||
|
t.Fatalf("eligible-count pace = %s, want %s", pace, defaultGap)
|
||||||
|
}
|
||||||
|
if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipEligibleCount {
|
||||||
|
t.Fatalf("skip = %q, want %q", pass.Skip, SkipEligibleCount)
|
||||||
|
}
|
||||||
|
if got := countPassRows(t, dbURL, "asura"); got != 1 {
|
||||||
|
t.Fatalf("pass rows = %d, want exactly 1", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nothing-eligible", func(t *testing.T) {
|
||||||
|
s, dbURL := newTestStore(t)
|
||||||
|
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
|
||||||
|
if pace := p.runLanePass(context.Background(), "asura", false); pace != sites["asura"].Rest {
|
||||||
|
t.Fatalf("nothing-eligible pace = %s, want a full rest %s", pace, sites["asura"].Rest)
|
||||||
|
}
|
||||||
|
if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipNothingEligible {
|
||||||
|
t.Fatalf("skip = %q, want %q", pass.Skip, SkipNothingEligible)
|
||||||
|
}
|
||||||
|
if got := countPassRows(t, dbURL, "asura"); got != 1 {
|
||||||
|
t.Fatalf("pass rows = %d, want exactly 1", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("reached the loop", func(t *testing.T) {
|
||||||
|
s, dbURL := newTestStore(t)
|
||||||
|
const url = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
|
||||||
|
seedForCheck(t, s, "asura:chronicles-of-the-demon-faction-f886a8af", url, 0)
|
||||||
|
p := newTestPoller(t, s, &fakeFetcher{body: asuraSeriesFixture, status: 200}, now)
|
||||||
|
if pace := p.runLanePass(context.Background(), "asura", false); pace != defaultGap {
|
||||||
|
t.Fatalf("loop pace = %s, want %s", pace, defaultGap)
|
||||||
|
}
|
||||||
|
pass := latestPassFor(t, s, "asura")
|
||||||
|
if pass.Skip != "" || pass.Due != 1 || pass.Checked != 1 {
|
||||||
|
t.Fatalf("loop pass = %+v, want an empty skip with 1 due and 1 checked", pass)
|
||||||
|
}
|
||||||
|
if got := countPassRows(t, dbURL, "asura"); got != 1 {
|
||||||
|
t.Fatalf("pass rows = %d, want exactly 1", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("browser-unreachable mid-loop", func(t *testing.T) {
|
||||||
|
s, dbURL := newTestStore(t)
|
||||||
|
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
|
||||||
|
interrupted := fmt.Errorf("%w: %w", errBrowserInterrupted, errors.New("restart"))
|
||||||
|
browser := &fakeFetcher{status: 200, err: interrupted}
|
||||||
|
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
|
||||||
|
p.BrowserFetch = browser
|
||||||
|
if pace := p.runLanePass(context.Background(), "comix", false); pace != defaultGap {
|
||||||
|
t.Fatalf("mid-loop pace = %s, want %s", pace, defaultGap)
|
||||||
|
}
|
||||||
|
// One row was still written, and it is stall-shaped by construction:
|
||||||
|
// empty skip with due > 0 and checked 0 because the return precedes
|
||||||
|
// the checked counter. The assertion below pins that shape precisely.
|
||||||
|
pass := latestPassFor(t, s, "comix")
|
||||||
|
if pass.Skip != "" || pass.Unreachable != 1 || pass.Checked != 0 || pass.Due != 1 {
|
||||||
|
t.Fatalf("mid-loop pass = %+v, want empty skip, unreachable 1, checked 0, due 1", pass)
|
||||||
|
}
|
||||||
|
if got := countPassRows(t, dbURL, "comix"); got != 1 {
|
||||||
|
t.Fatalf("pass rows = %d, want exactly 1", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carry-forward moves verbatim from the in-memory snapshot (issue #141): a
|
||||||
|
// pass that never computed its own gap carries the previous pass's due, gap,
|
||||||
|
// clamped and checked forward; a pass with a gap of its own records its own
|
||||||
|
// figures, zeroes included, beside the skip reason that explains them.
|
||||||
|
func TestRunLanePassCarryForwardOnlyWhenGapZero(t *testing.T) {
|
||||||
|
now := time.UnixMilli(5_000_000)
|
||||||
|
|
||||||
|
t.Run("no gap of its own carries the previous pass's figures", func(t *testing.T) {
|
||||||
|
s, _ := newTestStore(t)
|
||||||
|
seedForCheck(t, s, "kagane:x", "https://kagane.to/series/x", 0)
|
||||||
|
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
|
||||||
|
p.BrowserFetch = &fakeFetcher{body: kaganeAPIFixture, status: 200}
|
||||||
|
p.runLanePass(context.Background(), "kagane", false)
|
||||||
|
first := latestPassFor(t, s, "kagane")
|
||||||
|
if first.Due != 1 || first.Checked != 1 || first.GapMS == 0 {
|
||||||
|
t.Fatalf("first pass = %+v, want a measured pass", first)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.SetLaneRefusal("kagane", now.Add(10*time.Minute).UnixMilli()); err != nil {
|
||||||
|
t.Fatalf("SetLaneRefusal: %v", err)
|
||||||
|
}
|
||||||
|
// The pass row is keyed (site, ran_at), so the second pass needs its
|
||||||
|
// own timestamp: a minute later is still inside the refusal.
|
||||||
|
p.Now = func() time.Time { return now.Add(time.Minute) }
|
||||||
|
p.runLanePass(context.Background(), "kagane", false)
|
||||||
|
second := latestPassFor(t, s, "kagane")
|
||||||
|
if second.Skip != SkipRefusing {
|
||||||
|
t.Fatalf("second pass skip = %q, want %q", second.Skip, SkipRefusing)
|
||||||
|
}
|
||||||
|
if second.Due != first.Due || second.Checked != first.Checked ||
|
||||||
|
second.GapMS != first.GapMS || second.Clamped != first.Clamped {
|
||||||
|
t.Fatalf("carried pass = %+v, want the first pass's figures %+v", second, first)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a gap of its own records its own figures", func(t *testing.T) {
|
||||||
|
s, _ := newTestStore(t)
|
||||||
|
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
|
||||||
|
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
|
||||||
|
p.BrowserFetch = &fakeFetcher{body: comixSeriesFixture, status: 200}
|
||||||
|
p.runLanePass(context.Background(), "comix", false)
|
||||||
|
first := latestPassFor(t, s, "comix")
|
||||||
|
if first.Due != 1 || first.GapMS == 0 {
|
||||||
|
t.Fatalf("first pass = %+v, want a measured pass", first)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The no-fetcher exit sets its own gap, so no carry-forward: the due
|
||||||
|
// count it never gathered records as zero beside its skip reason. A
|
||||||
|
// minute later gives the second pass its own (site, ran_at) key.
|
||||||
|
p.BrowserFetch = nil
|
||||||
|
p.Now = func() time.Time { return now.Add(time.Minute) }
|
||||||
|
p.runLanePass(context.Background(), "comix", false)
|
||||||
|
second := latestPassFor(t, s, "comix")
|
||||||
|
if second.Skip != SkipNoFetcher {
|
||||||
|
t.Fatalf("second pass skip = %q, want %q", second.Skip, SkipNoFetcher)
|
||||||
|
}
|
||||||
|
if second.GapMS != defaultGap.Milliseconds() {
|
||||||
|
t.Fatalf("second pass gap = %d, want its own %d (not carried)", second.GapMS, defaultGap.Milliseconds())
|
||||||
|
}
|
||||||
|
if second.Due != 0 || second.Checked != 0 {
|
||||||
|
t.Fatalf("second pass = %+v, want due 0 checked 0 (its own, not the previous pass's)", second)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pass row's five outcome counts are the classification the Series read
|
||||||
|
// already makes — never a second taxonomy (issue #141). Success is derived,
|
||||||
|
// never stored: checked minus the four named counts, unreachable excluded
|
||||||
|
// because its exit returns before the checked counter increments.
|
||||||
|
func TestRunLanePassCountsOutcomes(t *testing.T) {
|
||||||
|
s, _ := newTestStore(t)
|
||||||
|
now := time.UnixMilli(5_000_000)
|
||||||
|
const (
|
||||||
|
successKey = "asura:chronicles-of-the-demon-faction-f886a8af"
|
||||||
|
successURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
|
||||||
|
)
|
||||||
|
seeds := map[string]string{
|
||||||
|
"asura:refused": "https://asurascans.com/series/refused",
|
||||||
|
"asura:no-chapter": "https://asurascans.com/comics/no-chapter",
|
||||||
|
"asura:unfetchable": "https://evil.example/x",
|
||||||
|
"asura:transport": "https://asurascans.com/series/transport",
|
||||||
|
successKey: successURL,
|
||||||
|
}
|
||||||
|
for key, url := range seeds {
|
||||||
|
seedForCheck(t, s, key, url, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
f := &fakeFetcher{perURL: map[string]fakeResponse{
|
||||||
|
seeds["asura:refused"]: {status: 403},
|
||||||
|
seeds["asura:no-chapter"]: {body: "<html></html>", status: 200},
|
||||||
|
seeds["asura:transport"]: {err: errors.New("dial tcp: refused")},
|
||||||
|
successURL: {body: asuraSeriesFixture, status: 200},
|
||||||
|
}}
|
||||||
|
newTestPoller(t, s, f, now).runLanePass(context.Background(), "asura", false)
|
||||||
|
|
||||||
|
pass := latestPassFor(t, s, "asura")
|
||||||
|
if pass.Skip != "" || pass.Due != 5 || pass.Checked != 5 {
|
||||||
|
t.Fatalf("pass = %+v, want a full pass over 5 due Series", pass)
|
||||||
|
}
|
||||||
|
if pass.Refused != 1 || pass.Unreachable != 0 || pass.NoChapter != 1 ||
|
||||||
|
pass.Unfetchable != 1 || pass.Errors != 1 {
|
||||||
|
t.Fatalf("outcome counts = refused %d unreachable %d no_chapter %d unfetchable %d errors %d, want 1 0 1 1 1",
|
||||||
|
pass.Refused, pass.Unreachable, pass.NoChapter, pass.Unfetchable, pass.Errors)
|
||||||
|
}
|
||||||
|
if success := pass.Checked - (pass.Refused + pass.NoChapter + pass.Unfetchable + pass.Errors); success != 1 {
|
||||||
|
t.Fatalf("derived success = %d, want 1", success)
|
||||||
|
}
|
||||||
|
// The one genuine read went through: the success Series carries the
|
||||||
|
// fixture's newest chapter, and none of the four failures do.
|
||||||
|
b, ok, err := s.Get(s.OwnerID(), successKey)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("Get: %v ok=%v", err, ok)
|
||||||
|
}
|
||||||
|
if b.LatestChapterNum == nil || *b.LatestChapterNum != 181 {
|
||||||
|
t.Fatalf("success Series latest = %v, want 181 (the fixture's newest)", b.LatestChapterNum)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"asura:refused", "asura:no-chapter", "asura:unfetchable", "asura:transport"} {
|
||||||
|
b, _, err := s.Get(s.OwnerID(), key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get %s: %v", key, err)
|
||||||
|
}
|
||||||
|
if b.LatestChapterNum != nil {
|
||||||
|
t.Fatalf("%s latest = %v, want nil (no chapter survived a failed read)", key, *b.LatestChapterNum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refusal is the Site's mood and outlives our process: the durable stamp a
|
||||||
|
// pass writes is honoured by a freshly constructed poller, which must not
|
||||||
|
// re-probe the Site inside its backoff (issue #141).
|
||||||
|
func TestDurableRefusalSurvivesFreshPoller(t *testing.T) {
|
||||||
|
s, _ := newTestStore(t)
|
||||||
|
now := time.UnixMilli(5_000_000)
|
||||||
|
// Four due Series: the Lane refuses twice, stamps those two, and leaves
|
||||||
|
// the remaining two untried and due — the queue the fresh poller must
|
||||||
|
// find intact once the durable backoff lifts.
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
key := fmt.Sprintf("kagane:s%d", i)
|
||||||
|
seedForCheck(t, s, key, "https://kagane.to/series/"+key[7:], 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
|
||||||
|
p.BrowserFetch = &fakeFetcher{status: 403}
|
||||||
|
p.runLanePass(context.Background(), "kagane", false)
|
||||||
|
if got := p.BrowserFetch.(*fakeFetcher).callCount(); got != 2 {
|
||||||
|
t.Fatalf("fetches on the refusing pass = %d, want 2 (refused twice)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A restart: a brand-new poller, no in-memory refusal, same store. The
|
||||||
|
// durable stamp gates the pass.
|
||||||
|
fresh := newTestPoller(t, s, &fakeFetcher{status: 200}, now.Add(14*time.Minute))
|
||||||
|
fresh.BrowserFetch = &fakeFetcher{status: 403}
|
||||||
|
fresh.runLanePass(context.Background(), "kagane", false)
|
||||||
|
if got := fresh.BrowserFetch.(*fakeFetcher).callCount(); got != 0 {
|
||||||
|
t.Fatalf("fetches by a fresh poller inside the backoff = %d, want 0", got)
|
||||||
|
}
|
||||||
|
if pass := latestPassFor(t, s, "kagane"); pass.Skip != SkipRefusing {
|
||||||
|
t.Fatalf("fresh poller's pass skip = %q, want %q", pass.Skip, SkipRefusing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past the backoff the fresh poller probes again — the two Series the
|
||||||
|
// original pass never reached, still due with their stamps untouched.
|
||||||
|
fresh.Now = func() time.Time { return now.Add(16 * time.Minute) }
|
||||||
|
fresh.runLanePass(context.Background(), "kagane", false)
|
||||||
|
if got := fresh.BrowserFetch.(*fakeFetcher).callCount(); got != 2 {
|
||||||
|
t.Fatalf("fetches after the backoff = %d, want 2", got)
|
||||||
|
}
|
||||||
|
for i := 2; i < 4; i++ {
|
||||||
|
if got := readLatestCheckedAt(t, s, fmt.Sprintf("kagane:s%d", i)); got == 0 {
|
||||||
|
t.Fatalf("kagane:s%d still untried after the backoff", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pause is read ahead of the refusal check: a Lane that is both paused
|
||||||
|
// and inside a refusal backoff records the paused skip value, not the
|
||||||
|
// refusing one. The pause is the owner's order and outranks the Site's mood
|
||||||
|
// (issue #147).
|
||||||
|
func TestPauseGatePrecedesRefusalGate(t *testing.T) {
|
||||||
|
s, _ := newTestStore(t)
|
||||||
|
now := time.UnixMilli(5_000_000)
|
||||||
|
seedForCheck(t, s, "asura:x", "https://asurascans.com/series/x", 0)
|
||||||
|
if err := s.SetLaneRefusal("asura", now.Add(10*time.Minute).UnixMilli()); err != nil {
|
||||||
|
t.Fatalf("SetLaneRefusal: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.PauseLane("asura", now.Add(30*time.Minute).UnixMilli()); err != nil {
|
||||||
|
t.Fatalf("PauseLane: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
||||||
|
p := newTestPoller(t, s, f, now)
|
||||||
|
if pace := p.runLanePass(context.Background(), "asura", false); pace != 30*time.Minute {
|
||||||
|
t.Fatalf("paused-while-refusing pace = %s, want 30m (the pause's expiry)", pace)
|
||||||
|
}
|
||||||
|
if f.callCount() != 0 {
|
||||||
|
t.Fatalf("fetches while paused and refusing = %d, want 0", f.callCount())
|
||||||
|
}
|
||||||
|
if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipPaused {
|
||||||
|
t.Fatalf("skip = %q, want %q (the pause outranks the refusal)", pass.Skip, SkipPaused)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pause is a fact about the Site, not about the process: a freshly
|
||||||
|
// constructed poller against a store holding a pause row stays paused until
|
||||||
|
// the expiry, then runs the Lane normally. The restart criterion is the
|
||||||
|
// whole point of writing a row instead of commanding a poller (issue #147).
|
||||||
|
func TestDurablePauseSurvivesFreshPoller(t *testing.T) {
|
||||||
|
s, _ := newTestStore(t)
|
||||||
|
now := time.UnixMilli(5_000_000)
|
||||||
|
seedForCheck(t, s, "asura:x", "https://asurascans.com/series/x", 0)
|
||||||
|
if err := s.PauseLane("asura", now.Add(30*time.Minute).UnixMilli()); err != nil {
|
||||||
|
t.Fatalf("PauseLane: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A restart: a brand-new poller, no in-memory state, same store.
|
||||||
|
fresh := newTestPoller(t, s, &fakeFetcher{body: asuraSeriesFixture, status: 200}, now)
|
||||||
|
if pace := fresh.runLanePass(context.Background(), "asura", false); pace != 30*time.Minute {
|
||||||
|
t.Fatalf("fresh poller's paused pace = %s, want 30m", pace)
|
||||||
|
}
|
||||||
|
if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipPaused {
|
||||||
|
t.Fatalf("fresh poller's pass skip = %q, want %q", pass.Skip, SkipPaused)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past the expiry the same fresh poller runs the Lane normally.
|
||||||
|
fresh.Now = func() time.Time { return now.Add(31 * time.Minute) }
|
||||||
|
fresh.runLanePass(context.Background(), "asura", false)
|
||||||
|
if pass := latestPassFor(t, s, "asura"); pass.Skip != "" {
|
||||||
|
t.Fatalf("pass after the expiry skip = %q, want the loop reached", pass.Skip)
|
||||||
|
}
|
||||||
|
if got := readLatestCheckedAt(t, s, "asura:x"); got == 0 {
|
||||||
|
t.Fatal("the Series was not checked after the pause lifted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumeLane zeroes the pause and the Lane's next pass finds its full queue
|
||||||
|
// waiting: a pause delays work rather than discarding it, so the due Series
|
||||||
|
// sit unstamped while paused and are all fetched once the pause lifts
|
||||||
|
// (issue #147).
|
||||||
|
func TestResumeLaneRestoresTheQueue(t *testing.T) {
|
||||||
|
s, _ := newTestStore(t)
|
||||||
|
now := time.UnixMilli(5_000_000)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
seedForCheck(t, s, fmt.Sprintf("asura:s%d", i), "https://asurascans.com/series/x", 0)
|
||||||
|
}
|
||||||
|
if err := s.PauseLane("asura", now.Add(30*time.Minute).UnixMilli()); err != nil {
|
||||||
|
t.Fatalf("PauseLane: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
||||||
|
p := newTestPoller(t, s, f, now)
|
||||||
|
p.runLanePass(context.Background(), "asura", false)
|
||||||
|
if f.callCount() != 0 {
|
||||||
|
t.Fatalf("fetches while paused = %d, want 0", f.callCount())
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if got := readLatestCheckedAt(t, s, fmt.Sprintf("asura:s%d", i)); got != 0 {
|
||||||
|
t.Fatalf("asura:s%d stamp while paused = %d, want 0 (due and unstamped)", i, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.ResumeLane("asura"); err != nil {
|
||||||
|
t.Fatalf("ResumeLane: %v", err)
|
||||||
|
}
|
||||||
|
before := f.callCount()
|
||||||
|
p.runLanePass(context.Background(), "asura", false)
|
||||||
|
if got := f.callCount() - before; got != 3 {
|
||||||
|
t.Fatalf("fetches after resume = %d, want 3 (the full due queue)", got)
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if got := readLatestCheckedAt(t, s, fmt.Sprintf("asura:s%d", i)); got == 0 {
|
||||||
|
t.Fatalf("asura:s%d still untried after resume", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordLanePass prunes in the same call that inserts, so the retention
|
||||||
|
// cutoff the recorder passes is observable in what survives: a row just inside
|
||||||
|
// 14 days behind the poller's clock is kept, one just outside is pruned
|
||||||
|
// (issue #139, #141).
|
||||||
|
func TestPassRetentionCutoffIsFourteenDays(t *testing.T) {
|
||||||
|
s, dbURL := newTestStore(t)
|
||||||
|
// A real-world clock: the seeded rows sit 14 days back, so they must be
|
||||||
|
// positive timestamps or the seed's own prune (ran_at < 0) removes them.
|
||||||
|
now := time.UnixMilli(1_800_000_000_000)
|
||||||
|
kept := now.Add(-14*24*time.Hour + time.Minute).UnixMilli()
|
||||||
|
pruned := now.Add(-14*24*time.Hour - time.Minute).UnixMilli()
|
||||||
|
for _, ranAt := range []int64{kept, pruned} {
|
||||||
|
if err := s.RecordLanePass(store.LanePass{Site: "asura", RanAt: ranAt}, 0); err != nil {
|
||||||
|
t.Fatalf("seed pass at %d: %v", ranAt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedForCheck(t, s, "asura:x", "https://asurascans.com/series/x", 0)
|
||||||
|
newTestPoller(t, s, &fakeFetcher{body: asuraSeriesFixture, status: 200}, now).
|
||||||
|
runLanePass(context.Background(), "asura", false)
|
||||||
|
|
||||||
|
db, err := sql.Open("pgx", dbURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open %s: %v", dbURL, err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
var n int
|
||||||
|
if err := db.QueryRow(`SELECT count(*) FROM poll_passes WHERE site = $1 AND ran_at = $2`, "asura", pruned).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count pruned row: %v", err)
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("row at %d survived, want it pruned (older than 14 days)", pruned)
|
||||||
|
}
|
||||||
|
if err := db.QueryRow(`SELECT count(*) FROM poll_passes WHERE site = $1`, "asura").Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count passes: %v", err)
|
||||||
|
}
|
||||||
|
if n != 2 {
|
||||||
|
t.Fatalf("passes = %d, want 2 (this pass plus the kept row)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stampRecordingFetcher is a fakeFetcher that records the Series' check stamp
|
||||||
|
// at call time — the seam that proves the check stamp is written before the
|
||||||
|
// fetch (issue #146). If the order were swapped, the recorded stamp would be
|
||||||
|
// the pre-pass value and the ordering assertion would fail.
|
||||||
|
type stampRecordingFetcher struct {
|
||||||
|
fakeFetcher
|
||||||
|
store *store.Store
|
||||||
|
site string
|
||||||
|
seriesID string
|
||||||
|
stampAtCall int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *stampRecordingFetcher) Get(ctx context.Context, url string) (string, int, error) {
|
||||||
|
ts, err := f.store.LatestCheckedAt(f.site, f.seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
f.stampAtCall = ts
|
||||||
|
return f.fakeFetcher.Get(ctx, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The check stamp is written before the fetch is attempted: the fake fetcher
|
||||||
|
// records the stamp it sees at call time, and it must already be the pass's
|
||||||
|
// own stamp. The order is load-bearing — a forced request is pending while
|
||||||
|
// force_poll_at > latest_checked_at, so stamping after the fetch would make a
|
||||||
|
// failed forced request sticky — and the assertion fails if it is swapped.
|
||||||
|
func TestCheckStampIsWrittenBeforeFetch(t *testing.T) {
|
||||||
|
s, _ := newTestStore(t)
|
||||||
|
const (
|
||||||
|
key = "asura:chronicles-of-the-demon-faction-f886a8af"
|
||||||
|
seriesURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
|
||||||
|
)
|
||||||
|
seedForCheck(t, s, key, seriesURL, 0)
|
||||||
|
now := time.UnixMilli(7_000_000)
|
||||||
|
rec := &stampRecordingFetcher{
|
||||||
|
fakeFetcher: fakeFetcher{body: asuraSeriesFixture, status: 200},
|
||||||
|
store: s, site: "asura", seriesID: "chronicles-of-the-demon-faction-f886a8af",
|
||||||
|
}
|
||||||
|
newTestPoller(t, s, rec, now).runOnce(context.Background())
|
||||||
|
if rec.stampAtCall != now.UnixMilli() {
|
||||||
|
t.Fatalf("check stamp at fetch time = %d, want %d (the stamp must be written before the fetch)", rec.stampAtCall, now.UnixMilli())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A forced Series whose attempt fails is no longer pending: the check stamp
|
||||||
|
// is written before the fetch, so the first attempt ends the pending state
|
||||||
|
// whatever it returns. A naive implementation (stamp only on success, or
|
||||||
|
// after the fetch) leaves the request sticky and the row due next pass.
|
||||||
|
func TestForcedSeriesSelfClearsOnFailedAttempt(t *testing.T) {
|
||||||
|
s, _ := newTestStore(t)
|
||||||
|
now := time.UnixMilli(7_000_000)
|
||||||
|
const url = "https://asurascans.com/comics/x"
|
||||||
|
// Freshly checked, so only the force flag makes it due.
|
||||||
|
seedForCheck(t, s, "asura:x", url, now.Add(-30*time.Minute).UnixMilli())
|
||||||
|
if err := s.ForceSeriesPoll("asura", "x", now.UnixMilli()); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
due, err := s.DueForLatestCheck("asura", now.Add(-time.Hour).UnixMilli(), -1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DueForLatestCheck: %v", err)
|
||||||
|
}
|
||||||
|
if len(due) != 1 || due[0].Key() != "asura:x" || !due[0].Forced {
|
||||||
|
t.Fatalf("forced row not due and flagged before the attempt: %+v", due)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The attempt fails, but the attempt still happened: the row is stamped
|
||||||
|
// and no longer pending.
|
||||||
|
f := &fakeFetcher{err: errors.New("dial tcp: refused")}
|
||||||
|
newTestPoller(t, s, f, now).runOnce(context.Background())
|
||||||
|
if got := readLatestCheckedAt(t, s, "asura:x"); got != now.UnixMilli() {
|
||||||
|
t.Fatalf("latest_checked_at = %d, want %d", got, now.UnixMilli())
|
||||||
|
}
|
||||||
|
due, err = s.DueForLatestCheck("asura", now.Add(-time.Hour).UnixMilli(), -1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DueForLatestCheck: %v", err)
|
||||||
|
}
|
||||||
|
if len(due) != 0 {
|
||||||
|
t.Fatalf("failed attempt left the forced row due: %v", due)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The refusal backoff is a Lane gate, not a Series gate: a forced Series does
|
||||||
|
// not override a Site that is actively refusing, because hand-forcing a
|
||||||
|
// request into a refusal only makes it worse (issue #146).
|
||||||
|
func TestForcedSeriesDoesNotOverrideRefusalBackoff(t *testing.T) {
|
||||||
|
s, _ := newTestStore(t)
|
||||||
|
now := time.UnixMilli(5_000_000)
|
||||||
|
seedForCheck(t, s, "kagane:w", "https://kagane.to/series/w", 0)
|
||||||
|
if err := s.ForceSeriesPoll("kagane", "w", now.UnixMilli()); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.SetLaneRefusal("kagane", now.Add(RefuseBackoff).UnixMilli()); err != nil {
|
||||||
|
t.Fatalf("SetLaneRefusal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
browser := &fakeFetcher{body: kaganeAPIFixture, status: 200}
|
||||||
|
p := &Poller{
|
||||||
|
Store: s, Fetch: &fakeFetcher{status: 200}, BrowserFetch: browser,
|
||||||
|
Now: func() time.Time { return now },
|
||||||
}
|
}
|
||||||
p.runOnce(context.Background())
|
p.runOnce(context.Background())
|
||||||
for _, lane := range p.LaneStatus().Lanes {
|
if got := browser.callCount(); got != 0 {
|
||||||
if lane.Site != "kagane" {
|
t.Fatalf("browser fetches through a refusal backoff = %d, want 0", got)
|
||||||
continue
|
|
||||||
}
|
|
||||||
if lane.Due != before.Due || lane.Gap != before.Gap {
|
|
||||||
t.Fatalf("kagane after a skipped pass = due %d gap %s, want the previous pass's %d / %s",
|
|
||||||
lane.Due, lane.Gap, before.Due, before.Gap)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A lost sidecar reads as unreachable for the same window the Lanes skip.
|
// A forced Series wakes a sleeping browser Lane: the wake thresholds exist to
|
||||||
p.setBrowserDown(now)
|
// stop the machine waking itself for one unattended check, and a human asking
|
||||||
if st := p.LaneStatus(); !st.BrowserConfigured || st.BrowserReachable {
|
// is not that (issue #146). Below both thresholds the Lane still sleeps when
|
||||||
t.Fatalf("browser after loss = configured=%v reachable=%v, want true/false", st.BrowserConfigured, st.BrowserReachable)
|
// nothing is forced.
|
||||||
|
func TestForcedSeriesWakesSleepingBrowser(t *testing.T) {
|
||||||
|
s, _ := newTestStore(t)
|
||||||
|
now := time.UnixMilli(5_000_000)
|
||||||
|
// Freshly due: checked two minutes before the rest elapses, so the wait
|
||||||
|
// is far below browserWakeAge and the count is under browserWakeCount.
|
||||||
|
seedForCheck(t, s, "kagane:w1", "https://kagane.to/series/w1", now.Add(-62*time.Minute).UnixMilli())
|
||||||
|
seedForCheck(t, s, "kagane:w2", "https://kagane.to/series/w2", now.Add(-62*time.Minute).UnixMilli())
|
||||||
|
|
||||||
|
browser := &fakeFetcher{body: kaganeAPIFixture, status: 200}
|
||||||
|
p := &Poller{
|
||||||
|
Store: s, Fetch: &fakeFetcher{status: 200}, BrowserFetch: browser,
|
||||||
|
Now: func() time.Time { return now },
|
||||||
|
}
|
||||||
|
p.runOnce(context.Background())
|
||||||
|
if got := browser.callCount(); got != 0 {
|
||||||
|
t.Fatalf("browser fetches without a forced series = %d, want 0 (Chrome stays asleep)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.ForceSeriesPoll("kagane", "w1", now.UnixMilli()); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||||
|
}
|
||||||
|
p.runOnce(context.Background())
|
||||||
|
if got := browser.callCount(); got != 2 {
|
||||||
|
t.Fatalf("browser fetches with a forced series = %d, want 2 (the lane wakes)", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -511,6 +512,18 @@ var sites = map[string]site{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SiteNames returns every registry Site, sorted. The admin Series list's Site
|
||||||
|
// select needs the full registry, not just the Sites that have rows, and
|
||||||
|
// laneNames() is the poller's copy of the same list — both read this.
|
||||||
|
func SiteNames() []string {
|
||||||
|
names := make([]string, 0, len(sites))
|
||||||
|
for name := range sites {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
// browserBackedSites is derived from the registry: the Sites whose pages are
|
// browserBackedSites is derived from the registry: the Sites whose pages are
|
||||||
// read through the browser sidecar. Sorted so callers that range it (the
|
// read through the browser sidecar. Sorted so callers that range it (the
|
||||||
// browser fetcher's dispatch) see a stable order instead of map-iteration
|
// browser fetcher's dispatch) see a stable order instead of map-iteration
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Series filter names (issue #140), ordered permanent-then-fixable — the
|
||||||
|
// repairs nothing will ever undo first, the ones a Poll can make right after.
|
||||||
|
// A name is the repair a row needs, not the SQL that finds it; the values are
|
||||||
|
// the wire form the Series list URL carries (#142). "all" is the absent and
|
||||||
|
// unknown case: every Series.
|
||||||
|
const (
|
||||||
|
SeriesFilterAll = "all"
|
||||||
|
SeriesFilterNoURL = "no_series_url"
|
||||||
|
SeriesFilterNoChapter = "never_read_a_chapter"
|
||||||
|
SeriesFilterNoReaders = "no_readers"
|
||||||
|
SeriesFilterNeverChecked = "never_checked"
|
||||||
|
SeriesFilterStale = "stale"
|
||||||
|
SeriesFilterNoCover = "no_cover"
|
||||||
|
SeriesFilterReaderReport = "reader_report"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SeriesFilter is one named hygiene predicate over the whole library. Site
|
||||||
|
// and Kind narrow the row read; Name picks the predicate; Cutoff is the
|
||||||
|
// staleness boundary the "stale" filter compares against, supplied by the
|
||||||
|
// caller's clock — the store has no clock; Page is 1-based.
|
||||||
|
type SeriesFilter struct {
|
||||||
|
Site string // "" = every Site
|
||||||
|
Kind string // "" = both library buckets' series
|
||||||
|
Name string // one of the SeriesFilter* constants; "" = SeriesFilterAll
|
||||||
|
Cutoff int64 // unix ms; "stale" reads it, the store never does
|
||||||
|
Page int // 1-based page of the row read; default 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminSeriesColumns is the owner's library-wide Series projection in
|
||||||
|
// scanAdminSeries order. It is the privacy boundary: a Series' row carries
|
||||||
|
// the Reader id that raised its Latest Chapter (latest_raised_by), and that id
|
||||||
|
// must never leave the store package — so the projection does not select it,
|
||||||
|
// and only the anonymous boolean in raisedByReaderAnswer crosses it.
|
||||||
|
const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_address,
|
||||||
|
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.force_poll_at`
|
||||||
|
|
||||||
|
// raisedByReaderAnswer answers "did a Reader's report set this number" without
|
||||||
|
// naming which Reader. Kept apart from adminSeriesColumns so the column list —
|
||||||
|
// the shape scanAdminSeries is fed — stays free of the Sighting-raiser
|
||||||
|
// identity, and the owner learns which rows to act on and nothing about the
|
||||||
|
// Reader behind them.
|
||||||
|
const raisedByReaderAnswer = `(s.latest_raised_by IS NOT NULL) AS raised_by_reader`
|
||||||
|
|
||||||
|
// seriesPageSize is the row read's page length. The tie-break in the query's
|
||||||
|
// ORDER BY is what makes this a stable page boundary — see SeriesPage.
|
||||||
|
const seriesPageSize = 50
|
||||||
|
|
||||||
|
// AdminSeries is one Series as the owner's library-wide view sees it: a
|
||||||
|
// Series-level fact plus an anonymous Reader count. ReaderCount being zero is
|
||||||
|
// the orphan marker. RaisedByReader is the only trace of the Sighting
|
||||||
|
// mechanism here; the Reader id behind it never reaches this type.
|
||||||
|
type AdminSeries struct {
|
||||||
|
Site string
|
||||||
|
SeriesID string
|
||||||
|
Title string
|
||||||
|
SeriesURL string
|
||||||
|
CoverAddress string // "" = no Cover yet
|
||||||
|
Kind string
|
||||||
|
LatestChapter string
|
||||||
|
LatestChapterNum *float64 // nil until first captured
|
||||||
|
LatestCheckedAt int64
|
||||||
|
// ForcePollAt is the owner's "check now" request stamp (issue #146), zero
|
||||||
|
// meaning never asked. Pending is derived, never stored: a request is
|
||||||
|
// pending while ForcePollAt is newer than LatestCheckedAt.
|
||||||
|
ForcePollAt int64
|
||||||
|
ReaderCount int
|
||||||
|
RaisedByReader bool // a Reader's report set LatestChapterNum
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeriesPage is one page of the owner's filtered Series list plus the count
|
||||||
|
// of every Series matching the same filter — a window number, not the page's
|
||||||
|
// len, so the landing page's figure and the list heading come from one query.
|
||||||
|
type SeriesPage struct {
|
||||||
|
Rows []AdminSeries
|
||||||
|
Total int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SiteSeriesShape is one Site's share of the Series matching a filter: how
|
||||||
|
// many, and the manga/novel split. One grouped pass, then library-wide totals
|
||||||
|
// are summed in Go over the rows — the landing page's per-Site table reads
|
||||||
|
// this and never pays for the rows the list discards.
|
||||||
|
type SiteSeriesShape struct {
|
||||||
|
Site string
|
||||||
|
Total int
|
||||||
|
Manga int
|
||||||
|
Novel int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key returns the canonical identity in bookmark-key form ("<site>:<series_id>").
|
||||||
|
func (a AdminSeries) Key() string { return a.Site + ":" + a.SeriesID }
|
||||||
|
|
||||||
|
// adminFilter maps a filter's named predicate to its compile-time WHERE and
|
||||||
|
// HAVING clauses and their bound parameters — the name never reaches query
|
||||||
|
// text, and Site and Kind bind as parameters. Shared by the row read and the
|
||||||
|
// per-Site aggregate so the two cannot disagree on what a filter means.
|
||||||
|
//
|
||||||
|
// The WHERE set is: no URL (an empty URL only — the host-failing-the-fetch-
|
||||||
|
// gate case is invisible to SQL, needs the Site registry in Go, and belongs to
|
||||||
|
// a later repair), never-read-a-chapter and never-checked as disjoint halves
|
||||||
|
// (non-zero versus zero check stamp), stale, no cover, and Reader-report.
|
||||||
|
// no_readers is the one HAVING predicate: it is the orphan test, an aggregate
|
||||||
|
// over the LEFT JOIN, where a bare WHERE has no row to test.
|
||||||
|
//
|
||||||
|
// stale is the checked-but-old half of the stamp partition — because the
|
||||||
|
// verdict line wants "not checked in twelve hours" as one figure, and a never
|
||||||
|
// checked Series is already counted on its own "waiting"/never-checked
|
||||||
|
// filter, folding it in would double-report it. The landing page computes the
|
||||||
|
// inclusive number as stale + never-checked.
|
||||||
|
func adminFilter(f SeriesFilter) (where, having string, args []any, err error) {
|
||||||
|
var clauses []string
|
||||||
|
if f.Kind != "" {
|
||||||
|
args = append(args, f.Kind)
|
||||||
|
clauses = append(clauses, "s.kind = $"+strconv.Itoa(len(args)))
|
||||||
|
}
|
||||||
|
switch f.Name {
|
||||||
|
case "", SeriesFilterAll:
|
||||||
|
case SeriesFilterNoURL:
|
||||||
|
clauses = append(clauses, `s.series_url = ''`)
|
||||||
|
case SeriesFilterNoChapter:
|
||||||
|
clauses = append(clauses, `s.latest_checked_at <> 0 AND s.latest_chapter_num IS NULL`)
|
||||||
|
case SeriesFilterNeverChecked:
|
||||||
|
clauses = append(clauses, `s.latest_checked_at = 0`)
|
||||||
|
case SeriesFilterStale:
|
||||||
|
clauses = append(clauses, `s.latest_checked_at > 0 AND s.latest_checked_at < $`+strconv.Itoa(len(args)+1))
|
||||||
|
args = append(args, f.Cutoff)
|
||||||
|
case SeriesFilterNoCover:
|
||||||
|
clauses = append(clauses, `s.cover_address = ''`)
|
||||||
|
case SeriesFilterReaderReport:
|
||||||
|
clauses = append(clauses, `s.latest_raised_by IS NOT NULL`)
|
||||||
|
case SeriesFilterNoReaders:
|
||||||
|
having = `HAVING COUNT(b.reader_id) = 0`
|
||||||
|
default:
|
||||||
|
return "", "", nil, fmt.Errorf("unknown series filter %q", f.Name)
|
||||||
|
}
|
||||||
|
if len(clauses) > 0 {
|
||||||
|
where = "WHERE " + strings.Join(clauses, " AND ")
|
||||||
|
}
|
||||||
|
return where, having, args, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeriesPage returns one page of the Series matching the filter, least
|
||||||
|
// recently checked first. The LEFT JOIN to Bookmarks is what surfaces the
|
||||||
|
// orphans that hygiene has to find — an inner join would hide them, exactly
|
||||||
|
// as the Lane's join does. ReaderCount is a plain count of every Bookmark on
|
||||||
|
// the Series, which knowingly disagrees with the two Lane queries for as long
|
||||||
|
// as the finished lifecycle bucket exists (#140).
|
||||||
|
//
|
||||||
|
// The tie-break is mandatory, not decorative: every unpollable Series shares a
|
||||||
|
// zero check stamp, so ordering on that column alone gives no stable page
|
||||||
|
// boundary and rows would repeat or vanish across pages. (site, series_id) is
|
||||||
|
// the primary key, hence total. The filtered total is a window count in the
|
||||||
|
// same query — window functions run after grouping and before the limit, so
|
||||||
|
// one where-clause cannot disagree with a second copy of itself.
|
||||||
|
func (s *Store) SeriesPage(f SeriesFilter) (SeriesPage, error) {
|
||||||
|
where, having, args, err := adminFilter(f)
|
||||||
|
if err != nil {
|
||||||
|
return SeriesPage{}, err
|
||||||
|
}
|
||||||
|
if f.Page < 1 {
|
||||||
|
f.Page = 1
|
||||||
|
}
|
||||||
|
// Site narrowing is the row read's own; the aggregate must see every Site.
|
||||||
|
if f.Site != "" {
|
||||||
|
args = append(args, f.Site)
|
||||||
|
clause := "s.site = $" + strconv.Itoa(len(args))
|
||||||
|
if where == "" {
|
||||||
|
where = "WHERE " + clause
|
||||||
|
} else {
|
||||||
|
where += " AND " + clause
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base := len(args)
|
||||||
|
args = append(args, seriesPageSize, seriesPageSize*(f.Page-1))
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT `+adminSeriesColumns+`, `+raisedByReaderAnswer+`,
|
||||||
|
COUNT(b.reader_id) AS reader_count,
|
||||||
|
COUNT(*) OVER () AS filtered_total
|
||||||
|
FROM series s
|
||||||
|
LEFT JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
|
||||||
|
`+where+`
|
||||||
|
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover_address,
|
||||||
|
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
|
||||||
|
s.force_poll_at, s.latest_raised_by
|
||||||
|
`+having+`
|
||||||
|
ORDER BY s.latest_checked_at, s.site, s.series_id
|
||||||
|
LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...)
|
||||||
|
if err != nil {
|
||||||
|
return SeriesPage{}, fmt.Errorf("query series page: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := SeriesPage{}
|
||||||
|
for rows.Next() {
|
||||||
|
a, total, err := scanAdminSeries(rows.Scan)
|
||||||
|
if err != nil {
|
||||||
|
return SeriesPage{}, fmt.Errorf("scan series page: %w", err)
|
||||||
|
}
|
||||||
|
out.Rows = append(out.Rows, a)
|
||||||
|
out.Total = total
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeriesShapes returns each Site's share of the Series matching the filter,
|
||||||
|
// one grouped pass. Site and Page are row-read concerns and are ignored; the
|
||||||
|
// Landing page reads this per Site and sums the totals in Go for the
|
||||||
|
// library-wide figure.
|
||||||
|
func (s *Store) SeriesShapes(f SeriesFilter) ([]SiteSeriesShape, error) {
|
||||||
|
where, having, args, err := adminFilter(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT site,
|
||||||
|
COUNT(*) AS total,
|
||||||
|
COUNT(*) FILTER (WHERE kind = 'manga') AS manga,
|
||||||
|
COUNT(*) FILTER (WHERE kind = 'novel') AS novel
|
||||||
|
FROM (
|
||||||
|
SELECT s.site, s.kind
|
||||||
|
FROM series s
|
||||||
|
LEFT JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
|
||||||
|
`+where+`
|
||||||
|
GROUP BY s.site, s.series_id, s.kind
|
||||||
|
`+having+`
|
||||||
|
) shape
|
||||||
|
GROUP BY site
|
||||||
|
ORDER BY site`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query series shapes: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []SiteSeriesShape{}
|
||||||
|
for rows.Next() {
|
||||||
|
var sh SiteSeriesShape
|
||||||
|
if err := rows.Scan(&sh.Site, &sh.Total, &sh.Manga, &sh.Novel); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan series shape: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, sh)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanAdminSeries reads one row in adminSeriesColumns + raisedByReaderAnswer
|
||||||
|
// order, plus the query's reader_count and filtered_total columns, and returns
|
||||||
|
// the window total alongside the row. latest_chapter_num is NULL until first
|
||||||
|
// captured — the "never read a chapter" state. The Sighting-raiser column is
|
||||||
|
// never among the scanned columns.
|
||||||
|
func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) {
|
||||||
|
var (
|
||||||
|
a AdminSeries
|
||||||
|
latestChapterNum sql.NullFloat64
|
||||||
|
total int
|
||||||
|
)
|
||||||
|
if err := scan(
|
||||||
|
&a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress,
|
||||||
|
&a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt,
|
||||||
|
&a.ForcePollAt,
|
||||||
|
&a.RaisedByReader, &a.ReaderCount, &total,
|
||||||
|
); err != nil {
|
||||||
|
return AdminSeries{}, 0, err
|
||||||
|
}
|
||||||
|
if latestChapterNum.Valid {
|
||||||
|
a.LatestChapterNum = &latestChapterNum.Float64
|
||||||
|
}
|
||||||
|
return a, total, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seriesSeed describes one Series (and optionally its bookmarks) to stand up
|
||||||
|
// for an admin filter test. Direct SQL, because the filters separate rows the
|
||||||
|
// Upsert path could not produce together: an orphan has no bookmark, and a
|
||||||
|
// Reader-raised Latest Chapter needs a Sighting the store does not create.
|
||||||
|
type seriesSeed struct {
|
||||||
|
key string
|
||||||
|
kind string
|
||||||
|
url string
|
||||||
|
cover string // cover_address
|
||||||
|
checkedAt int64
|
||||||
|
latestNum *float64
|
||||||
|
bookmarks int // readers that hold it; 0 = orphan
|
||||||
|
raisedBy bool // a Reader's report is attributed as the raiser
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedAdminSeries inserts one series row and its bookmarks (owner first, then
|
||||||
|
// fresh readers), with the exact admin-relevant facts a test needs.
|
||||||
|
func seedAdminSeries(t *testing.T, s *Store, seed seriesSeed) {
|
||||||
|
t.Helper()
|
||||||
|
site, seriesID, ok := strings.Cut(seed.key, ":")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("key %q: no ':' separator", seed.key)
|
||||||
|
}
|
||||||
|
if seed.kind == "" {
|
||||||
|
seed.kind = "manga"
|
||||||
|
}
|
||||||
|
var latestChapter any = ""
|
||||||
|
if seed.latestNum != nil {
|
||||||
|
latestChapter = "Chapter " + strconv.FormatFloat(*seed.latestNum, 'f', -1, 64)
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(`
|
||||||
|
INSERT INTO series (site, series_id, title, kind, series_url, cover_address,
|
||||||
|
latest_checked_at, latest_chapter, latest_chapter_num)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
|
site, seriesID, "Title of "+seed.key, seed.kind, seed.url, seed.cover,
|
||||||
|
seed.checkedAt, latestChapter, seed.latestNum); err != nil {
|
||||||
|
t.Fatalf("seed series %q: %v", seed.key, err)
|
||||||
|
}
|
||||||
|
for i := range seed.bookmarks {
|
||||||
|
var readerID int64 = s.OwnerID()
|
||||||
|
if i > 0 {
|
||||||
|
readerID = secondReader(t, s)
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(`
|
||||||
|
INSERT INTO bookmarks (reader_id, site, series_id,
|
||||||
|
last_chapter, last_chapter_num, last_chapter_url,
|
||||||
|
favorite, status, updated_at)
|
||||||
|
VALUES ($1, $2, $3, '', 0, '', false, 'reading', $4)`,
|
||||||
|
readerID, site, seriesID, seed.checkedAt); err != nil {
|
||||||
|
t.Fatalf("seed bookmark %q: %v", seed.key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if seed.raisedBy {
|
||||||
|
if _, err := s.db.Exec(
|
||||||
|
`UPDATE series SET latest_raised_by = $1 WHERE site = $2 AND series_id = $3`,
|
||||||
|
s.OwnerID(), site, seriesID); err != nil {
|
||||||
|
t.Fatalf("seed raised-by %q: %v", seed.key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pageKeys(t *testing.T, s *Store, f SeriesFilter) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
page, err := s.SeriesPage(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SeriesPage(%+v): %v", f, err)
|
||||||
|
}
|
||||||
|
keys := map[string]bool{}
|
||||||
|
for _, a := range page.Rows {
|
||||||
|
keys[a.Key()] = true
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each filter must return the rows it names and no others, over one shared
|
||||||
|
// seeded mix where every healthy neighbour is present to be wrongly returned.
|
||||||
|
// The stale cutoff is 5000: a Series checked at 9000 is current, at 2000 stale.
|
||||||
|
func TestAdminSeriesFilters(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:healthy", url: "https://asurascans.com/comics/healthy", cover: "aaa", checkedAt: 9000, latestNum: new(10.0), bookmarks: 1})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:nourl", url: "", cover: "bbb", checkedAt: 9000, latestNum: new(5.0), bookmarks: 1})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:nochapter", url: "https://asurascans.com/comics/nochapter", cover: "ccc", checkedAt: 9000, bookmarks: 1})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:neverchecked", url: "https://asurascans.com/comics/neverchecked", cover: "ddd", checkedAt: 0, bookmarks: 1})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:orphan", url: "https://asurascans.com/comics/orphan", cover: "eee", checkedAt: 9000, latestNum: new(7.0), bookmarks: 0})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:stale", url: "https://asurascans.com/comics/stale", cover: "fff", checkedAt: 2000, latestNum: new(4.0), bookmarks: 1})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:nocover", url: "https://asurascans.com/comics/nocover", checkedAt: 9000, latestNum: new(9.0), bookmarks: 1})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:report", url: "https://asurascans.com/comics/report", cover: "ggg", checkedAt: 9000, latestNum: new(8.0), bookmarks: 1, raisedBy: true})
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
f SeriesFilter
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"all", SeriesFilter{}, []string{"asura:healthy", "asura:nourl", "asura:nochapter", "asura:neverchecked", "asura:orphan", "asura:stale", "asura:nocover", "asura:report"}},
|
||||||
|
{"no series url", SeriesFilter{Name: SeriesFilterNoURL}, []string{"asura:nourl"}},
|
||||||
|
{"never read a chapter", SeriesFilter{Name: SeriesFilterNoChapter}, []string{"asura:nochapter"}},
|
||||||
|
{"never checked", SeriesFilter{Name: SeriesFilterNeverChecked}, []string{"asura:neverchecked"}},
|
||||||
|
{"no readers", SeriesFilter{Name: SeriesFilterNoReaders}, []string{"asura:orphan"}},
|
||||||
|
{"stale", SeriesFilter{Name: SeriesFilterStale, Cutoff: 5000}, []string{"asura:stale"}},
|
||||||
|
{"no cover", SeriesFilter{Name: SeriesFilterNoCover}, []string{"asura:nocover"}},
|
||||||
|
{"reader report", SeriesFilter{Name: SeriesFilterReaderReport}, []string{"asura:report"}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := pageKeys(t, s, tc.f)
|
||||||
|
want := map[string]bool{}
|
||||||
|
for _, k := range tc.want {
|
||||||
|
want[k] = true
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("%+v returned %v, want exactly %v", tc.f, got, want)
|
||||||
|
}
|
||||||
|
for k := range want {
|
||||||
|
if !got[k] {
|
||||||
|
t.Fatalf("%+v dropped %q (got %v)", tc.f, k, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Never read a chapter" and "never checked" are disjoint by construction:
|
||||||
|
// the first requires a non-zero check stamp, the second a zero one. Over a
|
||||||
|
// mix that should satisfy both, no row may be counted twice.
|
||||||
|
func TestAdminNeverChapterAndNeverCheckedAreDisjoint(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:nochapter", url: "u", checkedAt: 9000, bookmarks: 1})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:neverchecked", url: "u", checkedAt: 0, bookmarks: 1})
|
||||||
|
// A zero-stamp, no-chapter row is never-checked only: if never-read-a-
|
||||||
|
// chapter ever lost its non-zero-stamp guard, it would claim this row too
|
||||||
|
// and the two counts would double-report it.
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:both", url: "u", checkedAt: 0, bookmarks: 1})
|
||||||
|
noChapter := pageKeys(t, s, SeriesFilter{Name: SeriesFilterNoChapter})
|
||||||
|
neverChecked := pageKeys(t, s, SeriesFilter{Name: SeriesFilterNeverChecked})
|
||||||
|
for k := range noChapter {
|
||||||
|
if neverChecked[k] {
|
||||||
|
t.Fatalf("row %q matches both never-read-a-chapter and never-checked", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !noChapter["asura:nochapter"] || !neverChecked["asura:neverchecked"] {
|
||||||
|
t.Fatalf("disjoint split lost its own rows: no-chapter=%v never-checked=%v", noChapter, neverChecked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Several rows share a zero check stamp, so ordering on latest_checked_at
|
||||||
|
// alone gives no stable page boundary. The (site, series_id) tie-break must
|
||||||
|
// make page 2 a strict continuation of page 1: no repeat, no vanishing row.
|
||||||
|
func TestAdminSeriesPageTieBreakIsStable(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
const total = 53 // > one page, < two (page size 50)
|
||||||
|
for i := range total {
|
||||||
|
id := "tie" + strconv.Itoa(i)
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:" + id, url: "u", checkedAt: 0, bookmarks: 1})
|
||||||
|
}
|
||||||
|
// A second Site's zero-stamp row is part of the same all-filter list, and
|
||||||
|
// must land on a valid page boundary rather than duplicating or dropping
|
||||||
|
// one of asura's rows: the tie-break is global (site, series_id).
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "demonic:z", url: "u", checkedAt: 0, bookmarks: 1})
|
||||||
|
wantTotal := total + 1
|
||||||
|
|
||||||
|
p1, err := s.SeriesPage(SeriesFilter{Name: SeriesFilterAll})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SeriesPage page 1: %v", err)
|
||||||
|
}
|
||||||
|
p2, err := s.SeriesPage(SeriesFilter{Name: SeriesFilterAll, Page: 2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SeriesPage page 2: %v", err)
|
||||||
|
}
|
||||||
|
if len(p1.Rows) != seriesPageSize {
|
||||||
|
t.Fatalf("page 1 has %d rows, want %d", len(p1.Rows), seriesPageSize)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, a := range append(append([]AdminSeries{}, p1.Rows...), p2.Rows...) {
|
||||||
|
if seen[a.Key()] {
|
||||||
|
t.Fatalf("row %q repeats across pages", a.Key())
|
||||||
|
}
|
||||||
|
seen[a.Key()] = true
|
||||||
|
}
|
||||||
|
if len(seen) != wantTotal {
|
||||||
|
t.Fatalf("%d distinct rows across pages, want %d (a row vanished)", len(seen), wantTotal)
|
||||||
|
}
|
||||||
|
if p1.Total != wantTotal {
|
||||||
|
t.Fatalf("page total = %d, want %d (the window count must span pages)", p1.Total, wantTotal)
|
||||||
|
}
|
||||||
|
// A page beyond the end is empty, not an error (the list re-reads page 1).
|
||||||
|
// The window count runs over the rows present in the result set, so an
|
||||||
|
// overflow page has no rows and therefore no total — the caller must not
|
||||||
|
// render it, which is exactly why the list re-reads page 1.
|
||||||
|
pFinal, err := s.SeriesPage(SeriesFilter{Name: SeriesFilterAll, Page: 99})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SeriesPage beyond end: %v", err)
|
||||||
|
}
|
||||||
|
if len(pFinal.Rows) != 0 {
|
||||||
|
t.Fatalf("page beyond end = %d rows, want 0", len(pFinal.Rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The filtered total is the window number over the same filter the rows use,
|
||||||
|
// and the per-Site aggregate sums to the same figure — so the landing page's
|
||||||
|
// count and the list's heading can never disagree, whoever computes them.
|
||||||
|
func TestAdminTotalAgreesWithRowsAndShapes(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:a", url: "u", cover: "a", checkedAt: 9000, bookmarks: 1})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:b", url: "u", checkedAt: 9000, bookmarks: 1})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:c", url: "u", checkedAt: 9000, bookmarks: 1, latestNum: new(2.0), raisedBy: true})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "demonic:d", url: "u", kind: "novel", checkedAt: 9000, bookmarks: 1})
|
||||||
|
|
||||||
|
filters := []SeriesFilter{
|
||||||
|
{},
|
||||||
|
{Name: SeriesFilterNoCover},
|
||||||
|
{Name: SeriesFilterReaderReport},
|
||||||
|
{Name: SeriesFilterNoChapter},
|
||||||
|
}
|
||||||
|
for _, f := range filters {
|
||||||
|
page, err := s.SeriesPage(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SeriesPage(%+v): %v", f, err)
|
||||||
|
}
|
||||||
|
want := len(page.Rows)
|
||||||
|
if f.Page == 0 && want == seriesPageSize {
|
||||||
|
t.Fatalf("seed produced a full page; bump the seed or drop page size in the test")
|
||||||
|
}
|
||||||
|
if page.Total != want {
|
||||||
|
t.Fatalf("%+v total = %d, want %d (window count disagrees with row count)", f, page.Total, want)
|
||||||
|
}
|
||||||
|
shapes, err := s.SeriesShapes(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SeriesShapes(%+v): %v", f, err)
|
||||||
|
}
|
||||||
|
sum := 0
|
||||||
|
for _, sh := range shapes {
|
||||||
|
sum += sh.Total
|
||||||
|
}
|
||||||
|
if sum != want {
|
||||||
|
t.Fatalf("%+v aggregate sum = %d, want %d (aggregate disagrees with row query)", f, sum, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The default filter's aggregate carries the library shape: per-Site
|
||||||
|
// totals and the manga/novel split, summed in Go for library wide.
|
||||||
|
shapes, err := s.SeriesShapes(SeriesFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SeriesShapes default: %v", err)
|
||||||
|
}
|
||||||
|
if len(shapes) != 2 || shapes[0].Site != "asura" || shapes[1].Site != "demonic" {
|
||||||
|
t.Fatalf("shapes = %+v, want asura then demonic", shapes)
|
||||||
|
}
|
||||||
|
if shapes[0].Total != 3 || shapes[0].Manga != 3 || shapes[0].Novel != 0 {
|
||||||
|
t.Fatalf("asura shape = %+v, want 3 manga, 0 novel", shapes[0])
|
||||||
|
}
|
||||||
|
if shapes[1].Total != 1 || shapes[1].Manga != 0 || shapes[1].Novel != 1 {
|
||||||
|
t.Fatalf("demonic shape = %+v, want 1 novel", shapes[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Site and Library narrowing stack on a named filter without changing what
|
||||||
|
// the filter means.
|
||||||
|
func TestAdminFilterSiteAndKindNarrow(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:aa", url: "u", checkedAt: 9000, bookmarks: 1, latestNum: new(1.0)})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:ab", url: "", checkedAt: 9000, bookmarks: 1})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "demonic:aa", url: "u", kind: "novel", checkedAt: 9000, bookmarks: 1})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "demonic:ab", url: "", kind: "novel", checkedAt: 9000, bookmarks: 1})
|
||||||
|
|
||||||
|
got := pageKeys(t, s, SeriesFilter{Name: SeriesFilterNoURL, Site: "asura"})
|
||||||
|
if len(got) != 1 || !got["asura:ab"] {
|
||||||
|
t.Fatalf("site+nourl = %v, want only asura:ab", got)
|
||||||
|
}
|
||||||
|
got = pageKeys(t, s, SeriesFilter{Name: SeriesFilterNoURL, Kind: "novel"})
|
||||||
|
if len(got) != 1 || !got["demonic:ab"] {
|
||||||
|
t.Fatalf("kind+nourl = %v, want only demonic:ab", got)
|
||||||
|
}
|
||||||
|
got = pageKeys(t, s, SeriesFilter{Name: SeriesFilterAll, Site: "demonic", Kind: "novel"})
|
||||||
|
if len(got) != 2 || !got["demonic:aa"] || !got["demonic:ab"] {
|
||||||
|
t.Fatalf("site+kind+all = %v, want both demonic rows", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The aggregate ignores the Site narrowing (it is per-Site by shape), but
|
||||||
|
// honours the Library narrowing: asura's missing-URL row is manga, so the
|
||||||
|
// novel no-URL list is demonic alone.
|
||||||
|
shapes, err := s.SeriesShapes(SeriesFilter{Name: SeriesFilterNoURL, Kind: "novel"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SeriesShapes: %v", err)
|
||||||
|
}
|
||||||
|
if len(shapes) != 1 || shapes[0].Site != "demonic" ||
|
||||||
|
shapes[0].Total != 1 || shapes[0].Novel != 1 {
|
||||||
|
t.Fatalf("novel no-URL aggregate = %+v, want demonic {Total:1 Novel:1}", shapes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The projection is the privacy boundary: a Series whose Latest Chapter was
|
||||||
|
// raised by a Reader's report reads back with the anonymous boolean set, not
|
||||||
|
// with the Reader's id, and no Reader id travels in any returned row.
|
||||||
|
func TestAdminSeriesReportsAnonymously(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:raised", url: "u", checkedAt: 9000, latestNum: new(9.0), bookmarks: 1, raisedBy: true})
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:polled", url: "u", checkedAt: 9000, latestNum: new(8.0), bookmarks: 1})
|
||||||
|
|
||||||
|
page, err := s.SeriesPage(SeriesFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SeriesPage: %v", err)
|
||||||
|
}
|
||||||
|
byKey := map[string]AdminSeries{}
|
||||||
|
for _, a := range page.Rows {
|
||||||
|
byKey[a.Key()] = a
|
||||||
|
}
|
||||||
|
if !byKey["asura:raised"].RaisedByReader {
|
||||||
|
t.Fatal("Reader-raised Series read back RaisedByReader=false")
|
||||||
|
}
|
||||||
|
if byKey["asura:polled"].RaisedByReader {
|
||||||
|
t.Fatal("Poll-raised Series read back RaisedByReader=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The privacy test that cannot rot into a template-only guarantee: assert the
|
||||||
|
// admin column constant does not carry the Sighting-raiser column and that the
|
||||||
|
// admin row type has no field for it, modelled on the guard on the Bookmark
|
||||||
|
// column list.
|
||||||
|
func TestAdminProjectionHidesSightingRaiser(t *testing.T) {
|
||||||
|
if strings.Contains(adminSeriesColumns, "latest_raised_by") {
|
||||||
|
t.Fatal("admin column list carries latest_raised_by: the Sighting-raiser id would reach the owner")
|
||||||
|
}
|
||||||
|
if _, ok := reflect.TypeOf(AdminSeries{}).FieldByName("LatestRaisedBy"); ok {
|
||||||
|
t.Fatal("AdminSeries carries a field for the Sighting-raiser id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unknown filter name is rejected rather than silently meaning "all" —
|
||||||
|
// otherwise a mistyped URL would present an empty page as the whole library.
|
||||||
|
func TestAdminFilterUnknownNameRejected(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:a", url: "u", checkedAt: 9000, bookmarks: 1})
|
||||||
|
for name, call := range map[string]func() error{
|
||||||
|
"page": func() error { _, err := s.SeriesPage(SeriesFilter{Name: "bogus"}); return err },
|
||||||
|
"shape": func() error { _, err := s.SeriesShapes(SeriesFilter{Name: "bogus"}); return err },
|
||||||
|
} {
|
||||||
|
if err := call(); err == nil || !strings.Contains(err.Error(), "unknown series filter") {
|
||||||
|
t.Fatalf("%s with bogus filter = %v, want unknown-filter error", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForceSeriesPoll is the idempotent stamp write: a second press overwrites
|
||||||
|
// the request time, and touching a missing series is not an error.
|
||||||
|
func TestForceSeriesPollStampsIdempotently(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:x", url: "u", checkedAt: 9000, bookmarks: 1})
|
||||||
|
|
||||||
|
if err := s.ForceSeriesPoll("asura", "x", 42); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.ForceSeriesPoll("asura", "x", 99); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll re-stamp: %v", err)
|
||||||
|
}
|
||||||
|
// Touching a missing series is not an error: the row may have been
|
||||||
|
// orphaned, and the caller's read decides what exists.
|
||||||
|
if err := s.ForceSeriesPoll("asura", "ghost", 99); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got int64
|
||||||
|
if err := s.db.QueryRow(
|
||||||
|
`SELECT force_poll_at FROM series WHERE site = 'asura' AND series_id = 'x'`).Scan(&got); err != nil {
|
||||||
|
t.Fatalf("read force_poll_at: %v", err)
|
||||||
|
}
|
||||||
|
if got != 99 {
|
||||||
|
t.Fatalf("force_poll_at = %d, want 99 (the later press wins)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin projection carries the force stamp so the web layer can derive
|
||||||
|
// the pending flag without a second read.
|
||||||
|
func TestAdminSeriesCarriesForcePollAt(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:x", url: "u", checkedAt: 1000, bookmarks: 1})
|
||||||
|
if err := s.ForceSeriesPoll("asura", "x", 5000); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
page, err := s.SeriesPage(SeriesFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SeriesPage: %v", err)
|
||||||
|
}
|
||||||
|
if len(page.Rows) != 1 || page.Rows[0].ForcePollAt != 5000 {
|
||||||
|
t.Fatalf("row = %+v, want ForcePollAt 5000", page.Rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- One durable state row per Poll Lane. Zero means no pause or refusal is set.
|
||||||
|
CREATE TABLE poll_lanes (
|
||||||
|
site text NOT NULL PRIMARY KEY,
|
||||||
|
paused_until bigint NOT NULL DEFAULT 0,
|
||||||
|
refuse_until bigint NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Append-only Lane Pass log. Timestamps are unix milliseconds from the poller's clock.
|
||||||
|
CREATE TABLE poll_passes (
|
||||||
|
site text NOT NULL,
|
||||||
|
ran_at bigint NOT NULL,
|
||||||
|
skip text NOT NULL,
|
||||||
|
due integer NOT NULL,
|
||||||
|
checked integer NOT NULL,
|
||||||
|
gap_ms bigint NOT NULL,
|
||||||
|
clamped boolean NOT NULL,
|
||||||
|
refused integer NOT NULL,
|
||||||
|
unreachable integer NOT NULL,
|
||||||
|
no_chapter integer NOT NULL,
|
||||||
|
unfetchable integer NOT NULL,
|
||||||
|
errors integer NOT NULL,
|
||||||
|
PRIMARY KEY (site, ran_at)
|
||||||
|
);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Admin read-model foundation (#140). The Series list's default order is
|
||||||
|
-- least-recently-checked first, so the table — which has only its primary key
|
||||||
|
-- today — gets an index that can serve it. A grouped query over a join may
|
||||||
|
-- ignore the index, so this is a judgement, not a measurement: re-time on real
|
||||||
|
-- data before adding a second.
|
||||||
|
CREATE INDEX series_latest_checked_at_idx ON series (latest_checked_at);
|
||||||
|
|
||||||
|
-- force_poll_at is the "ask for one Series to be checked now" stamp (#146).
|
||||||
|
-- Zero means never forced; nothing reads the column before that ticket wires
|
||||||
|
-- it, so it lands here unused.
|
||||||
|
ALTER TABLE series ADD COLUMN force_poll_at bigint NOT NULL DEFAULT 0;
|
||||||
+273
-12
@@ -88,6 +88,39 @@ type Series struct {
|
|||||||
// readerCount is the number of bookmarks referencing this series, filled
|
// readerCount is the number of bookmarks referencing this series, filled
|
||||||
// only by the due-queue query that orders on it.
|
// only by the due-queue query that orders on it.
|
||||||
readerCount int
|
readerCount int
|
||||||
|
// Forced is whether the owner asked for a check now (issue #146): the
|
||||||
|
// request stamp is newer than the check stamp. Derived in the due query,
|
||||||
|
// never stored, and the flag that jumps the queue and opens the browser
|
||||||
|
// wake gate.
|
||||||
|
Forced bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// LanePass is one Poll Lane's durable pass snapshot. Pause and refusal stamps
|
||||||
|
// are joined from poll_lanes on read; they are not pass facts.
|
||||||
|
type LanePass struct {
|
||||||
|
Site string
|
||||||
|
RanAt int64
|
||||||
|
Skip string
|
||||||
|
Due, Checked int
|
||||||
|
GapMS int64
|
||||||
|
Clamped bool
|
||||||
|
Refused, Unreachable, NoChapter int
|
||||||
|
Unfetchable, Errors int
|
||||||
|
PausedUntil, RefuseUntil int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// SiteOutcomes is one Site's summed Lane Pass outcomes over a caller-supplied
|
||||||
|
// window.
|
||||||
|
type SiteOutcomes struct {
|
||||||
|
Site string
|
||||||
|
Refused, Unreachable, NoChapter int
|
||||||
|
Unfetchable, Errors int
|
||||||
|
}
|
||||||
|
|
||||||
|
// LanePause is one persisted Lane pause stamp.
|
||||||
|
type LanePause struct {
|
||||||
|
Site string
|
||||||
|
PausedUntil int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Key returns the canonical identity in bookmark-key form ("<site>:<series_id>"),
|
// Key returns the canonical identity in bookmark-key form ("<site>:<series_id>"),
|
||||||
@@ -188,9 +221,9 @@ const (
|
|||||||
//go:embed migrations/*.sql
|
//go:embed migrations/*.sql
|
||||||
var migrations embed.FS
|
var migrations embed.FS
|
||||||
|
|
||||||
// bookmarkColumns is the only value ever concatenated into query text. It is a
|
// These column lists are the only values ever concatenated into query text.
|
||||||
// compile-time constant; every request value is bound as a parameter. The
|
// They are compile-time constants; every request value is bound as a parameter.
|
||||||
// series-owned fields are joined in from the series table, in scanBookmark
|
// The series-owned fields are joined in from the series table, in scanBookmark
|
||||||
// order, so the flat Bookmark reads back whole despite the split (ADR-0004).
|
// order, so the flat Bookmark reads back whole despite the split (ADR-0004).
|
||||||
const bookmarkColumns = `b.site, b.series_id, s.title, s.series_url, s.cover_address,
|
const bookmarkColumns = `b.site, b.series_id, s.title, s.series_url, s.cover_address,
|
||||||
b.last_chapter, b.last_chapter_num, b.last_chapter_url,
|
b.last_chapter, b.last_chapter_num, b.last_chapter_url,
|
||||||
@@ -202,6 +235,10 @@ const bookmarkColumns = `b.site, b.series_id, s.title, s.series_url, s.cover_add
|
|||||||
const seriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover, s.cover_address,
|
const seriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover, s.cover_address,
|
||||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.latest_raised_by`
|
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.latest_raised_by`
|
||||||
|
|
||||||
|
const lanePassColumns = `p.site, p.ran_at, p.skip, p.due, p.checked, p.gap_ms, p.clamped,
|
||||||
|
p.refused, p.unreachable, p.no_chapter, p.unfetchable, p.errors,
|
||||||
|
COALESCE(l.paused_until, 0), COALESCE(l.refuse_until, 0)`
|
||||||
|
|
||||||
// Owner is the person running the service: the first Reader, seeded at startup
|
// Owner is the person running the service: the first Reader, seeded at startup
|
||||||
// so a fresh deployment has a library before anyone logs in. The seed makes
|
// so a fresh deployment has a library before anyone logs in. The seed makes
|
||||||
// sure exactly one readers row matches their Discord ID, carrying the SHA-256
|
// sure exactly one readers row matches their Discord ID, carrying the SHA-256
|
||||||
@@ -589,8 +626,9 @@ func (s *Store) scanBookmark(scan func(...any) error) (Bookmark, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// scanSeries reads one row in seriesColumns order, plus the due query's
|
// scanSeries reads one row in seriesColumns order, plus the due query's
|
||||||
// reader_count column. latest_chapter_num and latest_raised_by are both
|
// forced flag and reader_count columns. latest_chapter_num and
|
||||||
// nullable, same as latest_chapter_num on the bookmark read path.
|
// latest_raised_by are both nullable, same as latest_chapter_num on the
|
||||||
|
// bookmark read path.
|
||||||
func scanSeries(scan func(...any) error) (Series, error) {
|
func scanSeries(scan func(...any) error) (Series, error) {
|
||||||
var (
|
var (
|
||||||
sr Series
|
sr Series
|
||||||
@@ -600,7 +638,7 @@ func scanSeries(scan func(...any) error) (Series, error) {
|
|||||||
if err := scan(
|
if err := scan(
|
||||||
&sr.Site, &sr.SeriesID, &sr.Title, &sr.SeriesURL, &sr.Cover, &sr.CoverAddress,
|
&sr.Site, &sr.SeriesID, &sr.Title, &sr.SeriesURL, &sr.Cover, &sr.CoverAddress,
|
||||||
&sr.Kind, &sr.LatestChapter, &latestChapterNum, &sr.LatestCheckedAt, &latestRaisedBy,
|
&sr.Kind, &sr.LatestChapter, &latestChapterNum, &sr.LatestCheckedAt, &latestRaisedBy,
|
||||||
&sr.readerCount,
|
&sr.Forced, &sr.readerCount,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return Series{}, err
|
return Series{}, err
|
||||||
}
|
}
|
||||||
@@ -613,6 +651,18 @@ func scanSeries(scan func(...any) error) (Series, error) {
|
|||||||
return sr, nil
|
return sr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func scanLanePass(scan func(...any) error) (LanePass, error) {
|
||||||
|
var p LanePass
|
||||||
|
if err := scan(
|
||||||
|
&p.Site, &p.RanAt, &p.Skip, &p.Due, &p.Checked, &p.GapMS, &p.Clamped,
|
||||||
|
&p.Refused, &p.Unreachable, &p.NoChapter, &p.Unfetchable, &p.Errors,
|
||||||
|
&p.PausedUntil, &p.RefuseUntil,
|
||||||
|
); err != nil {
|
||||||
|
return LanePass{}, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Close releases the underlying database handle.
|
// Close releases the underlying database handle.
|
||||||
func (s *Store) Close() error { return s.db.Close() }
|
func (s *Store) Close() error { return s.db.Close() }
|
||||||
|
|
||||||
@@ -934,6 +984,184 @@ func (s *Store) Delete(readerID int64, key string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecordLanePass appends one pass and prunes every older row in the same
|
||||||
|
// transaction. retainBefore is supplied by the poller's clock.
|
||||||
|
func (s *Store) RecordLanePass(p LanePass, retainBefore int64) error {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin lane pass %s: %w", p.Site, err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.Exec(`
|
||||||
|
INSERT INTO poll_passes
|
||||||
|
(site, ran_at, skip, due, checked, gap_ms, clamped,
|
||||||
|
refused, unreachable, no_chapter, unfetchable, errors)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||||||
|
p.Site, p.RanAt, p.Skip, p.Due, p.Checked, p.GapMS, p.Clamped,
|
||||||
|
p.Refused, p.Unreachable, p.NoChapter, p.Unfetchable, p.Errors); err != nil {
|
||||||
|
return fmt.Errorf("insert lane pass %s at %d: %w", p.Site, p.RanAt, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`DELETE FROM poll_passes WHERE ran_at < $1`, retainBefore); err != nil {
|
||||||
|
return fmt.Errorf("prune lane passes before %d: %w", retainBefore, err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit lane pass %s at %d: %w", p.Site, p.RanAt, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestLanePass returns the newest pass for one Site, with its current Lane
|
||||||
|
// state joined on. A Site without a pass has no durable snapshot yet.
|
||||||
|
func (s *Store) LatestLanePass(site string) (LanePass, bool, error) {
|
||||||
|
p, err := scanLanePass(s.db.QueryRow(`SELECT `+lanePassColumns+`
|
||||||
|
FROM poll_passes p
|
||||||
|
LEFT JOIN poll_lanes l ON l.site = p.site
|
||||||
|
WHERE p.site = $1
|
||||||
|
ORDER BY p.ran_at DESC
|
||||||
|
LIMIT 1`, site).Scan)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return LanePass{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return LanePass{}, false, fmt.Errorf("latest lane pass %s: %w", site, err)
|
||||||
|
}
|
||||||
|
return p, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestLanePasses returns the newest pass for each Site, with current Lane
|
||||||
|
// state joined on. Sites without a pass have no row yet.
|
||||||
|
func (s *Store) LatestLanePasses() ([]LanePass, error) {
|
||||||
|
rows, err := s.db.Query(`SELECT ` + lanePassColumns + `
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (site)
|
||||||
|
site, ran_at, skip, due, checked, gap_ms, clamped,
|
||||||
|
refused, unreachable, no_chapter, unfetchable, errors
|
||||||
|
FROM poll_passes
|
||||||
|
ORDER BY site, ran_at DESC
|
||||||
|
) p
|
||||||
|
LEFT JOIN poll_lanes l ON l.site = p.site
|
||||||
|
ORDER BY p.site`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query latest lane passes: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []LanePass{}
|
||||||
|
for rows.Next() {
|
||||||
|
p, err := scanLanePass(rows.Scan)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan latest lane pass: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LanePassOutcomes sums the named outcomes for each Site at or after since.
|
||||||
|
// The window boundary is supplied by the caller; the store has no clock.
|
||||||
|
func (s *Store) LanePassOutcomes(since int64) ([]SiteOutcomes, error) {
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT site, SUM(refused), SUM(unreachable), SUM(no_chapter),
|
||||||
|
SUM(unfetchable), SUM(errors)
|
||||||
|
FROM poll_passes
|
||||||
|
WHERE ran_at >= $1
|
||||||
|
GROUP BY site
|
||||||
|
ORDER BY site`, since)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query lane pass outcomes: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []SiteOutcomes{}
|
||||||
|
for rows.Next() {
|
||||||
|
var outcomes SiteOutcomes
|
||||||
|
if err := rows.Scan(
|
||||||
|
&outcomes.Site, &outcomes.Refused, &outcomes.Unreachable,
|
||||||
|
&outcomes.NoChapter, &outcomes.Unfetchable, &outcomes.Errors,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan lane pass outcomes: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, outcomes)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLaneRefusal persists a Site's refusal backoff stamp without touching its
|
||||||
|
// pause. until is supplied by the caller's clock.
|
||||||
|
func (s *Store) SetLaneRefusal(site string, until int64) error {
|
||||||
|
if _, err := s.db.Exec(`
|
||||||
|
INSERT INTO poll_lanes (site, refuse_until) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (site) DO UPDATE SET refuse_until = EXCLUDED.refuse_until`, site, until); err != nil {
|
||||||
|
return fmt.Errorf("set lane refusal %s: %w", site, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PauseLane persists a bounded pause. The caller must ensure until is after
|
||||||
|
// its current timestamp; the store has no clock and rejects only the invalid
|
||||||
|
// zero and negative sentinels.
|
||||||
|
func (s *Store) PauseLane(site string, until int64) error {
|
||||||
|
if until <= 0 {
|
||||||
|
return fmt.Errorf("pause lane %s: expiry must be positive", site)
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(`
|
||||||
|
INSERT INTO poll_lanes (site, paused_until) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (site) DO UPDATE SET paused_until = EXCLUDED.paused_until`, site, until); err != nil {
|
||||||
|
return fmt.Errorf("pause lane %s: %w", site, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumeLane clears only the pause stamp and keeps the Lane state row, along
|
||||||
|
// with any refusal stamp already persisted on it.
|
||||||
|
func (s *Store) ResumeLane(site string) error {
|
||||||
|
if _, err := s.db.Exec(
|
||||||
|
`UPDATE poll_lanes SET paused_until = 0 WHERE site = $1`, site); err != nil {
|
||||||
|
return fmt.Errorf("resume lane %s: %w", site, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PausedLanes returns Lane rows with a nonzero pause stamp. Expiry comparison
|
||||||
|
// stays with the caller because the store is deliberately clockless.
|
||||||
|
func (s *Store) PausedLanes() ([]LanePause, error) {
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT site, paused_until
|
||||||
|
FROM poll_lanes
|
||||||
|
WHERE paused_until > 0
|
||||||
|
ORDER BY site`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query paused lanes: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []LanePause{}
|
||||||
|
for rows.Next() {
|
||||||
|
var pause LanePause
|
||||||
|
if err := rows.Scan(&pause.Site, &pause.PausedUntil); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan paused lane: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, pause)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LaneGates reads a Site's pause and refusal stamps in one row read — the
|
||||||
|
// top-of-pass gate the poller uses (issue #141). A missing state row is the
|
||||||
|
// default: unpaused and not refusing.
|
||||||
|
func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err error) {
|
||||||
|
err = s.db.QueryRow(
|
||||||
|
`SELECT paused_until, refuse_until FROM poll_lanes WHERE site = $1`, site).
|
||||||
|
Scan(&pausedUntil, &refuseUntil)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, 0, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("lane gates %s: %w", site, err)
|
||||||
|
}
|
||||||
|
return pausedUntil, refuseUntil, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DueForLatestCheck returns one Site's series whose server-side
|
// DueForLatestCheck returns one Site's series whose server-side
|
||||||
// latest-chapter check has aged past cutoffMs, ordered by how many bookmarks
|
// latest-chapter check has aged past cutoffMs, ordered by how many bookmarks
|
||||||
// reference them (descending) then least-recently-checked first. One Site per
|
// reference them (descending) then least-recently-checked first. One Site per
|
||||||
@@ -942,6 +1170,13 @@ func (s *Store) Delete(readerID int64, key string) error {
|
|||||||
// limit — the Lane's own gap paces the fetches, and the batch size that used
|
// limit — the Lane's own gap paces the fetches, and the batch size that used
|
||||||
// to cap this query is gone with the shared pace.
|
// to cap this query is gone with the shared pace.
|
||||||
//
|
//
|
||||||
|
// A forced Series (force_poll_at newer than latest_checked_at, issue #146)
|
||||||
|
// overrides exactly three gates: the rest cutoff, the Sighting-deferral
|
||||||
|
// clause and the finished-only bucket. It never overrides an empty
|
||||||
|
// series_url or the Bookmarks join — nothing to fetch, and no consumer for
|
||||||
|
// the result — so those stay unconditional. Forced rows sort to the front of
|
||||||
|
// the queue; the reader-count-then-age ordering among the rest is ADR-0003.
|
||||||
|
//
|
||||||
// The reader_count ordering is the point of the split (ADR-0003): a series
|
// The reader_count ordering is the point of the split (ADR-0003): a series
|
||||||
// shared by several readers is fetched once per due cycle, and the popular
|
// shared by several readers is fetched once per due cycle, and the popular
|
||||||
// ones stay freshest while the long tail absorbs any shortfall. Within one
|
// ones stay freshest while the long tail absorbs any shortfall. Within one
|
||||||
@@ -970,19 +1205,26 @@ func (s *Store) Delete(readerID int64, key string) error {
|
|||||||
// allowed to defer at all was settled when the Sighting was recorded — see
|
// allowed to defer at all was settled when the Sighting was recorded — see
|
||||||
// RecordSighting.
|
// RecordSighting.
|
||||||
func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Series, error) {
|
func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Series, error) {
|
||||||
rows, err := s.db.Query(`SELECT `+seriesColumns+`, COUNT(*) AS reader_count
|
rows, err := s.db.Query(`SELECT `+seriesColumns+`,
|
||||||
|
(s.force_poll_at > s.latest_checked_at) AS forced,
|
||||||
|
COUNT(*) AS reader_count
|
||||||
FROM series s
|
FROM series s
|
||||||
JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
|
JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
|
||||||
WHERE s.site = $1
|
WHERE s.site = $1
|
||||||
AND s.series_url <> ''
|
AND s.series_url <> ''
|
||||||
AND s.latest_checked_at <= $2::bigint
|
AND (s.latest_checked_at <= $2::bigint
|
||||||
|
OR s.force_poll_at > s.latest_checked_at)
|
||||||
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover,
|
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover,
|
||||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at
|
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
|
||||||
HAVING COUNT(*) FILTER (WHERE b.status <> 'finished') > 0
|
s.force_poll_at
|
||||||
|
HAVING (COUNT(*) FILTER (WHERE b.status <> 'finished') > 0
|
||||||
|
OR s.force_poll_at > s.latest_checked_at)
|
||||||
AND (COUNT(*) > 1
|
AND (COUNT(*) > 1
|
||||||
OR s.latest_sighted_at <= $2::bigint
|
OR s.latest_sighted_at <= $2::bigint
|
||||||
OR s.latest_checked_at <= $3::bigint)
|
OR s.latest_checked_at <= $3::bigint
|
||||||
ORDER BY reader_count DESC, s.latest_checked_at ASC`, site, cutoffMs, ceilingMs)
|
OR s.force_poll_at > s.latest_checked_at)
|
||||||
|
ORDER BY (s.force_poll_at > s.latest_checked_at) DESC,
|
||||||
|
reader_count DESC, s.latest_checked_at ASC`, site, cutoffMs, ceilingMs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("query due series: %w", err)
|
return nil, fmt.Errorf("query due series: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1042,6 +1284,25 @@ func (s *Store) MarkLatestChecked(site, seriesID string, ts int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForceSeriesPoll stamps a Series with the owner's "check now" request
|
||||||
|
// (issue #146): a fact about the Series the Lane's next pass reads through
|
||||||
|
// DueForLatestCheck, never a command to the poller — so the request survives
|
||||||
|
// a restart. Writing again overwrites the request time; the write is
|
||||||
|
// idempotent. Touching a missing series is not an error: the row may have
|
||||||
|
// been orphaned, and the caller's read decides what exists. The stamp never
|
||||||
|
// expires by itself — an unanswered request keeps ageing — and pending is
|
||||||
|
// derived as force_poll_at > latest_checked_at, which is why the poller's
|
||||||
|
// check stamp is written before the fetch: the first attempt ends the
|
||||||
|
// pending state whatever it returns.
|
||||||
|
func (s *Store) ForceSeriesPoll(site, seriesID string, at int64) error {
|
||||||
|
if _, err := s.db.Exec(
|
||||||
|
`UPDATE series SET force_poll_at = $1 WHERE site = $2 AND series_id = $3`,
|
||||||
|
at, site, seriesID); err != nil {
|
||||||
|
return fmt.Errorf("force poll %s:%s: %w", site, seriesID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// LatestCheckedAt reads the column MarkLatestChecked writes. It exists for
|
// LatestCheckedAt reads the column MarkLatestChecked writes. It exists for
|
||||||
// tests outside this package (the poller's own tests assert on rest
|
// tests outside this package (the poller's own tests assert on rest
|
||||||
// bookkeeping) — see MarkLatestChecked for why the field stays off the
|
// bookkeeping) — see MarkLatestChecked for why the field stays off the
|
||||||
|
|||||||
@@ -758,7 +758,7 @@ func TestMigration0008DropsLegacyCoverRows(t *testing.T) {
|
|||||||
func readSeries(t *testing.T, s *Store, site, seriesID string) Series {
|
func readSeries(t *testing.T, s *Store, site, seriesID string) Series {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
sr, err := scanSeries(s.db.QueryRow(
|
sr, err := scanSeries(s.db.QueryRow(
|
||||||
`SELECT `+seriesColumns+`, 0 AS reader_count FROM series s
|
`SELECT `+seriesColumns+`, false AS forced, 0 AS reader_count FROM series s
|
||||||
WHERE s.site = $1 AND s.series_id = $2`, site, seriesID).Scan)
|
WHERE s.site = $1 AND s.series_id = $2`, site, seriesID).Scan)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read series %s:%s: %v", site, seriesID, err)
|
t.Fatalf("read series %s:%s: %v", site, seriesID, err)
|
||||||
@@ -1584,3 +1584,318 @@ func TestCoverStoreAcceptsAnySourceURL(t *testing.T) {
|
|||||||
t.Fatalf("rejected cover = found %v, err %v; want missing", ok, err)
|
t.Fatalf("rejected cover = found %v, err %v; want missing", ok, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
func TestRecordLanePassPrunesBeforeInsertCutoff(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
for _, pass := range []LanePass{
|
||||||
|
{Site: "asura", RanAt: 99},
|
||||||
|
{Site: "asura", RanAt: 100},
|
||||||
|
} {
|
||||||
|
if err := s.RecordLanePass(pass, 100); err != nil {
|
||||||
|
t.Fatalf("RecordLanePass(%d): %v", pass.RanAt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.RecordLanePass(LanePass{Site: "asura", RanAt: 200}, 100); err != nil {
|
||||||
|
t.Fatalf("RecordLanePass(200): %v", err)
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := s.db.QueryRow(`SELECT count(*) FROM poll_passes WHERE site = $1`, "asura").Scan(&count); err != nil {
|
||||||
|
t.Fatalf("count passes: %v", err)
|
||||||
|
}
|
||||||
|
if count != 2 {
|
||||||
|
t.Fatalf("retained passes = %d, want 2", count)
|
||||||
|
}
|
||||||
|
if _, ok, err := s.LatestLanePass("asura"); err != nil || !ok {
|
||||||
|
t.Fatalf("LatestLanePass = ok %v, err %v; want latest row", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatestLanePassesKeepsNewestPerSiteAndJoinsState(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
for _, pass := range []LanePass{
|
||||||
|
{Site: "asura", RanAt: 100, Due: 1},
|
||||||
|
{Site: "asura", RanAt: 200, Skip: "due-query", Due: 2, Checked: 3, GapMS: 4000, Clamped: true},
|
||||||
|
{Site: "demonic", RanAt: 150, Due: 4},
|
||||||
|
} {
|
||||||
|
if err := s.RecordLanePass(pass, -1); err != nil {
|
||||||
|
t.Fatalf("RecordLanePass(%s/%d): %v", pass.Site, pass.RanAt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.PauseLane("asura", 1234); err != nil {
|
||||||
|
t.Fatalf("PauseLane: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.SetLaneRefusal("asura", 5678); err != nil {
|
||||||
|
t.Fatalf("SetLaneRefusal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.LatestLanePasses()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LatestLanePasses: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("latest passes = %d, want one per Site", len(got))
|
||||||
|
}
|
||||||
|
bySite := map[string]LanePass{}
|
||||||
|
for _, pass := range got {
|
||||||
|
bySite[pass.Site] = pass
|
||||||
|
}
|
||||||
|
asura := bySite["asura"]
|
||||||
|
if asura.RanAt != 200 || asura.Skip != "due-query" || asura.Due != 2 || asura.Checked != 3 || asura.GapMS != 4000 || !asura.Clamped ||
|
||||||
|
asura.PausedUntil != 1234 || asura.RefuseUntil != 5678 {
|
||||||
|
t.Fatalf("asura latest pass = %+v, want newest pass and joined state", asura)
|
||||||
|
}
|
||||||
|
if demonic := bySite["demonic"]; demonic.RanAt != 150 || demonic.Due != 4 {
|
||||||
|
t.Fatalf("demonic latest pass = %+v, want its only pass", demonic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLanePassOutcomesSumsWindow(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
for _, pass := range []LanePass{
|
||||||
|
{Site: "asura", RanAt: 99, Refused: 1, Unreachable: 2, NoChapter: 3, Unfetchable: 4, Errors: 5},
|
||||||
|
{Site: "asura", RanAt: 100, Refused: 2, Unreachable: 3, NoChapter: 4, Unfetchable: 5, Errors: 6},
|
||||||
|
{Site: "asura", RanAt: 200, Refused: 3, Unreachable: 4, NoChapter: 5, Unfetchable: 6, Errors: 7},
|
||||||
|
{Site: "demonic", RanAt: 150, Refused: 8, Unreachable: 9, NoChapter: 10, Unfetchable: 11, Errors: 12},
|
||||||
|
} {
|
||||||
|
if err := s.RecordLanePass(pass, -1); err != nil {
|
||||||
|
t.Fatalf("RecordLanePass(%s/%d): %v", pass.Site, pass.RanAt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.LanePassOutcomes(100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LanePassOutcomes: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("outcome Sites = %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
bySite := map[string]SiteOutcomes{}
|
||||||
|
for _, outcomes := range got {
|
||||||
|
bySite[outcomes.Site] = outcomes
|
||||||
|
}
|
||||||
|
if want := (SiteOutcomes{Site: "asura", Refused: 5, Unreachable: 7, NoChapter: 9, Unfetchable: 11, Errors: 13}); bySite["asura"] != want {
|
||||||
|
t.Fatalf("asura outcomes = %+v, want %+v", bySite["asura"], want)
|
||||||
|
}
|
||||||
|
if want := (SiteOutcomes{Site: "demonic", Refused: 8, Unreachable: 9, NoChapter: 10, Unfetchable: 11, Errors: 12}); bySite["demonic"] != want {
|
||||||
|
t.Fatalf("demonic outcomes = %+v, want %+v", bySite["demonic"], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaneGatesReadsOneRow(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
paused, refused, err := s.LaneGates("asura")
|
||||||
|
if err != nil || paused != 0 || refused != 0 {
|
||||||
|
t.Fatalf("LaneGates on a missing state row = (%d, %d, %v), want (0, 0, nil)", paused, refused, err)
|
||||||
|
}
|
||||||
|
if err := s.PauseLane("asura", 2000); err != nil {
|
||||||
|
t.Fatalf("PauseLane: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.SetLaneRefusal("asura", 3000); err != nil {
|
||||||
|
t.Fatalf("SetLaneRefusal: %v", err)
|
||||||
|
}
|
||||||
|
paused, refused, err = s.LaneGates("asura")
|
||||||
|
if err != nil || paused != 2000 || refused != 3000 {
|
||||||
|
t.Fatalf("LaneGates = (%d, %d, %v), want (2000, 3000, nil)", paused, refused, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaneStatePauseResumeAndRefusal(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
for _, until := range []int64{0, -1} {
|
||||||
|
if err := s.PauseLane("asura", until); err == nil {
|
||||||
|
t.Fatalf("PauseLane(%d) accepted a non-future expiry", until)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.PauseLane("asura", 2000); err != nil {
|
||||||
|
t.Fatalf("PauseLane: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.SetLaneRefusal("asura", 3000); err != nil {
|
||||||
|
t.Fatalf("SetLaneRefusal: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.RecordLanePass(LanePass{Site: "asura", RanAt: 1}, -1); err != nil {
|
||||||
|
t.Fatalf("RecordLanePass: %v", err)
|
||||||
|
}
|
||||||
|
pausedUntil, refuseUntil, err := s.LaneGates("asura")
|
||||||
|
if err != nil || pausedUntil != 2000 || refuseUntil != 3000 {
|
||||||
|
t.Fatalf("LaneGates = %d/%d, %v; want 2000/3000", pausedUntil, refuseUntil, err)
|
||||||
|
}
|
||||||
|
paused, err := s.PausedLanes()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PausedLanes: %v", err)
|
||||||
|
}
|
||||||
|
if len(paused) != 1 || paused[0] != (LanePause{Site: "asura", PausedUntil: 2000}) {
|
||||||
|
t.Fatalf("PausedLanes = %+v, want asura/2000", paused)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.ResumeLane("asura"); err != nil {
|
||||||
|
t.Fatalf("ResumeLane: %v", err)
|
||||||
|
}
|
||||||
|
latest, ok, err := s.LatestLanePass("asura")
|
||||||
|
if err != nil || !ok || latest.RefuseUntil != 3000 {
|
||||||
|
t.Fatalf("latest refusal after resume = %+v, ok=%v, err=%v; want 3000 preserved", latest, ok, err)
|
||||||
|
}
|
||||||
|
if got, got2, err := s.LaneGates("asura"); err != nil || got != 0 || got2 != 3000 {
|
||||||
|
t.Fatalf("LaneGates after resume = %d/%d, %v; want 0/3000", got, got2, err)
|
||||||
|
}
|
||||||
|
if paused, err := s.PausedLanes(); err != nil || len(paused) != 0 {
|
||||||
|
t.Fatalf("PausedLanes after resume = %+v, %v; want empty", paused, err)
|
||||||
|
}
|
||||||
|
var rows int
|
||||||
|
if err := s.db.QueryRow(`SELECT count(*) FROM poll_lanes WHERE site = $1`, "asura").Scan(&rows); err != nil {
|
||||||
|
t.Fatalf("count lane state: %v", err)
|
||||||
|
}
|
||||||
|
if rows != 1 {
|
||||||
|
t.Fatalf("lane state rows after resume = %d, want 1", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A forced Series is due ahead of the rest cutoff: the request overrides the
|
||||||
|
// rest gate so the Lane's next pass picks it up however recently it was
|
||||||
|
// checked. An unforced series under the rest stays out.
|
||||||
|
func TestDueForLatestCheckForcedOverridesRestCutoff(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
const now = int64(10 * 3600_000)
|
||||||
|
seedForCheck(t, s, "asura:forced", "https://asurascans.com/comics/forced", now-30*60_000)
|
||||||
|
seedForCheck(t, s, "asura:fresh", "https://asurascans.com/comics/fresh", now-30*60_000)
|
||||||
|
if err := s.ForceSeriesPoll("asura", "forced", now); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
due, err := s.DueForLatestCheck("asura", now-3600_000, noCeiling)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DueForLatestCheck: %v", err)
|
||||||
|
}
|
||||||
|
if len(due) != 1 || due[0].Key() != "asura:forced" {
|
||||||
|
t.Fatalf("due = %v, want only the forced series", due)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Sighting-deferral clause holds a solitary series back after a recent
|
||||||
|
// Reader report; a forced request overrides it and puts the series back on
|
||||||
|
// the Lane's list.
|
||||||
|
func TestDueForLatestCheckForcedOverridesSightingDeferral(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
const now = int64(10 * 3600_000)
|
||||||
|
// One bookmark (so deferral can apply), sighted and checked 10m ago:
|
||||||
|
// inside the deferral window and under the ceiling.
|
||||||
|
seedForCheck(t, s, "asura:deferred", "https://asurascans.com/comics/deferred", now-10*60_000)
|
||||||
|
if _, err := s.db.Exec(
|
||||||
|
`UPDATE series SET latest_sighted_at = $1 WHERE site = 'asura' AND series_id = 'deferred'`,
|
||||||
|
now-10*60_000); err != nil {
|
||||||
|
t.Fatalf("seed sighting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unforced: deferred, and under the rest anyway.
|
||||||
|
due, err := s.DueForLatestCheck("asura", now-3600_000, now-3*3600_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DueForLatestCheck: %v", err)
|
||||||
|
}
|
||||||
|
if len(due) != 0 {
|
||||||
|
t.Fatalf("unforced deferred series is due: %v", due)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forced: the request overrides the deferral.
|
||||||
|
if err := s.ForceSeriesPoll("asura", "deferred", now); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||||
|
}
|
||||||
|
due, err = s.DueForLatestCheck("asura", now-3600_000, now-3*3600_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DueForLatestCheck: %v", err)
|
||||||
|
}
|
||||||
|
if len(due) != 1 || due[0].Key() != "asura:deferred" {
|
||||||
|
t.Fatalf("forced deferred series not due: %v", due)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The finished-only bucket excludes a series whose only bookmarks are
|
||||||
|
// finished; a forced request overrides it — the owner asked, so the Lane
|
||||||
|
// looks.
|
||||||
|
func TestDueForLatestCheckForcedOverridesFinishedBucket(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
seedForCheck(t, s, "asura:reading", "https://asurascans.com/comics/reading", 0)
|
||||||
|
if _, err := s.Upsert(s.OwnerID(), Bookmark{
|
||||||
|
Key: "asura:finished", Site: "asura", SeriesID: "finished",
|
||||||
|
SeriesURL: "https://asurascans.com/comics/finished",
|
||||||
|
Status: StatusFinished, UpdatedAt: 1000,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed finished: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DueForLatestCheck: %v", err)
|
||||||
|
}
|
||||||
|
for _, sr := range due {
|
||||||
|
if sr.Key() == "asura:finished" {
|
||||||
|
t.Fatalf("unforced finished series is due: %v", due)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.ForceSeriesPoll("asura", "finished", 5000); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||||
|
}
|
||||||
|
due, err = s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DueForLatestCheck: %v", err)
|
||||||
|
}
|
||||||
|
got := map[string]bool{}
|
||||||
|
for _, sr := range due {
|
||||||
|
got[sr.Key()] = true
|
||||||
|
}
|
||||||
|
if !got["asura:reading"] || !got["asura:finished"] {
|
||||||
|
t.Fatalf("forced finished series not due: %v", due)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A forced Series jumps the queue: it sorts ahead of a more-read series that
|
||||||
|
// is due by rest, without disturbing the reader-count-then-age tie-break
|
||||||
|
// among the unforced rows (ADR-0003).
|
||||||
|
func TestDueForLatestCheckForcedSortsFirst(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
// "popular" has two readers and is long overdue; "forced" has one reader
|
||||||
|
// and a fresh check stamp. The forced row must come first.
|
||||||
|
seedForCheck(t, s, "asura:popular", "https://asurascans.com/comics/popular", 100)
|
||||||
|
seedSecondReader(t, s, "asura:popular:2", "asura", "popular", 1001)
|
||||||
|
seedForCheck(t, s, "asura:forced", "https://asurascans.com/comics/forced", 900)
|
||||||
|
if err := s.ForceSeriesPoll("asura", "forced", 5000); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DueForLatestCheck: %v", err)
|
||||||
|
}
|
||||||
|
if len(due) != 2 {
|
||||||
|
t.Fatalf("due = %d rows, want 2", len(due))
|
||||||
|
}
|
||||||
|
if due[0].Key() != "asura:forced" || due[1].Key() != "asura:popular" {
|
||||||
|
t.Fatalf("due order = %q, %q; want forced first, then popular", due[0].Key(), due[1].Key())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A forced Series with no series URL is still not fetched — nothing to fetch —
|
||||||
|
// and one with no Bookmarks is still excluded by the join. The force flag
|
||||||
|
// opens the three gates it is allowed to, not the whole query.
|
||||||
|
func TestDueForLatestCheckForcedDoesNotOverrideURLOrJoin(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
seedForCheck(t, s, "asura:nourl", "", 0)
|
||||||
|
if err := s.ForceSeriesPoll("asura", "nourl", 5000); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(`
|
||||||
|
INSERT INTO series (site, series_id, title, series_url, cover, kind,
|
||||||
|
latest_chapter, latest_chapter_num, latest_checked_at,
|
||||||
|
force_poll_at)
|
||||||
|
VALUES ('asura', 'orphan', 'Orphan', 'https://asurascans.com/comics/orphan',
|
||||||
|
'', 'manga', '', NULL, 0, 5000)`); err != nil {
|
||||||
|
t.Fatalf("seed orphan: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DueForLatestCheck: %v", err)
|
||||||
|
}
|
||||||
|
if len(due) != 0 {
|
||||||
|
t.Fatalf("due = %v, want neither the URL-less nor the orphan series", due)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+35
-116
@@ -6,69 +6,27 @@ 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
|
// ownerWindow is the staleness boundary the Series list's "not checked in
|
||||||
// poller: one snapshot of Poll Lane state, copied out of memory on request.
|
// 12h" filter compares against. Declared once; later admin tickets read it.
|
||||||
// The Poller satisfies it in production and a fake with fixed values satisfies
|
const ownerWindow = 12 * time.Hour
|
||||||
// it in tests, so the page's tests need neither a poller nor a Site.
|
|
||||||
type LaneReporter interface {
|
|
||||||
LaneStatus() latest.Status
|
|
||||||
}
|
|
||||||
|
|
||||||
// adminView is what the administrative page and the roster fragment receive.
|
// adminView is the shared shell data for an administrative page and the roster
|
||||||
|
// fragment returned after a Reader action.
|
||||||
type adminView struct {
|
type adminView struct {
|
||||||
|
Page string
|
||||||
Readers []store.ReaderSummary
|
Readers []store.ReaderSummary
|
||||||
// OwnerID travels with the roster so it can tell the owner's own row from
|
// OwnerID travels with the roster so it can tell the owner's own row from
|
||||||
// the Readers they may act on.
|
// the Readers they may act on.
|
||||||
OwnerID int64
|
OwnerID int64
|
||||||
Lanes lanesView
|
Lanes lanesView
|
||||||
}
|
SeriesList seriesListView
|
||||||
|
// Detail is the per-Series page data; zero on every other page.
|
||||||
// lanesView is the Lane status block: one row per Site that has run, plus the
|
Detail seriesDetailView
|
||||||
// browser fact, which is shared by the three browser Sites rather than held
|
// Overview is the landing page data; zero on every other page.
|
||||||
// once per Site.
|
Overview overviewView
|
||||||
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
|
||||||
@@ -84,6 +42,13 @@ type adminRoute struct {
|
|||||||
func (h *Handler) adminRoutes() []adminRoute {
|
func (h *Handler) adminRoutes() []adminRoute {
|
||||||
return []adminRoute{
|
return []adminRoute{
|
||||||
{"GET /admin", h.admin},
|
{"GET /admin", h.admin},
|
||||||
|
{"GET /admin/lanes", h.adminLanes},
|
||||||
|
{"GET /admin/readers", h.adminReaders},
|
||||||
|
{"GET /admin/series", h.adminSeries},
|
||||||
|
{"GET /admin/series/{key}", h.adminSeriesDetail},
|
||||||
|
{"POST /admin/series/{key}/poll", h.adminSeriesPoll},
|
||||||
|
{"POST /admin/lanes/{site}/pause", h.adminLanePause},
|
||||||
|
{"POST /admin/lanes/{site}/resume", h.adminLaneResume},
|
||||||
{"GET /ui/admin/lanes", h.uiLanes},
|
{"GET /ui/admin/lanes", h.uiLanes},
|
||||||
{"POST /readers/{id}/revoke", h.revokeReaderSessions},
|
{"POST /readers/{id}/revoke", h.revokeReaderSessions},
|
||||||
{"POST /readers/{id}/clear-marks", h.clearReaderMarks},
|
{"POST /readers/{id}/clear-marks", h.clearReaderMarks},
|
||||||
@@ -116,78 +81,32 @@ func (h *Handler) requireOwner(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// admin renders the owner's page: the Reader roster and Poll Lane status.
|
// admin renders the Overview landing page: a verdict line, a stats block
|
||||||
|
// where every figure is a door into the list it counts, and the per-Site
|
||||||
|
// library shape table — all read from the database, never from a poller.
|
||||||
func (h *Handler) admin(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) admin(w http.ResponseWriter, r *http.Request) {
|
||||||
readers, err := h.store.Readers()
|
view, err := h.overviewView()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("admin: %v", err)
|
log.Printf("admin overview: %v", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.render(w, http.StatusOK, "admin", adminView{
|
h.renderAdmin(w, adminView{Page: "overview", Overview: view})
|
||||||
Readers: readers,
|
|
||||||
OwnerID: h.store.OwnerID(),
|
|
||||||
Lanes: h.lanesView(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// uiLanes answers the status block's own refresh. Only the block refreshes on a
|
// adminReaders renders the Reader roster on its own bookmarkable page.
|
||||||
// timer; the roster re-renders after an action, as it always has.
|
func (h *Handler) adminReaders(w http.ResponseWriter, r *http.Request) {
|
||||||
func (h *Handler) uiLanes(w http.ResponseWriter, r *http.Request) {
|
readers, err := h.store.Readers()
|
||||||
h.render(w, http.StatusOK, "lanes", h.lanesView())
|
if err != nil {
|
||||||
|
log.Printf("admin readers: %v", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.renderAdmin(w, adminView{Page: "readers", Readers: readers, OwnerID: h.store.OwnerID()})
|
||||||
}
|
}
|
||||||
|
|
||||||
// lanesView copies the poller's snapshot into display form. A nil reporter (no
|
func (h *Handler) renderAdmin(w http.ResponseWriter, view adminView) {
|
||||||
// poller running) and a poller no Lane has reported to yet are the same thing
|
h.render(w, http.StatusOK, "admin", view)
|
||||||
// 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
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"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
|
||||||
|
// Paused is the live pause state — the poll_lanes stamp the pass row
|
||||||
|
// joins on, still in the future — not the pass's skip: the control must
|
||||||
|
// offer Resume from the moment the owner presses Pause, with no pass
|
||||||
|
// having run to record it (issue #147).
|
||||||
|
Paused 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
// pauseDurations are the offered pause lengths, by their wire value. A fixed
|
||||||
|
// allow-list rather than time.ParseDuration: the unoffered value must be
|
||||||
|
// refused, and a permissive parser turns the offered set into "anything Go
|
||||||
|
// can read" (issue #147).
|
||||||
|
var pauseDurations = map[string]time.Duration{
|
||||||
|
"1h": time.Hour,
|
||||||
|
"6h": 6 * time.Hour,
|
||||||
|
"24h": 24 * time.Hour,
|
||||||
|
}
|
||||||
|
|
||||||
|
// laneSite reads the Site a lane route names, answering the request itself
|
||||||
|
// when it is not a registry Site. The path value is client-supplied, so it
|
||||||
|
// is checked against the registry before it reaches the store.
|
||||||
|
func laneSite(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||||
|
site := r.PathValue("site")
|
||||||
|
if !slices.Contains(latest.SiteNames(), site) {
|
||||||
|
http.Error(w, "unknown site", http.StatusBadRequest)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return site, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminLanePause writes a bounded pause for one Site and answers with the
|
||||||
|
// freshly rendered Lanes block, so the figures describe the state after the
|
||||||
|
// press. The pause is a fact about the Site — the Lane's next pass reads it
|
||||||
|
// from the durable row, never from this process — so it survives a restart.
|
||||||
|
// The owner gate is the route's, not this handler's; the body is capped like
|
||||||
|
// the API path caps its bodies; the Site and the duration are validated
|
||||||
|
// here, before the store sees them (issue #147).
|
||||||
|
func (h *Handler) adminLanePause(w http.ResponseWriter, r *http.Request) {
|
||||||
|
site, ok := laneSite(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d, ok := pauseDurations[r.PostFormValue("duration")]
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unknown pause duration", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.PauseLane(site, time.Now().Add(d).UnixMilli()); err != nil {
|
||||||
|
log.Printf("pause lane %s: %v", site, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(w, http.StatusOK, "lanes", h.lanesView())
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminLaneResume zeroes one Site's pause and answers with the freshly
|
||||||
|
// rendered Lanes block. Resume is the reversal of a bounded pause, so it
|
||||||
|
// fires instantly with no confirm row (issue #147).
|
||||||
|
func (h *Handler) adminLaneResume(w http.ResponseWriter, r *http.Request) {
|
||||||
|
site, ok := laneSite(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.ResumeLane(site); err != nil {
|
||||||
|
log.Printf("resume lane %s: %v", site, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
row.Paused = time.UnixMilli(p.PausedUntil).After(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) {
|
||||||
|
// The pause phrase reads the live poll_lanes stamp the pass row joins
|
||||||
|
// on, not the pass's skip: the owner's press must render as paused on
|
||||||
|
// the very answer it gets, with no pass having run to record it. The
|
||||||
|
// pause is a fact about the Site, and the join delivers it (issue #147).
|
||||||
|
if pausedUntil := time.UnixMilli(p.PausedUntil); pausedUntil.After(now) {
|
||||||
|
phrase = "paused · resumes in " + humanDuration(pausedUntil.Sub(now))
|
||||||
|
good = true
|
||||||
|
return phrase, good, attention
|
||||||
|
}
|
||||||
|
switch p.Skip {
|
||||||
|
case latest.SkipPaused:
|
||||||
|
// A paused pass whose stamp has since lapsed: the Lane still
|
||||||
|
// declined with a reason, so it is never the one true stall.
|
||||||
|
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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookmarkmanager/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// overviewView is the Overview landing page's data: one verdict line, the
|
||||||
|
// hygiene and library stats blocks, and the per-Site library shape table.
|
||||||
|
// Every judgement — the verdict state, which figures link, what a Lane's
|
||||||
|
// state means — is made here; the template only prints.
|
||||||
|
type overviewView struct {
|
||||||
|
// Verdict is the attention phrase that leads the page.
|
||||||
|
Verdict string
|
||||||
|
// HasCounts is false on a virgin pass log: the waiting figure would be a
|
||||||
|
// confident zero, and "nothing has happened" must not render as health.
|
||||||
|
HasCounts bool
|
||||||
|
// Waiting is the sum of Due over the latest pass per Site.
|
||||||
|
Waiting int
|
||||||
|
// Unchecked is the number of Series not checked in the window, computed
|
||||||
|
// as stale + never_checked: a never-checked Series is already counted on
|
||||||
|
// its own filter, and the verdict wants the inclusive number.
|
||||||
|
Unchecked int
|
||||||
|
// Hygiene is the seven problem filters in the Series list's own render
|
||||||
|
// order; Library is the library split plus the roster. Every figure is a
|
||||||
|
// door into the list that counts it, except a zero.
|
||||||
|
Hygiene []fig
|
||||||
|
Library []fig
|
||||||
|
// Sites is the per-Site library shape table, one row per Site with any
|
||||||
|
// Series, in the store's Site order.
|
||||||
|
Sites []siteRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// fig is one stats figure: its label, the list it counts, and the count
|
||||||
|
// itself. Href empty means the count is zero: a measured zero is a real
|
||||||
|
// figure that stays on the page, but it is not a door, because following it
|
||||||
|
// lands on an empty list.
|
||||||
|
type fig struct {
|
||||||
|
Label string
|
||||||
|
Href string
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// siteRow is one Site's share of the library: the Series total and the three
|
||||||
|
// hygiene counts the per-Site table carries, each a door to the list narrowed
|
||||||
|
// to that Site, plus the Lane state phrase derived from its latest pass. The
|
||||||
|
// table is library shape only — the Poll outcome sums live on the Lanes page.
|
||||||
|
type siteRow struct {
|
||||||
|
Site string
|
||||||
|
SiteHref string
|
||||||
|
Figs []fig
|
||||||
|
// State is the Lane's own sentence; "" means the last pass read normally.
|
||||||
|
// StateGood / StateBad pick the ok / bad second class.
|
||||||
|
State string
|
||||||
|
StateGood bool
|
||||||
|
StateBad bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// overviewView assembles the landing page from the store's read model: one
|
||||||
|
// SeriesShapes pass per filter summed in Go (the shipped surface offers eight
|
||||||
|
// grouped passes, not a stats query — #140), the pass log's latest pass per
|
||||||
|
// Site, and the roster. A failure in any read is a 500 with a logged reason,
|
||||||
|
// never a page of silent zeroes.
|
||||||
|
func (h *Handler) overviewView() (overviewView, error) {
|
||||||
|
now := time.Now()
|
||||||
|
cutoff := now.Add(-ownerWindow).UnixMilli()
|
||||||
|
|
||||||
|
shapes := make(map[string][]store.SiteSeriesShape, len(seriesFilterOrder))
|
||||||
|
totals := make(map[string]int, len(seriesFilterOrder))
|
||||||
|
for _, name := range seriesFilterOrder {
|
||||||
|
rows, err := h.store.SeriesShapes(store.SeriesFilter{Name: name, Cutoff: cutoff})
|
||||||
|
if err != nil {
|
||||||
|
return overviewView{}, err
|
||||||
|
}
|
||||||
|
shapes[name] = rows
|
||||||
|
for _, sh := range rows {
|
||||||
|
totals[name] += sh.Total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
passes, err := h.store.LatestLanePasses()
|
||||||
|
if err != nil {
|
||||||
|
return overviewView{}, err
|
||||||
|
}
|
||||||
|
readers, err := h.store.Readers()
|
||||||
|
if err != nil {
|
||||||
|
return overviewView{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
view := overviewView{Waiting: waiting(passes)}
|
||||||
|
view.Unchecked = totals[store.SeriesFilterStale] + totals[store.SeriesFilterNeverChecked]
|
||||||
|
view.Verdict, view.HasCounts = overviewVerdict(passes, now)
|
||||||
|
|
||||||
|
// The seven problem filters, in seriesFilterOrder's permanent-then-fixable
|
||||||
|
// order; the All filter's count belongs to the Library block, not to a
|
||||||
|
// "hygiene" figure.
|
||||||
|
hygiene := make([]fig, 0, len(seriesFilterOrder)-1)
|
||||||
|
for _, name := range seriesFilterOrder[1:] {
|
||||||
|
hygiene = append(hygiene, door(seriesFilterLabels[name], totals[name], seriesListHref(name, "", "", 0)))
|
||||||
|
}
|
||||||
|
view.Hygiene = hygiene
|
||||||
|
|
||||||
|
var manga, novel int
|
||||||
|
for _, sh := range shapes[store.SeriesFilterAll] {
|
||||||
|
manga += sh.Manga
|
||||||
|
novel += sh.Novel
|
||||||
|
}
|
||||||
|
view.Library = []fig{
|
||||||
|
door("Series", totals[store.SeriesFilterAll], seriesListHref("", "", "", 0)),
|
||||||
|
door("Manga", manga, seriesListHref("", "", store.KindManga, 0)),
|
||||||
|
door("Novels", novel, seriesListHref("", "", store.KindNovel, 0)),
|
||||||
|
door("Readers", len(readers), "/admin/readers"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row per Site with any Series, from the All shapes; the hygiene
|
||||||
|
// counts come from the same per-Site projection so the table cannot
|
||||||
|
// disagree with the library-wide figures above it.
|
||||||
|
siteCounts := make(map[string]map[string]int, len(shapes))
|
||||||
|
for name, rows := range shapes {
|
||||||
|
m := make(map[string]int, len(rows))
|
||||||
|
for _, sh := range rows {
|
||||||
|
m[sh.Site] = sh.Total
|
||||||
|
}
|
||||||
|
siteCounts[name] = m
|
||||||
|
}
|
||||||
|
passBySite := make(map[string]store.LanePass, len(passes))
|
||||||
|
for _, p := range passes {
|
||||||
|
passBySite[p.Site] = p
|
||||||
|
}
|
||||||
|
view.Sites = make([]siteRow, 0, len(shapes[store.SeriesFilterAll]))
|
||||||
|
for _, sh := range shapes[store.SeriesFilterAll] {
|
||||||
|
row := siteRow{
|
||||||
|
Site: sh.Site,
|
||||||
|
SiteHref: seriesListHref("", sh.Site, "", 0),
|
||||||
|
Figs: []fig{
|
||||||
|
door("", sh.Total, seriesListHref("", sh.Site, "", 0)),
|
||||||
|
door("", siteCounts[store.SeriesFilterNoCover][sh.Site], seriesListHref(store.SeriesFilterNoCover, sh.Site, "", 0)),
|
||||||
|
door("", siteCounts[store.SeriesFilterNeverChecked][sh.Site], seriesListHref(store.SeriesFilterNeverChecked, sh.Site, "", 0)),
|
||||||
|
door("", siteCounts[store.SeriesFilterStale][sh.Site], seriesListHref(store.SeriesFilterStale, sh.Site, "", 0)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if p, ok := passBySite[sh.Site]; ok {
|
||||||
|
row.State, row.StateGood, row.StateBad = laneState(p, now)
|
||||||
|
} else {
|
||||||
|
row.State = "no pass yet"
|
||||||
|
}
|
||||||
|
view.Sites = append(view.Sites, row)
|
||||||
|
}
|
||||||
|
return view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// door is one figure with its door: the list that counts it. A measured zero
|
||||||
|
// is still a real figure, but the door closes — following it would land on an
|
||||||
|
// empty list. The count is written once so the figure and what it links to
|
||||||
|
// cannot drift apart.
|
||||||
|
func door(label string, count int, href string) fig {
|
||||||
|
if count == 0 {
|
||||||
|
href = ""
|
||||||
|
}
|
||||||
|
return fig{Label: label, Href: href, Count: count}
|
||||||
|
}
|
||||||
|
|
||||||
|
// overviewVerdict decides the landing page's one line from the latest pass
|
||||||
|
// per Site: no passes at all is "no Lane has reported yet" — never confident
|
||||||
|
// zeroes; otherwise the count of Lanes whose last pass needs the owner, or
|
||||||
|
// "all lanes healthy". The count comes from the same laneState judgement the
|
||||||
|
// Lanes page colours on, so the two pages cannot disagree on what a fault is.
|
||||||
|
func overviewVerdict(passes []store.LanePass, now time.Time) (phrase string, counts bool) {
|
||||||
|
if len(passes) == 0 {
|
||||||
|
return "no Lane has reported yet", false
|
||||||
|
}
|
||||||
|
attention := 0
|
||||||
|
for _, p := range passes {
|
||||||
|
if _, _, attn := laneState(p, now); attn {
|
||||||
|
attention++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if attention == 0 {
|
||||||
|
return "all lanes healthy", true
|
||||||
|
}
|
||||||
|
if attention == 1 {
|
||||||
|
return "1 lane needs a look", true
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d lanes need a look", attention), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// waiting sums Due over the latest pass per Site: how many Series the Lanes
|
||||||
|
// found waiting, from the durable log rather than a running poller.
|
||||||
|
func waiting(passes []store.LanePass) int {
|
||||||
|
n := 0
|
||||||
|
for _, p := range passes {
|
||||||
|
n += p.Due
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookmarkmanager/backend/internal/latest"
|
||||||
|
"bookmarkmanager/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seriesPageSize matches the store's row-read page length: the pager's range
|
||||||
|
// must agree with the LIMIT the store applies or the "of N" figure describes
|
||||||
|
// the wrong page. The store does not export it (#140).
|
||||||
|
const seriesPageSize = 50
|
||||||
|
|
||||||
|
// seriesFilterLabels names every hygiene filter for the Series list select,
|
||||||
|
// keyed by the wire constant the URL carries. The render order is
|
||||||
|
// seriesFilterOrder; the labels are read by later admin tickets too, so the
|
||||||
|
// map and the constants cannot drift apart.
|
||||||
|
var seriesFilterLabels = map[string]string{
|
||||||
|
store.SeriesFilterAll: "All series",
|
||||||
|
store.SeriesFilterNoURL: "No series URL",
|
||||||
|
store.SeriesFilterNoChapter: "Never read a chapter",
|
||||||
|
store.SeriesFilterNoReaders: "No Readers",
|
||||||
|
store.SeriesFilterNeverChecked: "Never checked",
|
||||||
|
store.SeriesFilterStale: "Not checked in 12h",
|
||||||
|
store.SeriesFilterNoCover: "No cover",
|
||||||
|
store.SeriesFilterReaderReport: "Latest from a Reader",
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesFilterOrder is the select's render order: All first, then the
|
||||||
|
// permanent repairs, then the fixable ones (issue #140).
|
||||||
|
var seriesFilterOrder = []string{
|
||||||
|
store.SeriesFilterAll,
|
||||||
|
store.SeriesFilterNoURL,
|
||||||
|
store.SeriesFilterNoChapter,
|
||||||
|
store.SeriesFilterNoReaders,
|
||||||
|
store.SeriesFilterNeverChecked,
|
||||||
|
store.SeriesFilterStale,
|
||||||
|
store.SeriesFilterNoCover,
|
||||||
|
store.SeriesFilterReaderReport,
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesListView is the Series list page's data. The template renders strings
|
||||||
|
// and flags, and every judgement about what a value means is made here.
|
||||||
|
type seriesListView struct {
|
||||||
|
Filters []seriesFilterOption
|
||||||
|
Sites []string
|
||||||
|
Site string // "" = every Site
|
||||||
|
Kind string // "" = both libraries
|
||||||
|
FilterLabel string
|
||||||
|
Rows []seriesRowView
|
||||||
|
Total int
|
||||||
|
// KindBoth / KindManga / KindNovel are the Library segment links, and
|
||||||
|
// PrevHref / NextHref the pager's, all carrying the active filter, Site
|
||||||
|
// and Kind so narrowing never drops state.
|
||||||
|
KindBoth string
|
||||||
|
KindManga string
|
||||||
|
KindNovel string
|
||||||
|
PrevHref string
|
||||||
|
NextHref string
|
||||||
|
Range string
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesFilterOption is one entry of the Show select: its wire value, its
|
||||||
|
// rendered label with the library-wide count, and whether it is the active
|
||||||
|
// filter.
|
||||||
|
type seriesFilterOption struct {
|
||||||
|
Name string
|
||||||
|
Label string
|
||||||
|
Count int
|
||||||
|
Selected bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesRowView is one Series row formatted for the template. Band carries
|
||||||
|
// the alternating row tint by class rather than nth-of-type, so the confirm
|
||||||
|
// rows later tickets add are row siblings without breaking the alternation.
|
||||||
|
// Attention tints the title patina: a row with any hygiene chip needs one.
|
||||||
|
//
|
||||||
|
// CanPoll is the Check now control's visibility: absent on a Series with no
|
||||||
|
// page to fetch and on an orphan, so the owner is never offered a button that
|
||||||
|
// can never do anything. Pending is derived — the request stamp is newer than
|
||||||
|
// the check stamp — and Requested is its ageing label.
|
||||||
|
type seriesRowView struct {
|
||||||
|
Key string
|
||||||
|
Title string
|
||||||
|
Site string
|
||||||
|
Ch string // chapter number; "—" until first captured
|
||||||
|
Age string // checked age; "never" until first check
|
||||||
|
Readers int
|
||||||
|
Notes []string // chips, capped at two
|
||||||
|
More int // chips past the cap, rendered as a +N tail
|
||||||
|
Band bool
|
||||||
|
Attention bool
|
||||||
|
CanPoll bool
|
||||||
|
Pending bool
|
||||||
|
Requested string // "requested 3m ago", rendered only while pending
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminSeries renders the filterable, bookmarkable Series list: filter, Site,
|
||||||
|
// Library and page all live in the query string, so the list's state is an
|
||||||
|
// address rather than a click path.
|
||||||
|
func (h *Handler) adminSeries(w http.ResponseWriter, r *http.Request) {
|
||||||
|
view, err := h.seriesListView(r)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("admin series: %v", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.renderAdmin(w, adminView{Page: "series", SeriesList: view})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminSeriesPoll is the Check now action: it stamps the Series' force_poll_at
|
||||||
|
// and answers with the freshly rendered row, so the figures describe the
|
||||||
|
// state after the press. The control never commands the poller — the request
|
||||||
|
// is a fact about the Series, and the Lane's next pass reads it through
|
||||||
|
// DueForLatestCheck (ADR-0013). The owner gate is the route's, not this
|
||||||
|
// handler's; the body is capped like the API path caps its bodies; the key is
|
||||||
|
// validated here — a malformed key is a 400 and an unknown one a 404.
|
||||||
|
func (h *Handler) adminSeriesPoll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
|
||||||
|
if !ok || site == "" || seriesID == "" {
|
||||||
|
http.Error(w, "bad series key", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil {
|
||||||
|
log.Printf("series poll %s: %v", site+":"+seriesID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
} else if !found {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.ForceSeriesPoll(site, seriesID, time.Now().UnixMilli()); err != nil {
|
||||||
|
log.Printf("series poll %s: %v", site+":"+seriesID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Re-read after the stamp: the answer must describe the state after the
|
||||||
|
// press. The detail page's control swaps its meta in place and the list
|
||||||
|
// row's swaps the row; htmx names an id target in HX-Target, so the
|
||||||
|
// response matches the surface it came from. The row's band parity travels
|
||||||
|
// with the press (hx-vals), so the swap keeps the zebra alternation.
|
||||||
|
a, found, err := h.adminSeriesByKey(site, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("series poll %s: %v", site+":"+seriesID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Header.Get("HX-Target") == "detail-meta" {
|
||||||
|
h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
band := 0
|
||||||
|
if r.PostFormValue("band") == "1" {
|
||||||
|
band = 1
|
||||||
|
}
|
||||||
|
h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesListView assembles one Series list view from the request's query
|
||||||
|
// string. An unknown filter value is the absent All case, never an error: the
|
||||||
|
// select's options are not the only way this URL can be reached.
|
||||||
|
func (h *Handler) seriesListView(r *http.Request) (seriesListView, error) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
filter := q.Get("filter")
|
||||||
|
if _, ok := seriesFilterLabels[filter]; !ok {
|
||||||
|
filter = store.SeriesFilterAll
|
||||||
|
}
|
||||||
|
site := q.Get("site")
|
||||||
|
kind := q.Get("kind")
|
||||||
|
if kind != store.KindManga && kind != store.KindNovel {
|
||||||
|
kind = ""
|
||||||
|
}
|
||||||
|
page := 1
|
||||||
|
if p, err := strconv.Atoi(q.Get("page")); err == nil && p > 1 {
|
||||||
|
page = p
|
||||||
|
}
|
||||||
|
|
||||||
|
sf := store.SeriesFilter{
|
||||||
|
Site: site,
|
||||||
|
Kind: kind,
|
||||||
|
Name: filter,
|
||||||
|
Cutoff: time.Now().Add(-ownerWindow).UnixMilli(),
|
||||||
|
Page: page,
|
||||||
|
}
|
||||||
|
data, err := h.store.SeriesPage(sf)
|
||||||
|
if err != nil {
|
||||||
|
return seriesListView{}, err
|
||||||
|
}
|
||||||
|
// A page past the end is not an empty list: the store's window count runs
|
||||||
|
// over the rows the result set carries, so an overflow page reports zero
|
||||||
|
// rows and zero total, and the list re-reads at page 1 to know the truth.
|
||||||
|
if len(data.Rows) == 0 && page > 1 {
|
||||||
|
page = 1
|
||||||
|
sf.Page = 1
|
||||||
|
data, err = h.store.SeriesPage(sf)
|
||||||
|
if err != nil {
|
||||||
|
return seriesListView{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view := seriesListView{
|
||||||
|
Site: site,
|
||||||
|
Kind: kind,
|
||||||
|
FilterLabel: seriesFilterLabels[filter],
|
||||||
|
Rows: make([]seriesRowView, 0, len(data.Rows)),
|
||||||
|
Total: data.Total,
|
||||||
|
Sites: latest.SiteNames(),
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
for i, a := range data.Rows {
|
||||||
|
view.Rows = append(view.Rows, seriesRow(a, i, now))
|
||||||
|
}
|
||||||
|
view.Filters, err = h.seriesFilterOptions(filter, sf.Cutoff)
|
||||||
|
if err != nil {
|
||||||
|
return seriesListView{}, err
|
||||||
|
}
|
||||||
|
view.KindBoth = seriesListHref(filter, site, "", 0)
|
||||||
|
view.KindManga = seriesListHref(filter, site, store.KindManga, 0)
|
||||||
|
view.KindNovel = seriesListHref(filter, site, store.KindNovel, 0)
|
||||||
|
if page > 1 {
|
||||||
|
view.PrevHref = seriesListHref(filter, site, kind, page-1)
|
||||||
|
}
|
||||||
|
if last := (data.Total + seriesPageSize - 1) / seriesPageSize; page < last {
|
||||||
|
view.NextHref = seriesListHref(filter, site, kind, page+1)
|
||||||
|
}
|
||||||
|
view.Range = pagerRange(data.Total, len(data.Rows), page)
|
||||||
|
return view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesFilterOptions renders every hygiene filter with its library-wide
|
||||||
|
// count, one SeriesShapes pass per filter summed in Go — the shipped surface
|
||||||
|
// offers eight grouped passes, not a single stats query (#140). The counts
|
||||||
|
// are library-wide because the select sits next to the Site narrowing and
|
||||||
|
// must not shift as the owner narrows the list itself. Cutoff travels with
|
||||||
|
// the stale filter, or its count would always be zero.
|
||||||
|
func (h *Handler) seriesFilterOptions(selected string, cutoff int64) ([]seriesFilterOption, error) {
|
||||||
|
out := make([]seriesFilterOption, 0, len(seriesFilterOrder))
|
||||||
|
for _, name := range seriesFilterOrder {
|
||||||
|
shapes, err := h.store.SeriesShapes(store.SeriesFilter{Name: name, Cutoff: cutoff})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for _, sh := range shapes {
|
||||||
|
count += sh.Total
|
||||||
|
}
|
||||||
|
out = append(out, seriesFilterOption{
|
||||||
|
Name: name,
|
||||||
|
Label: seriesFilterLabels[name],
|
||||||
|
Count: count,
|
||||||
|
Selected: name == selected,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesRow shapes one store row for the template, capping its chips at two
|
||||||
|
// plus a +N tail; attention marks a row that carries any.
|
||||||
|
// pollState derives the Check now control and the pending marker (issue
|
||||||
|
// #146), shared by the list row and the detail page: CanPoll is false on a
|
||||||
|
// Series with no page to fetch and on an orphan, so the owner is never
|
||||||
|
// offered a button that can never do anything. Pending is derived — the
|
||||||
|
// request stamp is newer than the check stamp — and requested is its ageing
|
||||||
|
// label, which never expires.
|
||||||
|
func pollState(a store.AdminSeries, now time.Time) (canPoll, pending bool, requested string) {
|
||||||
|
canPoll = a.SeriesURL != "" && a.ReaderCount > 0
|
||||||
|
if a.ForcePollAt > a.LatestCheckedAt {
|
||||||
|
pending = true
|
||||||
|
requested = requestedAge(now, a.ForcePollAt)
|
||||||
|
}
|
||||||
|
return canPoll, pending, requested
|
||||||
|
}
|
||||||
|
|
||||||
|
func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView {
|
||||||
|
canPoll, pending, requested := pollState(a, now)
|
||||||
|
row := seriesRowView{
|
||||||
|
Key: a.Key(),
|
||||||
|
Site: a.Site,
|
||||||
|
Title: a.Title,
|
||||||
|
Readers: a.ReaderCount,
|
||||||
|
Band: i%2 == 1,
|
||||||
|
CanPoll: canPoll,
|
||||||
|
Pending: pending,
|
||||||
|
Requested: requested,
|
||||||
|
}
|
||||||
|
if a.LatestChapterNum != nil {
|
||||||
|
row.Ch = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64)
|
||||||
|
} else {
|
||||||
|
row.Ch = "—"
|
||||||
|
}
|
||||||
|
row.Age = checkedAge(now, a.LatestCheckedAt)
|
||||||
|
notes := seriesNotes(a, now)
|
||||||
|
if n := len(notes); n > 2 {
|
||||||
|
row.Notes, row.More = notes[:2], n-2
|
||||||
|
} else {
|
||||||
|
row.Notes = notes
|
||||||
|
}
|
||||||
|
row.Attention = len(notes) > 0
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesNotes are a row's hygiene chips in the design's order: no URL, no
|
||||||
|
// cover, orphan, stale, reader sighting.
|
||||||
|
func seriesNotes(a store.AdminSeries, now time.Time) []string {
|
||||||
|
notes := []string{}
|
||||||
|
if a.SeriesURL == "" {
|
||||||
|
notes = append(notes, "no URL")
|
||||||
|
}
|
||||||
|
if a.CoverAddress == "" {
|
||||||
|
notes = append(notes, "no cover")
|
||||||
|
}
|
||||||
|
if a.ReaderCount == 0 {
|
||||||
|
notes = append(notes, "orphan")
|
||||||
|
}
|
||||||
|
if a.LatestCheckedAt > 0 && a.LatestCheckedAt < now.Add(-ownerWindow).UnixMilli() {
|
||||||
|
notes = append(notes, "stale")
|
||||||
|
}
|
||||||
|
if a.RaisedByReader {
|
||||||
|
notes = append(notes, "reader sighting")
|
||||||
|
}
|
||||||
|
return notes
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkedAge formats how long ago a Series was last checked, at the
|
||||||
|
// granularity the list reads at — minutes, hours, days. Zero means never.
|
||||||
|
func checkedAge(now time.Time, ts int64) string {
|
||||||
|
if ts == 0 {
|
||||||
|
return "never"
|
||||||
|
}
|
||||||
|
d := now.Sub(time.UnixMilli(ts))
|
||||||
|
switch {
|
||||||
|
case d < time.Hour:
|
||||||
|
m := int(d / time.Minute)
|
||||||
|
if m < 1 {
|
||||||
|
m = 1
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dm ago", m)
|
||||||
|
case d < 24*time.Hour:
|
||||||
|
return fmt.Sprintf("%dh ago", int(d/time.Hour))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestedAge is the pending marker's text: how long ago the owner asked,
|
||||||
|
// and nothing about when the request will run — the page does not know when a
|
||||||
|
// sleeping browser will wake (issue #146). An unanswered request ages forever;
|
||||||
|
// there is no expiry.
|
||||||
|
func requestedAge(now time.Time, ts int64) string {
|
||||||
|
return "requested " + checkedAge(now, ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pagerRange is the pager's "1–50 of 120" line. The template renders the
|
||||||
|
// pager only over rows (the empty state replaces it), so it is never asked
|
||||||
|
// to describe an empty list.
|
||||||
|
func pagerRange(total, rows, page int) string {
|
||||||
|
from := (page-1)*seriesPageSize + 1
|
||||||
|
return fmt.Sprintf("%d–%d of %d", from, from+rows-1, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesListHref is one Series list address carrying the filter, Site, Kind
|
||||||
|
// and page. The All filter and page 1 are the absent cases and stay out of
|
||||||
|
// the URL, so the default address is the shortest one.
|
||||||
|
func seriesListHref(filter, site, kind string, page int) string {
|
||||||
|
q := url.Values{}
|
||||||
|
if filter != "" && filter != store.SeriesFilterAll {
|
||||||
|
q.Set("filter", filter)
|
||||||
|
}
|
||||||
|
if site != "" {
|
||||||
|
q.Set("site", site)
|
||||||
|
}
|
||||||
|
if kind != "" {
|
||||||
|
q.Set("kind", kind)
|
||||||
|
}
|
||||||
|
if page > 1 {
|
||||||
|
q.Set("page", strconv.Itoa(page))
|
||||||
|
}
|
||||||
|
if len(q) == 0 {
|
||||||
|
return "/admin/series"
|
||||||
|
}
|
||||||
|
return "/admin/series?" + q.Encode()
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookmarkmanager/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seriesDetailView is one Series' page as the owner sees it: strings and
|
||||||
|
// flags, every judgement made here, the template left to print. ReaderCount
|
||||||
|
// is the only figure that crosses the privacy boundary — the owner learns how
|
||||||
|
// many Readers hold the Series, never which Reader reads what.
|
||||||
|
type seriesDetailView struct {
|
||||||
|
Key string // "<site>:<series_id>", the page's address and the Series' identity
|
||||||
|
Site string
|
||||||
|
Kind string
|
||||||
|
// Title, Cover and Chapter come from the shared Series row; the Cover is
|
||||||
|
// the wire URL of the stored bytes, "" before any exist.
|
||||||
|
Title string
|
||||||
|
Cover string
|
||||||
|
Chapter string // Latest Chapter number, or "—" before the first capture
|
||||||
|
Checked string // how long ago the poller last checked, or "never"
|
||||||
|
Readers int
|
||||||
|
|
||||||
|
// Marks, one per hygiene fact, rendered only while it holds.
|
||||||
|
Unpollable bool // no SeriesURL to fetch
|
||||||
|
NoCover bool
|
||||||
|
Orphan bool // no Reader holds the Series
|
||||||
|
SightingRaised bool // a Reader's Sighting set the Latest Chapter
|
||||||
|
|
||||||
|
// Poll is the Check now control and the pending marker (issue #146): the
|
||||||
|
// same derivation and visibility as the list row. CanPoll is false on a
|
||||||
|
// Series with no page to fetch and on an orphan; Pending is derived —
|
||||||
|
// the request stamp is newer than the check stamp — and Requested is its
|
||||||
|
// ageing label.
|
||||||
|
CanPoll bool
|
||||||
|
Pending bool
|
||||||
|
Requested string
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminSeriesDetail renders one Series' page, keyed by the composite
|
||||||
|
// "<site>:<series_id>" the list row already shows. The row is read through
|
||||||
|
// the list's own SeriesPage read narrowed to the key's Site: the admin
|
||||||
|
// projection is the privacy boundary, and a dedicated single-row read would
|
||||||
|
// be a second definition of it.
|
||||||
|
func (h *Handler) adminSeriesDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
|
||||||
|
if !ok || site == "" || seriesID == "" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a, found, err := h.adminSeriesByKey(site, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("series detail %s: %v", site+":"+seriesID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.renderAdmin(w, adminView{Page: "series-detail", Detail: h.seriesDetailView(a)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminSeriesByKey reads one Series through the list's own SeriesPage read
|
||||||
|
// narrowed to the key's Site: the admin projection is the privacy boundary,
|
||||||
|
// and a dedicated single-row read would be a second definition of it. Absence
|
||||||
|
// is reported with found=false, never an error.
|
||||||
|
// ponytail: a page scan per keyed read, one query per page of the Site's rows
|
||||||
|
// up to the window total; a keyed read alongside SeriesPage when the library
|
||||||
|
// outgrows the page size.
|
||||||
|
func (h *Handler) adminSeriesByKey(site, seriesID string) (store.AdminSeries, bool, error) {
|
||||||
|
seen := 0
|
||||||
|
for page := 1; ; page++ {
|
||||||
|
p, err := h.store.SeriesPage(store.SeriesFilter{Site: site, Page: page})
|
||||||
|
if err != nil {
|
||||||
|
return store.AdminSeries{}, false, err
|
||||||
|
}
|
||||||
|
seen += len(p.Rows)
|
||||||
|
for i := range p.Rows {
|
||||||
|
if p.Rows[i].SeriesID == seriesID {
|
||||||
|
return p.Rows[i], true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if seen >= p.Total {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return store.AdminSeries{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesDetailView shapes one AdminSeries row for display: every judgement in
|
||||||
|
// Go, the template left to print strings and flags.
|
||||||
|
func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
|
||||||
|
canPoll, pending, requested := pollState(a, time.Now())
|
||||||
|
v := seriesDetailView{
|
||||||
|
Key: a.Key(),
|
||||||
|
Site: a.Site,
|
||||||
|
Kind: a.Kind,
|
||||||
|
Title: a.Title,
|
||||||
|
Cover: h.store.CoverWireURL(a.CoverAddress),
|
||||||
|
Readers: a.ReaderCount,
|
||||||
|
Unpollable: a.SeriesURL == "",
|
||||||
|
NoCover: a.CoverAddress == "",
|
||||||
|
Orphan: a.ReaderCount == 0,
|
||||||
|
SightingRaised: a.RaisedByReader,
|
||||||
|
CanPoll: canPoll,
|
||||||
|
Pending: pending,
|
||||||
|
Requested: requested,
|
||||||
|
}
|
||||||
|
if a.LatestChapterNum == nil {
|
||||||
|
v.Chapter = "—"
|
||||||
|
} else {
|
||||||
|
v.Chapter = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64)
|
||||||
|
}
|
||||||
|
if a.LatestCheckedAt == 0 {
|
||||||
|
v.Checked = "never"
|
||||||
|
} else {
|
||||||
|
v.Checked = since(time.Now(), time.UnixMilli(a.LatestCheckedAt))
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -0,0 +1,830 @@
|
|||||||
|
:root {
|
||||||
|
--measure-wide: 1080px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--measure-wide: 1080px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet {
|
||||||
|
max-width: var(--measure-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .brand em {
|
||||||
|
color: var(--patina);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .brand .mark > g > g:last-child {
|
||||||
|
stroke: var(--patina);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navrow {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 2px 20px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navrow::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navrow a {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 44px;
|
||||||
|
padding: 8px 0 12px;
|
||||||
|
color: var(--mute);
|
||||||
|
font: 400 17px var(--font-display);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navrow a:hover {
|
||||||
|
color: var(--paper-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navrow a.active {
|
||||||
|
color: var(--paper);
|
||||||
|
border-bottom: 2px solid var(--paper);
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page {
|
||||||
|
padding: 0 20px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page > .sec,
|
||||||
|
.admin-sheet .readers h2,
|
||||||
|
.admin-sheet .lanes h2 {
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
padding: 26px 0 8px;
|
||||||
|
font: 500 11px/1 var(--font-mono);
|
||||||
|
letter-spacing: .2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--mute-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page > .sec::before,
|
||||||
|
.admin-sheet .readers h2::before,
|
||||||
|
.admin-sheet .lanes h2::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
left: 0;
|
||||||
|
width: 34px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--patina);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .readers,
|
||||||
|
.admin-sheet .lanes {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 16px;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .readerlist,
|
||||||
|
.admin-sheet .lanelist {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .readerlist li,
|
||||||
|
.admin-sheet .lanelist li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 16px;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .reader-actions {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 18px;
|
||||||
|
margin-left: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .reader-actions .ghost,
|
||||||
|
.admin-sheet .c-act .ghost {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--patina);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .reader-actions .ghost.danger,
|
||||||
|
.admin-sheet .c-act .ghost.danger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .readerlist form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .lane-browser {
|
||||||
|
padding: 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .ghost.danger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .ghost.danger:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
border-bottom-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .reader-id {
|
||||||
|
font: 500 15px/1.5 var(--font-mono);
|
||||||
|
letter-spacing: .01em;
|
||||||
|
color: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .reader-sessions,
|
||||||
|
.admin-sheet .reader-sightings,
|
||||||
|
.admin-sheet .reader-blocked,
|
||||||
|
.admin-sheet .lane-fact,
|
||||||
|
.admin-sheet .lane-mark {
|
||||||
|
font: 500 13px/1.4 var(--font-mono);
|
||||||
|
letter-spacing: .06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .reader-sessions {
|
||||||
|
color: var(--paper-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .reader-sightings,
|
||||||
|
.admin-sheet .lane-fact {
|
||||||
|
color: var(--mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .reader-blocked,
|
||||||
|
.admin-sheet .lane-mark {
|
||||||
|
color: var(--patina);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .lane-site {
|
||||||
|
font: 400 19px/1.2 var(--font-display);
|
||||||
|
color: var(--paper-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .lanelist li.attention .lane-site {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The verdict line is set in the data face, not the display face: it is
|
||||||
|
three counts, not a page title. The judgement is the only bright thing. */
|
||||||
|
.admin-sheet .verdict {
|
||||||
|
padding: 16px 0 12px;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
font: 500 15px/1.6 var(--font-mono);
|
||||||
|
letter-spacing: .04em;
|
||||||
|
color: var(--mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .verdict .attn {
|
||||||
|
color: var(--patina);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .verdict .counts b {
|
||||||
|
color: var(--paper);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(240px, 1fr) 156px 92px 110px 76px minmax(150px, 220px) 140px;
|
||||||
|
column-gap: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .thead {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .thead > * {
|
||||||
|
padding: 10px 14px 8px 0;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
font: 500 12px/1 var(--font-mono);
|
||||||
|
letter-spacing: .12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--mute-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .trow {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .trow > * {
|
||||||
|
padding: 11px 14px 11px 0;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
font: 500 15px/1.5 var(--font-mono);
|
||||||
|
letter-spacing: .01em;
|
||||||
|
color: var(--paper-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .thead > *:last-child,
|
||||||
|
.admin-sheet .tbl .trow > *:last-child {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .c-title,
|
||||||
|
.admin-sheet .tbl .c-site {
|
||||||
|
font: 400 18px/1.35 var(--font-display);
|
||||||
|
letter-spacing: 0;
|
||||||
|
color: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .c-title a:hover,
|
||||||
|
.admin-sheet .tbl .c-act .ghost:hover {
|
||||||
|
color: var(--patina);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .trow.attention .c-title {
|
||||||
|
color: var(--patina);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .c-ch,
|
||||||
|
.admin-sheet .tbl .c-rd {
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .trow .c-note .mark {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .c-act {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .c-act .ghost + .ghost {
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .trow > .confirm-row {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(232px, 1fr));
|
||||||
|
margin: 4px 0 0;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .stat {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 11px 24px 11px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .stat .lbl {
|
||||||
|
font: 500 13px/1.3 var(--font-mono);
|
||||||
|
letter-spacing: .1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--mute-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .stat .fig {
|
||||||
|
font: 500 19px/1 var(--font-mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .stat .fig.zero {
|
||||||
|
color: var(--mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .stat a.fig {
|
||||||
|
/* The hover rule below sets the border on :hover only, which shifts the
|
||||||
|
row a pixel on hover; the transparent baseline holds the layout. */
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .stat a.fig:hover {
|
||||||
|
color: var(--patina);
|
||||||
|
border-bottom: 1px solid var(--patina);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .mark,
|
||||||
|
.admin-sheet .mark-faint {
|
||||||
|
font: 500 13px/1 var(--font-mono);
|
||||||
|
letter-spacing: .06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .mark {
|
||||||
|
color: var(--patina);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .mark-faint {
|
||||||
|
color: var(--mute-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .mark.mark-strong {
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: .14em;
|
||||||
|
color: var(--patina);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .mark.mark-strong::before {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--patina);
|
||||||
|
margin-right: 8px;
|
||||||
|
vertical-align: .08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .mark.bad {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.sites {
|
||||||
|
grid-template-columns: 170px repeat(4, 106px) minmax(180px, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.sites .c-site,
|
||||||
|
.admin-sheet .tbl.lanes .c-site {
|
||||||
|
font: 400 18px/1.35 var(--font-display);
|
||||||
|
letter-spacing: 0;
|
||||||
|
color: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.sites .c-state {
|
||||||
|
color: var(--patina);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.sites .c-state.bad,
|
||||||
|
.admin-sheet .tbl.lanes .c-skip .bad,
|
||||||
|
.admin-sheet .tbl.lanes .trow.attention .c-site {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.lanes .c-skip .ok {
|
||||||
|
color: var(--patina);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.sites .thead > *:nth-child(n+2):nth-child(-n+5),
|
||||||
|
.admin-sheet .tbl.sites .trow > *:nth-child(n+2):nth-child(-n+5) {
|
||||||
|
padding-right: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.lanes {
|
||||||
|
grid-template-columns: 150px 62px 88px 66px 118px minmax(0, 1fr) 168px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.lanes .c-skip {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.lanes .c-skip > * {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.lanes .c-ctrl {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.lanes .thead > *:nth-child(n+2):nth-child(-n+4),
|
||||||
|
.admin-sheet .tbl.lanes .trow > *:nth-child(n+2):nth-child(-n+4) {
|
||||||
|
padding-right: 26px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.series {
|
||||||
|
grid-template-columns: 150px 84px 104px 76px minmax(120px, 1fr) 212px;
|
||||||
|
row-gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.series .thead > *:first-child {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.series .thead > *:last-child {
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The site cell's colour is a class, never an inline style: site is
|
||||||
|
client-supplied and unvalidated, and a hostile value reaching a CSS
|
||||||
|
context would render ZgotmplZ, while an unknown class degrades to the
|
||||||
|
unstyled cell. */
|
||||||
|
.admin-sheet .tbl .c-site.site-asura {
|
||||||
|
color: var(--asura);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .c-site.site-demonic {
|
||||||
|
color: var(--demonic);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .c-site.site-comix {
|
||||||
|
color: var(--comix);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .c-site.site-kagane {
|
||||||
|
color: var(--kagane);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .c-site.site-novelfull {
|
||||||
|
color: var(--novelfull);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .c-site.site-lightnovelworld {
|
||||||
|
color: var(--lightnovelworld);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.series .trow {
|
||||||
|
display: grid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-template-columns: subgrid;
|
||||||
|
padding: 13px 20px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.series .trow.band {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.series .trow > * {
|
||||||
|
padding: 0 14px 0 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.series .c-title {
|
||||||
|
display: flex;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.series .c-title .mark {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .filterbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px 22px;
|
||||||
|
padding: 16px 0 14px;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .fsel {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .fsel > span {
|
||||||
|
font: 500 11px/1 var(--font-mono);
|
||||||
|
letter-spacing: .14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--mute-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .fsel select,
|
||||||
|
.admin-sheet .pausebar select {
|
||||||
|
color-scheme: dark;
|
||||||
|
background: var(--ink);
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--field-line);
|
||||||
|
color: var(--paper);
|
||||||
|
font: 400 16px/1.4 var(--font-display);
|
||||||
|
padding: 4px 4px 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .fsel select:hover,
|
||||||
|
.admin-sheet .pausebar select:hover {
|
||||||
|
border-bottom-color: var(--patina);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .fsel select option,
|
||||||
|
.admin-sheet .pausebar select option {
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .segrow {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .segrow a {
|
||||||
|
padding: 6px 11px 7px;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--mute);
|
||||||
|
font: 500 14px/1 var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .segrow a.active {
|
||||||
|
border-bottom-color: var(--patina);
|
||||||
|
color: var(--patina);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .listhead {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 0 4px;
|
||||||
|
color: var(--paper);
|
||||||
|
font: 400 18px/1.2 var(--font-display);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .listhead .lbl {
|
||||||
|
color: var(--mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .listhead .lbl em {
|
||||||
|
color: var(--patina);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .pager {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 0 0;
|
||||||
|
color: var(--mute-2);
|
||||||
|
font: 500 11px/1 var(--font-mono);
|
||||||
|
letter-spacing: .14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .pager .pg {
|
||||||
|
color: var(--paper-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .pager .pg:hover {
|
||||||
|
color: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .pager .pg.disabled {
|
||||||
|
color: var(--faint);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .empty {
|
||||||
|
padding: 28px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .empty strong {
|
||||||
|
color: var(--paper);
|
||||||
|
font: 400 20px var(--font-display);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .empty p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font: 14px/1.5 var(--font-body);
|
||||||
|
color: var(--mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .confirm-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--danger-wash);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .confirm-row span {
|
||||||
|
flex: 1 1 16ch;
|
||||||
|
color: var(--danger-soft);
|
||||||
|
font: 400 15px/1.3 var(--font-display);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .confirm-row div {
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
gap: 12px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .detail-back {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 18px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .detail-title {
|
||||||
|
margin: 10px 0 2px;
|
||||||
|
color: var(--paper);
|
||||||
|
font: 400 28px/1.25 var(--font-display);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .detail-key {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--mute-2);
|
||||||
|
font: 500 11px/1.4 var(--font-mono);
|
||||||
|
letter-spacing: .08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .detail-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 14px;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: var(--mute-2);
|
||||||
|
font: 500 12px/1.5 var(--font-mono);
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .cover {
|
||||||
|
width: 160px;
|
||||||
|
aspect-ratio: 3 / 4;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 18px 0 4px;
|
||||||
|
background: var(--hatch);
|
||||||
|
color: var(--mute-2);
|
||||||
|
font: 500 10px/1 var(--font-mono);
|
||||||
|
letter-spacing: .2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .dform {
|
||||||
|
padding: 14px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .dform h3 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
color: var(--mute-2);
|
||||||
|
font: 500 10px/1 var(--font-mono);
|
||||||
|
letter-spacing: .2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .dform .field {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .pausebar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 719px) {
|
||||||
|
.admin-sheet .topbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .brand {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .topbar-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1019px) {
|
||||||
|
.admin-sheet .tbl.lanes {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.lanes .thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.lanes .trow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px 16px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.lanes .trow > * {
|
||||||
|
padding: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.lanes .c-site {
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.lanes .c-ctrl {
|
||||||
|
margin-left: auto;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.admin-sheet .tbl,
|
||||||
|
.admin-sheet .tbl.lanes {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .trow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px 10px;
|
||||||
|
padding: 11px 0;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .trow > * {
|
||||||
|
padding: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .c-ch,
|
||||||
|
.admin-sheet .tbl .c-rd {
|
||||||
|
padding-right: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .c-title {
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.series .trow {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.series .trow > * {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .detail-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .c-act {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -296,69 +296,6 @@ button { cursor: pointer; }
|
|||||||
letter-spacing: .04em;
|
letter-spacing: .04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- admin page: two sections on the same measured sheet, no cards ----
|
|
||||||
The reading page is a list of series; this is a list of facts. Both are
|
|
||||||
sheets of hairline-separated rows, so the roster keeps the shape it had as
|
|
||||||
a fold-out and the Lane block copies it. */
|
|
||||||
.readers, .lanes { margin: 0 20px; padding: 12px 0 16px; border-bottom: 1px solid var(--rule); }
|
|
||||||
.readers h2, .lanes h2 {
|
|
||||||
margin: 0;
|
|
||||||
padding: 8px 0;
|
|
||||||
font: 500 10px/1 var(--font-mono);
|
|
||||||
letter-spacing: .2em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--mute-2);
|
|
||||||
}
|
|
||||||
.readerlist, .lanelist { margin: 0; padding: 0; list-style: none; }
|
|
||||||
.readerlist li, .lanelist li {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px 16px;
|
|
||||||
min-height: 44px;
|
|
||||||
border-top: 1px solid var(--rule);
|
|
||||||
}
|
|
||||||
.reader-actions { display: flex; gap: 18px; margin-left: auto; }
|
|
||||||
.readerlist form { margin: 0; }
|
|
||||||
.reader-id {
|
|
||||||
font: 500 13px/1.4 var(--font-mono);
|
|
||||||
letter-spacing: .04em;
|
|
||||||
color: var(--paper);
|
|
||||||
}
|
|
||||||
.reader-sessions {
|
|
||||||
font: 500 10px/1 var(--font-mono);
|
|
||||||
letter-spacing: .14em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--mute);
|
|
||||||
}
|
|
||||||
.reader-sightings, .lane-fact {
|
|
||||||
font: 500 10px/1 var(--font-mono);
|
|
||||||
letter-spacing: .14em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--mute-2);
|
|
||||||
}
|
|
||||||
/* Two states the owner is meant to find rather than read for: a Reader whose
|
|
||||||
reports no longer defer a Poll, and a Lane that is not keeping its promise.
|
|
||||||
Both wear --patina — never ember, which means one thing, and never danger,
|
|
||||||
which is destruction. */
|
|
||||||
.reader-blocked, .lane-mark {
|
|
||||||
font: 500 10px/1 var(--font-mono);
|
|
||||||
letter-spacing: .14em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--patina);
|
|
||||||
}
|
|
||||||
.lane-site {
|
|
||||||
font: 400 19px/1.2 var(--font-display);
|
|
||||||
color: var(--paper-dim);
|
|
||||||
}
|
|
||||||
/* The whole row leans patina when the Lane needs attention, so the scan is one
|
|
||||||
pass down the left edge rather than a read of every mark. */
|
|
||||||
.lanelist li.attention .lane-site { color: var(--patina); }
|
|
||||||
.lane-browser { padding: 12px 0 0; }
|
|
||||||
/* Revocation cuts someone off, so it wears --danger. Ember stays reserved for
|
|
||||||
the new-chapter signal. */
|
|
||||||
.ghost.danger { color: var(--danger); }
|
|
||||||
.ghost.danger:hover { color: var(--danger); border-bottom-color: var(--danger); }
|
|
||||||
|
|
||||||
.chrome { display: flex; flex-direction: column; }
|
.chrome { display: flex; flex-direction: column; }
|
||||||
|
|
||||||
@@ -643,9 +580,6 @@ button { cursor: pointer; }
|
|||||||
box-shadow: inset 0 -2px 0 var(--ember);
|
box-shadow: inset 0 -2px 0 var(--ember);
|
||||||
}
|
}
|
||||||
.topbar form { margin-left: 18px; }
|
.topbar form { margin-left: 18px; }
|
||||||
/* The admin page's topbar has no switch to fill the middle, so its back link
|
|
||||||
keeps company with Log out at the right edge instead of floating centre. */
|
|
||||||
.topbar .back { margin-left: auto; }
|
|
||||||
/* At phone width brand + switch + Log out do not fit on one line, so the
|
/* At phone width brand + switch + Log out do not fit on one line, so the
|
||||||
switch takes its own row under the wordmark rather than pushing Log out
|
switch takes its own row under the wordmark rather than pushing Log out
|
||||||
off-screen. */
|
off-screen. */
|
||||||
@@ -655,9 +589,6 @@ button { cursor: pointer; }
|
|||||||
.libswitch { order: 3; margin-left: 0; }
|
.libswitch { order: 3; margin-left: 0; }
|
||||||
.libswitch a { flex: 1; text-align: center; padding: 8px 14px; }
|
.libswitch a { flex: 1; text-align: center; padding: 8px 14px; }
|
||||||
.topbar form { margin-left: 12px; }
|
.topbar form { margin-left: 12px; }
|
||||||
/* The admin page has no switch to take the second row, so its brand claims
|
|
||||||
the first outright and the back link keeps Log out company below. */
|
|
||||||
.topbar:has(.back) .brand { flex: 1 1 100%; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- action strip: full-width on a phone, hairline-divided cells ---- */
|
/* ---- action strip: full-width on a phone, hairline-divided cells ---- */
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
{{/* The owner's administrative page: everything that reaches past one Reader,
|
{{/* Every owner-only address shares this shell; page content stays behind its
|
||||||
at its own address so it can be bookmarked rather than hunted for inside
|
bookmarkable route so the active tab survives a reload. */}}
|
||||||
the reading page. Owner-only at route registration (requireOwner), which
|
|
||||||
is why nothing in here re-tests who is asking. */}}
|
|
||||||
{{define "admin"}}
|
{{define "admin"}}
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -12,27 +10,40 @@
|
|||||||
<title>BookmarkManager — Admin</title>
|
<title>BookmarkManager — Admin</title>
|
||||||
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
|
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/admin.css">
|
||||||
<link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin>
|
<link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
<script src="/static/htmx.min.js" defer></script>
|
<script src="/static/htmx.min.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="sheet">
|
<div class="sheet admin-sheet">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<h1 class="brand">{{template "mark" .}}<span>Bookmark<em>Manager</em></span></h1>
|
<h1 class="brand">{{template "mark" .}}<span>Bookmark<em>Manager</em></span></h1>
|
||||||
{{/* Back to the library, no switch: this page belongs to neither library,
|
<span class="topbar-actions">
|
||||||
and the ember-lit switch says which library you are reading. */}}
|
<a class="ghost" href="/">Library</a>
|
||||||
<a class="ghost back" href="/">Library</a>
|
|
||||||
<form method="post" action="/logout">
|
<form method="post" action="/logout">
|
||||||
<button type="submit" class="ghost">Log out</button>
|
<button type="submit" class="ghost">Log out</button>
|
||||||
</form>
|
</form>
|
||||||
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{{/* The live region wraps the swapped block rather than being it: the
|
<nav class="navrow" aria-label="Admin pages">
|
||||||
refresh replaces the section wholesale, and a region recreated on every
|
<a href="/admin" class="{{if eq .Page "overview"}}active{{end}}" {{if eq .Page "overview"}}aria-current="page"{{end}}>Overview</a>
|
||||||
update is never announced. */}}
|
<a href="/admin/lanes" class="{{if eq .Page "lanes"}}active{{end}}" {{if eq .Page "lanes"}}aria-current="page"{{end}}>Lanes</a>
|
||||||
<div aria-live="polite">{{template "lanes" .Lanes}}</div>
|
<a href="/admin/readers" class="{{if eq .Page "readers"}}active{{end}}" {{if eq .Page "readers"}}aria-current="page"{{end}}>Readers</a>
|
||||||
|
<a href="/admin/series" class="{{if or (eq .Page "series") (eq .Page "series-detail")}}active{{end}}" {{if or (eq .Page "series") (eq .Page "series-detail")}}aria-current="page"{{end}}>Series</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="page admin-page">
|
||||||
|
{{if eq .Page "lanes"}}
|
||||||
|
<div aria-live="polite">{{template "lanes" .Lanes}}</div>
|
||||||
|
{{else if eq .Page "readers"}}
|
||||||
{{template "readers" .}}
|
{{template "readers" .}}
|
||||||
|
{{else if eq .Page "series"}}
|
||||||
|
{{template "series-list" .SeriesList}}
|
||||||
|
{{else if eq .Page "series-detail"}}
|
||||||
|
{{template "series-detail" .Detail}}
|
||||||
|
{{else}}{{template "overview" .Overview}}{{end}}
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,42 +1,60 @@
|
|||||||
{{/* 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}}
|
{{/* The pause control lives in the one slot the design leaves for it:
|
||||||
{{if .BrowserLost}}<span class="lane-mark">no browser</span>{{end}}
|
a running Lane offers the three durations and Pause; a paused Lane
|
||||||
{{if .Stalled}}<span class="lane-mark">not checking</span>{{end}}
|
offers Resume in the same place. Pause is not destruction — it
|
||||||
{{if .Asleep}}<span class="lane-mark">browser asleep</span>{{end}}
|
takes nothing away and reverses in one press — so neither wears a
|
||||||
</li>
|
confirm row or the danger accent. The form wraps the select so the
|
||||||
|
offered duration travels with the press. */}}
|
||||||
|
<span class="c-ctrl">{{if .Paused}}<span class="pausebar">
|
||||||
|
<form hx-post="/admin/lanes/{{.Site}}/resume" hx-target="#lanes" hx-swap="outerHTML">
|
||||||
|
<button type="submit" class="ghost">Resume</button>
|
||||||
|
</form>
|
||||||
|
</span>{{else}}<span class="pausebar">
|
||||||
|
<form hx-post="/admin/lanes/{{.Site}}/pause" hx-target="#lanes" hx-swap="outerHTML">
|
||||||
|
<select name="duration" aria-label="Pause duration">
|
||||||
|
<option>1h</option><option selected>6h</option><option>24h</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="ghost">Pause</button>
|
||||||
|
</form>
|
||||||
|
</span>{{end}}</span>
|
||||||
|
</div>
|
||||||
{{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}}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{{/* The Overview landing page: one verdict line leading, then a stats block
|
||||||
|
where every figure is a door into the list that counts it, and the
|
||||||
|
per-Site library shape table. Every judgement — the verdict state, which
|
||||||
|
figures link, what a Lane's state means — is made in Go; this template
|
||||||
|
only prints. */}}
|
||||||
|
{{define "overview"}}
|
||||||
|
<p class="verdict"><span class="attn">{{.Verdict}}</span> {{if .HasCounts}}<span class="counts">· <b>{{.Waiting}}</b> series waiting · <b>{{.Unchecked}}</b> unchecked over 12h</span>{{end}}</p>
|
||||||
|
<h2 class="sec">Hygiene</h2>
|
||||||
|
<div class="stats">{{range .Hygiene}}<div class="stat"><span class="lbl">{{.Label}}</span>{{if .Href}}<a class="fig" href="{{.Href}}">{{.Count}}</a>{{else}}<span class="fig zero">{{.Count}}</span>{{end}}</div>{{end}}</div>
|
||||||
|
<h2 class="sec">Library</h2>
|
||||||
|
<div class="stats">{{range .Library}}<div class="stat"><span class="lbl">{{.Label}}</span>{{if .Href}}<a class="fig" href="{{.Href}}">{{.Count}}</a>{{else}}<span class="fig zero">{{.Count}}</span>{{end}}</div>{{end}}</div>
|
||||||
|
<h2 class="sec">Sites · last 12h</h2>
|
||||||
|
<div class="tbl sites"><div class="thead"><span>Site</span><span>Series</span><span>No cover</span><span>Never chk</span><span>Stale</span><span>State</span></div>{{range .Sites}}<div class="trow"><a class="c-site site-{{.Site}}" href="{{.SiteHref}}">{{.Site}}</a>{{range .Figs}}{{if .Href}}<a class="fig" href="{{.Href}}">{{.Count}}</a>{{else}}<span class="fig zero">{{.Count}}</span>{{end}}{{end}}<span class="c-state{{if .StateGood}} ok{{end}}{{if .StateBad}} bad{{end}}">{{.State}}</span></div>{{end}}</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{{/* Per-Series page: one address per Series, keyed "<site>:<series_id>" so the
|
||||||
|
list row is one hop from it. Everything here is a Series-level fact plus
|
||||||
|
the anonymous Reader count. The Check now control lands in its own .dform
|
||||||
|
below the (empty) .detail-grid; the pending marker rides the meta line
|
||||||
|
with the other marks. */}}
|
||||||
|
{{define "series-detail"}}
|
||||||
|
<a class="ghost detail-back" href="/admin/series">← Series</a>
|
||||||
|
<h1 class="detail-title">{{.Title}}</h1>
|
||||||
|
<p class="detail-key">{{.Key}} · {{.Site}} · {{.Kind}}</p>
|
||||||
|
{{if .Cover}}<div class="cover"><img src="{{.Cover}}" alt="" loading="lazy"></div>
|
||||||
|
{{else}}<div class="cover"></div>{{end}}
|
||||||
|
{{template "series-detail-meta" .}}
|
||||||
|
<div class="detail-grid"></div>
|
||||||
|
{{if .CanPoll}}
|
||||||
|
<div class="dform">
|
||||||
|
<div class="field"><a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="#detail-meta" hx-swap="outerHTML" href="#">Check now</a></div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* series-detail-meta is the meta line, and the answer a Check now press on
|
||||||
|
the detail page swaps into its place: the same marks, re-rendered after
|
||||||
|
the stamp so the pending marker shows. */}}
|
||||||
|
{{define "series-detail-meta"}}
|
||||||
|
<div class="detail-meta" id="detail-meta">
|
||||||
|
<span>ch {{.Chapter}}</span>
|
||||||
|
<span>checked {{.Checked}}</span>
|
||||||
|
<span>{{.Readers}} readers</span>
|
||||||
|
{{if .Pending}}<span class="mark">{{.Requested}}</span>{{end}}
|
||||||
|
{{if .Unpollable}}<span class="mark">unpollable</span>{{end}}
|
||||||
|
{{if .NoCover}}<span class="mark">no cover</span>{{end}}
|
||||||
|
{{if .Orphan}}<span class="mark">orphan</span>{{end}}
|
||||||
|
{{if .SightingRaised}}<span class="mark">sighting-raised</span>{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{{/* The Series list: every Series across every Reader's library, filtered by
|
||||||
|
one hygiene rule and narrowed by Site and Library. Filter, Site, Library
|
||||||
|
and page all live in the query string, so the list's state is an address
|
||||||
|
that can be bookmarked: the two selects submit the GET form, and the
|
||||||
|
Library segment links and the pager preserve the filter and Site. */}}
|
||||||
|
{{define "series-list"}}
|
||||||
|
<form class="filterbar" method="get" action="/admin/series">
|
||||||
|
<input type="hidden" name="kind" value="{{.Kind}}">
|
||||||
|
<label class="fsel"><span>Show</span><select name="filter" onchange="this.form.submit()">
|
||||||
|
{{range .Filters}}<option value="{{.Name}}"{{if .Selected}} selected{{end}}>{{.Label}} ({{.Count}})</option>{{end}}
|
||||||
|
</select></label>
|
||||||
|
<label class="fsel"><span>Site</span><select name="site" onchange="this.form.submit()">
|
||||||
|
<option value=""{{if not .Site}} selected{{end}}>All sites</option>
|
||||||
|
{{range .Sites}}<option value="{{.}}"{{if eq $.Site .}} selected{{end}}>{{.}}</option>{{end}}
|
||||||
|
</select></label>
|
||||||
|
<span class="segrow">
|
||||||
|
<a href="{{.KindBoth}}"{{if not .Kind}} class="active"{{end}}>both</a>
|
||||||
|
<a href="{{.KindManga}}"{{if eq .Kind "manga"}} class="active"{{end}}>manga</a>
|
||||||
|
<a href="{{.KindNovel}}"{{if eq .Kind "novel"}} class="active"{{end}}>novels</a>
|
||||||
|
</span>
|
||||||
|
</form>
|
||||||
|
<div class="listhead">{{.Total}} series <span class="lbl">· <em>{{.FilterLabel}}</em></span></div>
|
||||||
|
{{if .Rows}}
|
||||||
|
<div class="tbl series">
|
||||||
|
<div class="thead"><span>Site</span><span class="c-ch">Ch</span><span>Checked</span><span class="c-rd">Readers</span><span>Notes</span><span></span></div>
|
||||||
|
{{range .Rows}}{{template "series-row" .}}{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="pager">
|
||||||
|
{{if .PrevHref}}<a class="pg" href="{{.PrevHref}}">‹ prev</a>{{else}}<span class="pg disabled">‹ prev</span>{{end}}
|
||||||
|
<span>{{.Range}}</span>
|
||||||
|
{{if .NextHref}}<a class="pg" href="{{.NextHref}}">next ›</a>{{else}}<span class="pg disabled">next ›</span>{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty"><strong>No series</strong><p>Nothing matches <em>{{.FilterLabel}}</em>.</p></div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* series-row is one Series list row, and the answer a Check now press swaps
|
||||||
|
into the row's place (hx-target="closest .trow"): it must render the
|
||||||
|
pending marker the press created. The control is absent on a Series with
|
||||||
|
no page to fetch and on an orphan, so the owner is never offered a button
|
||||||
|
that can never do anything. */}}
|
||||||
|
{{define "series-row"}}
|
||||||
|
<div class="trow{{if .Attention}} attention{{end}}{{if .Band}} band{{end}}">
|
||||||
|
<span class="c-title"><a href="/admin/series/{{.Key}}">{{.Title}}</a>{{if .Pending}}<span class="mark">{{.Requested}}</span>{{end}}</span>
|
||||||
|
<span class="c-site site-{{.Site}}">{{.Site}}</span>
|
||||||
|
<span class="c-ch">{{.Ch}}</span>
|
||||||
|
<span>{{.Age}}</span>
|
||||||
|
<span class="c-rd">{{.Readers}}</span>
|
||||||
|
<span class="c-note">{{range .Notes}}<span class="mark">{{.}}</span>{{end}}{{if .More}}<span class="mark mark-faint">+{{.More}}</span>{{end}}</span>
|
||||||
|
<span class="c-act">{{if .CanPoll}}<a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="closest .trow" hx-swap="outerHTML" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' href="#">Check now</a>{{end}}</span>
|
||||||
|
</div>
|
||||||
|
{{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()
|
||||||
|
|||||||
+1530
-100
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# ADR-0013: Commands through the database
|
||||||
|
|
||||||
|
Date: 2026-08-22
|
||||||
|
Status: accepted
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Owner interventions are **facts about rows, never commands to the poller**.
|
||||||
|
*Check now* (`POST /admin/series/{key}/poll`) writes one stamp —
|
||||||
|
`series.force_poll_at`, unix ms, zero meaning never asked (the column landed
|
||||||
|
in migration 0014) — and the poller's next pass reads it through
|
||||||
|
`Store.DueForLatestCheck`. The control never signals the running process, so
|
||||||
|
a request survives a restart, and the whole surface is testable with no
|
||||||
|
poller running at all.
|
||||||
|
|
||||||
|
**Pending is derived, never stored**: a Series is pending while
|
||||||
|
`force_poll_at > latest_checked_at`. It self-clears with no second write and
|
||||||
|
no sweeper because the check stamp is written *before* the fetch (the same
|
||||||
|
"attempted" discipline as ADR-0010) — the first attempt ends the pending
|
||||||
|
state whatever the attempt returns. There is no expiry: a request the Lane
|
||||||
|
never reaches keeps ageing in the UI, and an old pending marker is itself the
|
||||||
|
evidence that a Lane is stuck. Writing again re-stamps the request time; the
|
||||||
|
write is idempotent.
|
||||||
|
|
||||||
|
**Queue-jump rules.** A forced Series overrides exactly three gates in the
|
||||||
|
due query: the rest cutoff, the Sighting-deferral clause and the finished-only
|
||||||
|
bucket, and it sorts to the front of the queue
|
||||||
|
(`ORDER BY forced DESC, reader_count DESC, latest_checked_at ASC`). It never
|
||||||
|
overrides an empty `series_url` (nothing to fetch), the Bookmarks join (a
|
||||||
|
Series no Reader holds has no consumer for the result), the Lane's refusal
|
||||||
|
backoff, the sidecar-down skip, or the Lane's gap — the last three are
|
||||||
|
poller-side gates the query cannot see and must not. The one pass-level gate
|
||||||
|
a forced Series does open is the browser wake threshold: a human asking wakes
|
||||||
|
a sleeping Chrome, where the thresholds exist to stop the machine waking
|
||||||
|
itself for one unattended check. If the home machine is off, nothing happens
|
||||||
|
and the request ages visibly, which is correct.
|
||||||
|
|
||||||
|
Rejected: zeroing the check stamp as the force signal. It would corrupt the
|
||||||
|
never-checked and stale counts the landing page exists to show, and make a
|
||||||
|
pending marker impossible.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
A stuck-looking Series previously waited for its turn in the Lane's hour, and
|
||||||
|
there was no way to ask for one check sooner. A direct poller command would
|
||||||
|
have been lost on every restart and untestable without a running poller; a
|
||||||
|
row the poller already reads is neither. Deriving pending from the two stamps
|
||||||
|
keeps the flag honest across restarts and makes the mechanism two column
|
||||||
|
writes and three query clauses instead of a state machine.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- The finished-status clause the force flag overrides is today's Lifecycle
|
||||||
|
test; a later spec in this series deletes it wholesale rather than amending
|
||||||
|
it, so the clause stays as it stands.
|
||||||
|
- The control is unconfirmed (it takes nothing away) and renders no
|
||||||
|
`.confirm-row`; it is hidden on a Series with no `series_url` and on an
|
||||||
|
orphan — the same pair the due query refuses to override.
|
||||||
|
- The answer to a press is the freshly rendered row, so the figures describe
|
||||||
|
the state after the press.
|
||||||
Reference in New Issue
Block a user