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
+23 -15
View File
@@ -54,6 +54,11 @@ type Config struct {
// /u/{token}/novel-bookmark.user.js. Same bindmount, second script: the
// two libraries are separate installs.
NovelUserscriptPath string
// BrowserWSURL is the CDP websocket the poller's browser Sites read
// through. Set means a browser sidecar is configured in this deployment —
// the Lanes page reports the fact and derives reachability from the pass
// log rather than asking the poller (issue #145).
BrowserWSURL string
// LatestPoll configures the background latest-chapter fetcher.
LatestPoll LatestPoll
}
@@ -108,6 +113,7 @@ func loadConfig() Config {
OwnerDiscordID: os.Getenv("OWNER_DISCORD_ID"),
UserscriptPath: envOr("USERSCRIPT_PATH", "/userscript/manga-bookmark.user.js"),
NovelUserscriptPath: envOr("NOVEL_USERSCRIPT_PATH", "/userscript/novel-bookmark.user.js"),
BrowserWSURL: os.Getenv("BROWSER_WS_URL"),
LatestPoll: loadLatestPoll(),
}
c.Discord = web.DiscordConfig{
@@ -130,9 +136,10 @@ func loadConfig() Config {
// preflight OPTIONS short-circuits before auth; /bookmarks* is auth-protected,
// /healthz is public.
//
// lanes may be nil — polling disabled, or its client could not be built. The
// admin page reports that rather than pretending Lanes exist.
func newRouter(s *store.Store, cfg Config, lanes web.LaneReporter) http.Handler {
// The web layer learns the deployment's poller and browser config from cfg —
// nothing of the running poller is wired through here; the Lanes page reads
// the database (issue #145).
func newRouter(s *store.Store, cfg Config) http.Handler {
mux := http.NewServeMux()
h := &api.Handler{Store: s}
mux.HandleFunc("GET /healthz", api.Healthz)
@@ -161,9 +168,12 @@ func newRouter(s *store.Store, cfg Config, lanes web.LaneReporter) http.Handler
mux.Handle("/bookmarks/", auth)
// The browser UI is always registered; signing in is Discord OAuth, so
// there is no password to forget and no gate to leave unset.
// there is no password to forget and no gate to leave unset. The poller
// and browser facts are config, not the poller's: the Lanes page reads
// the pass log and reports the deployment as configured.
wh, err := web.New(s, cfg.Discord, []byte(cfg.TokenKey),
cfg.UserscriptPath, cfg.NovelUserscriptPath, lanes)
cfg.UserscriptPath, cfg.NovelUserscriptPath,
cfg.LatestPoll.Enabled, strings.TrimSpace(cfg.BrowserWSURL) != "")
if err != nil {
log.Fatalf("web handler: %v", err)
}
@@ -241,7 +251,7 @@ func main() {
var browser latest.Fetcher
pollCtx, stopPoll := context.WithCancel(context.Background())
defer stopPoll()
if ws := strings.TrimSpace(os.Getenv("BROWSER_WS_URL")); ws != "" {
if ws := strings.TrimSpace(cfg.BrowserWSURL); ws != "" {
bf, err := latest.NewBrowserFetcher(ws)
if err != nil {
log.Printf("browser fetcher disabled: %v", err)
@@ -278,17 +288,14 @@ func main() {
}
s.OnSeriesCreated = acq.Acquire
}
// A nil *Poller must not become a non-nil interface holding a nil pointer:
// the admin page tests the reporter for nil to decide whether anything is
// polling at all.
var lanes web.LaneReporter
if poller := startLatestPoller(pollCtx, s, cfg.LatestPoll, browser); poller != nil {
lanes = poller
}
// The poller's only connection to the web layer is the database now: it is
// started for its own sake, and the Lanes page reads the pass rows it
// records (issue #145).
startLatestPoller(pollCtx, s, cfg.LatestPoll, browser)
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: newRouter(s, cfg, lanes),
Handler: newRouter(s, cfg),
ReadHeaderTimeout: 10 * time.Second,
}
@@ -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
// feature going missing degrades the service to userscript-only latest-chapter
// 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 {
if !cfg.Enabled {
log.Println("latest-chapter poller: disabled by config")