889f0f3f38
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>
399 lines
13 KiB
Go
399 lines
13 KiB
Go
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()
|
||
}
|