Admin dashboard: pages, Lane observability, poll pass log, per-Series intervention (#134) #148
@@ -19,8 +19,7 @@ type LaneReporter interface {
|
||||
}
|
||||
|
||||
// ownerWindow is the staleness boundary the Series list's "not checked in
|
||||
// 12h" filter compares against, and the same twelve hours the landing page's
|
||||
// figures share. Declared once; later admin tickets read it.
|
||||
// 12h" filter compares against. Declared once; later admin tickets read it.
|
||||
const ownerWindow = 12 * time.Hour
|
||||
|
||||
// adminView is the shared shell data for an administrative page and the roster
|
||||
|
||||
@@ -160,7 +160,7 @@ func (h *Handler) seriesListView(r *http.Request) (seriesListView, error) {
|
||||
for i, a := range data.Rows {
|
||||
view.Rows = append(view.Rows, seriesRow(a, i, now))
|
||||
}
|
||||
view.Filters, err = h.seriesFilterOptions(filter)
|
||||
view.Filters, err = h.seriesFilterOptions(filter, sf.Cutoff)
|
||||
if err != nil {
|
||||
return seriesListView{}, err
|
||||
}
|
||||
@@ -181,11 +181,12 @@ func (h *Handler) seriesListView(r *http.Request) (seriesListView, error) {
|
||||
// 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.
|
||||
func (h *Handler) seriesFilterOptions(selected string) ([]seriesFilterOption, error) {
|
||||
// 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})
|
||||
shapes, err := h.store.SeriesShapes(store.SeriesFilter{Name: name, Cutoff: cutoff})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -203,9 +204,8 @@ func (h *Handler) seriesFilterOptions(selected string) ([]seriesFilterOption, er
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// seriesRow formats one store row for the template. The note chips are the
|
||||
// row's own hygiene facts, capped at two with a +N tail; attention marks a
|
||||
// row that carries any.
|
||||
// seriesRow shapes one store row for the template, capping its chips at two
|
||||
// plus a +N tail; attention marks a row that carries any.
|
||||
func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView {
|
||||
row := seriesRowView{
|
||||
Key: a.Key(),
|
||||
@@ -273,13 +273,10 @@ func checkedAge(now time.Time, ts int64) string {
|
||||
}
|
||||
}
|
||||
|
||||
// pagerRange is the pager's "1–50 of 120" line. Zero rows reads as "0 of 0":
|
||||
// the empty state carries the page then, and a window number for a list that
|
||||
// rendered nothing would be a number nothing points at.
|
||||
// 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 {
|
||||
if rows == 0 {
|
||||
return "0 of 0"
|
||||
}
|
||||
from := (page-1)*seriesPageSize + 1
|
||||
return fmt.Sprintf("%d–%d of %d", from, from+rows-1, total)
|
||||
}
|
||||
|
||||
+9
-3
@@ -1939,15 +1939,16 @@ func TestSeriesListFilterWiring(t *testing.T) {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:healthy", url: "https://asurascans.com/comics/healthy", cover: "aaa", checkedAt: 9000, latestNum: floatPtr(10), bookmarks: 1})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:nocover", url: "https://asurascans.com/comics/nocover", checkedAt: 9000, latestNum: floatPtr(3), bookmarks: 1})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:healthy", url: "https://asurascans.com/comics/healthy", cover: "aaa", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(10), bookmarks: 1})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:nocover", url: "https://asurascans.com/comics/nocover", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(3), bookmarks: 1})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:stale", url: "https://asurascans.com/comics/stale", cover: "bbb", checkedAt: time.Now().Add(-24 * time.Hour).UnixMilli(), latestNum: floatPtr(4), bookmarks: 1})
|
||||
srv := newRouter(st, testConfig(), nil)
|
||||
|
||||
body := adminSeriesPage(t, srv, st, "?filter=no_cover")
|
||||
if !strings.Contains(body, "Title of asura:nocover") {
|
||||
t.Errorf("no_cover list misses its row:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "Title of asura:healthy") {
|
||||
if strings.Contains(body, "Title of asura:healthy") || strings.Contains(body, "Title of asura:stale") {
|
||||
t.Errorf("no_cover list renders a covered row:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "1 series") || !strings.Contains(body, "No cover") {
|
||||
@@ -1959,6 +1960,11 @@ func TestSeriesListFilterWiring(t *testing.T) {
|
||||
if !strings.Contains(body, `<option value="no_cover" selected>`) {
|
||||
t.Errorf("the no_cover option is not selected:\n%s", body)
|
||||
}
|
||||
// The stale option's count is cutoff-dependent: the row read passes the
|
||||
// 12h boundary, and so must the aggregate that numbers the select.
|
||||
if !strings.Contains(body, "Not checked in 12h (1)") {
|
||||
t.Errorf("the stale option lacks its cutoff-dependent count:\n%s", body)
|
||||
}
|
||||
|
||||
// An unknown filter value is the absent All case: both rows, no error.
|
||||
body = adminSeriesPage(t, srv, st, "?filter=bogus")
|
||||
|
||||
Reference in New Issue
Block a user