Admin dashboard: pages, Lane observability, poll pass log, per-Series intervention (#134) (#148)

Spec #134, all ten tickets. Closes #134.

## What ships

The admin surface becomes four bookmarkable addresses behind one nav row, and Lane observability stops dying with the process.

- **#138** `/admin` splits into Overview, Lanes, Readers, Series, each a real route with the active tab underlined.
- **#139** `poll_passes` and `poll_lanes` land as durable tables with their store surface.
- **#140** cross-Series admin read model, with the privacy boundary in the projection: the Reader id that raised a Latest Chapter never leaves the store package.
- **#141** the poller records exactly one pass row per exit, with a skip reason and outcome counts.
- **#142** Series list: eight hygiene filters, Site and Library narrowing, paging — all of it in the query string, so a filtered list is a bookmark.
- **#143** Overview: a three-state verdict line and a stats block where every non-zero figure links to the list that counts it.
- **#144** per-Series detail page, keyed by the `site:series_id` composite the rest of the system already uses.
- **#145** the Lanes page reads the database; the in-memory Lane state, `web.LaneReporter` and `latest.Status` are deleted.
- **#146** Forced Poll: *Check now* stamps `series.force_poll_at` and never commands the poller.
- **#147** pause and resume one Site's Lane, with a mandatory 1h/6h/24h expiry.

## Shape of the design

Two decisions carry the rest. **Commands go through the database, never at the poller**: both *Check now* and a Lane pause write a row the next pass reads, so they survive a restart and the whole surface stays testable with no poller running. And **pending is derived, never stored** — the request stamp being newer than the check stamp — which self-clears on the check stamp with no second write and no sweeper, because the check stamp is written before the fetch.

ADRs: `docs/adr/0012-persisted-lane-state.md`, `docs/adr/0013-commands-through-the-database.md`.

## Verification

`go test ./...` green on the merged base (`264839e`), all packages, Docker-backed. `gofmt -l` and `go vet` clean.

Every ticket was reviewed on both axes (`cr-spec` + `cr-standards`) before merge.

## Known, non-blocking

- **#143** the verdict ignores never-reported Lanes when other Lanes have reported, and the per-Site table lists Sites that have Series rather than the whole registry. The ticket prose asks for eight hygiene figures per Site; the design mock and the landed `.tbl.sites` grid both say six columns, and the mock won.
- **#146** two `SeriesPage` scans per press instead of a keyed read — `ponytail:`-commented in-tree with the upgrade path.
- **#147** a paused Site with no pass row yet renders no row and so no control, since the Lanes page lists Sites that have passed.
- **#141** a sibling browser Lane declining at the top of a pass records as `sidecar-down`. Specified deliberately; the later spec in this series settles it.

Reviewed-on: #148
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #148.
This commit is contained in:
2026-08-22 08:27:19 +07:00
committed by sulthan
parent 0a245a0dde
commit 889f0f3f38
33 changed files with 6112 additions and 634 deletions
+316 -1
View File
@@ -758,7 +758,7 @@ func TestMigration0008DropsLegacyCoverRows(t *testing.T) {
func readSeries(t *testing.T, s *Store, site, seriesID string) Series {
t.Helper()
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)
if err != nil {
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)
}
}
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)
}
}