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
+197
View File
@@ -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
}