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
+398
View File
@@ -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()
}