Admin dashboard: pages, Lane observability, poll pass log, per-Series intervention (#134) #148

Merged
sulthan merged 22 commits from spec-134 into main 2026-08-22 08:27:20 +07:00
5 changed files with 351 additions and 1 deletions
Showing only changes of commit f5b52e48c8 - Show all commits
+3
View File
@@ -32,6 +32,8 @@ type adminView struct {
OwnerID int64
Lanes lanesView
SeriesList seriesListView
// Detail is the per-Series page data; zero on every other page.
Detail seriesDetailView
}
// lanesView is the Lane status block: one row per Site that has run, plus the
@@ -94,6 +96,7 @@ func (h *Handler) adminRoutes() []adminRoute {
{"GET /admin/lanes", h.adminLanes},
{"GET /admin/readers", h.adminReaders},
{"GET /admin/series", h.adminSeries},
{"GET /admin/series/{key}", h.adminSeriesDetail},
{"GET /ui/admin/lanes", h.uiLanes},
{"POST /readers/{id}/revoke", h.revokeReaderSessions},
{"POST /readers/{id}/clear-marks", h.clearReaderMarks},
@@ -0,0 +1,98 @@
package web
import (
"log"
"net/http"
"strconv"
"strings"
"time"
"bookmarkmanager/backend/internal/store"
)
// seriesDetailView is one Series' page as the owner sees it: strings and
// flags, every judgement made here, the template left to print. ReaderCount
// is the only figure that crosses the privacy boundary — the owner learns how
// many Readers hold the Series, never which Reader reads what.
type seriesDetailView struct {
Key string // "<site>:<series_id>", the page's address and the Series' identity
Site string
Kind string
// Title, Cover and Chapter come from the shared Series row; the Cover is
// the wire URL of the stored bytes, "" before any exist.
Title string
Cover string
Chapter string // Latest Chapter number, or "—" before the first capture
Checked string // how long ago the poller last checked, or "never"
Readers int
// Marks, one per hygiene fact, rendered only while it holds.
Unpollable bool // no SeriesURL to fetch
NoCover bool
Orphan bool // no Reader holds the Series
SightingRaised bool // a Reader's Sighting set the Latest Chapter
}
// adminSeriesDetail renders one Series' page, keyed by the composite
// "<site>:<series_id>" the list row already shows. The row is read through
// the list's own SeriesPage read narrowed to the key's Site: the admin
// projection is the privacy boundary, and a dedicated single-row read would
// be a second definition of it.
// ponytail: a page scan per detail view, one query per page of the Site's
// rows up to the window total; a keyed read alongside SeriesPage when the
// library outgrows the page size.
func (h *Handler) adminSeriesDetail(w http.ResponseWriter, r *http.Request) {
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
if !ok || site == "" || seriesID == "" {
http.NotFound(w, r)
return
}
seen := 0
for page := 1; ; page++ {
p, err := h.store.SeriesPage(store.SeriesFilter{Site: site, Page: page})
if err != nil {
log.Printf("series detail %s: %v", site+":"+seriesID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
seen += len(p.Rows)
for i := range p.Rows {
if p.Rows[i].SeriesID == seriesID {
h.renderAdmin(w, adminView{Page: "series-detail", Detail: h.seriesDetailView(p.Rows[i])})
return
}
}
if seen >= p.Total {
break
}
}
http.NotFound(w, r)
}
// seriesDetailView shapes one AdminSeries row for display: every judgement in
// Go, the template left to print strings and flags.
func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
v := seriesDetailView{
Key: a.Key(),
Site: a.Site,
Kind: a.Kind,
Title: a.Title,
Cover: h.store.CoverWireURL(a.CoverAddress),
Readers: a.ReaderCount,
Unpollable: a.SeriesURL == "",
NoCover: a.CoverAddress == "",
Orphan: a.ReaderCount == 0,
SightingRaised: a.RaisedByReader,
}
if a.LatestChapterNum == nil {
v.Chapter = "—"
} else {
v.Chapter = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64)
}
if a.LatestCheckedAt == 0 {
v.Checked = "never"
} else {
v.Checked = since(time.Now(), time.UnixMilli(a.LatestCheckedAt))
}
return v
}
+3 -1
View File
@@ -30,7 +30,7 @@
<a href="/admin" class="{{if eq .Page "overview"}}active{{end}}" {{if eq .Page "overview"}}aria-current="page"{{end}}>Overview</a>
<a href="/admin/lanes" class="{{if eq .Page "lanes"}}active{{end}}" {{if eq .Page "lanes"}}aria-current="page"{{end}}>Lanes</a>
<a href="/admin/readers" class="{{if eq .Page "readers"}}active{{end}}" {{if eq .Page "readers"}}aria-current="page"{{end}}>Readers</a>
<a href="/admin/series" class="{{if eq .Page "series"}}active{{end}}" {{if eq .Page "series"}}aria-current="page"{{end}}>Series</a>
<a href="/admin/series" class="{{if or (eq .Page "series") (eq .Page "series-detail")}}active{{end}}" {{if or (eq .Page "series") (eq .Page "series-detail")}}aria-current="page"{{end}}>Series</a>
</nav>
<main class="page admin-page">
@@ -40,6 +40,8 @@
{{template "readers" .}}
{{else if eq .Page "series"}}
{{template "series-list" .SeriesList}}
{{else if eq .Page "series-detail"}}
{{template "series-detail" .Detail}}
{{else}}
<h2 class="sec">Overview</h2>
{{end}}
@@ -0,0 +1,21 @@
{{/* Per-Series page: one address per Series, keyed "<site>:<series_id>" so the
list row is one hop from it. Everything here is a Series-level fact plus
the anonymous Reader count; the intervention forms a later ticket adds
land inside the empty .detail-grid, so this page edits nothing. */}}
{{define "series-detail"}}
<a class="ghost detail-back" href="/admin/series">← Series</a>
<h1 class="detail-title">{{.Title}}</h1>
<p class="detail-key">{{.Key}} · {{.Site}} · {{.Kind}}</p>
{{if .Cover}}<div class="cover"><img src="{{.Cover}}" alt="" loading="lazy"></div>
{{else}}<div class="cover"></div>{{end}}
<div class="detail-meta">
<span>ch {{.Chapter}}</span>
<span>checked {{.Checked}}</span>
<span>{{.Readers}} readers</span>
{{if .Unpollable}}<span class="mark">unpollable</span>{{end}}
{{if .NoCover}}<span class="mark">no cover</span>{{end}}
{{if .Orphan}}<span class="mark">orphan</span>{{end}}
{{if .SightingRaised}}<span class="mark">sighting-raised</span>{{end}}
</div>
<div class="detail-grid"></div>
{{end}}
+226
View File
@@ -1,6 +1,7 @@
package main
import (
"crypto/sha256"
"database/sql"
"encoding/json"
"fmt"
@@ -2243,3 +2244,228 @@ func TestSeriesListRowShape(t *testing.T) {
t.Errorf("the page content carries an ember token:\n%s", body)
}
}
// The per-Series page renders every Series-level fact the admin read model
// holds for the key the list row already shows: title, the composite key with
// Site and kind, the Latest Chapter, the check age and the anonymous Reader
// count. No Reader identity or progress may appear anywhere in the response.
func TestAdminSeriesDetailRendersFacts(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
// Two Readers hold the same Series, so the anonymous count is 2.
seed(t, st, store.Bookmark{
Key: "kagane:sp-baby", Site: "kagane", SeriesID: "sp-baby",
Title: "SP Baby", SeriesURL: "https://kagane.to/series/sp-baby",
Kind: "manga", LatestChapter: "Chapter 45", LatestChapterNum: floatPtr(45),
})
other, err := st.EnsureReader("reader-two", sha256.Sum256([]byte("reader-two-hash")))
if err != nil {
t.Fatalf("EnsureReader: %v", err)
}
if _, err := st.Upsert(other, store.Bookmark{
Key: "kagane:sp-baby", Site: "kagane", SeriesID: "sp-baby",
Title: "SP Baby", SeriesURL: "https://kagane.to/series/sp-baby",
Kind: "manga", LatestChapter: "Chapter 45", LatestChapterNum: floatPtr(45),
}); err != nil {
t.Fatalf("Upsert second reader: %v", err)
}
if err := st.MarkLatestChecked("kagane", "sp-baby", time.Now().Add(-2*time.Hour).UnixMilli()); err != nil {
t.Fatalf("MarkLatestChecked: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/admin/series/kagane:sp-baby", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET /admin/series/kagane:sp-baby status = %d, want 200", rr.Code)
}
body := rr.Body.String()
for _, want := range []string{
"SP Baby",
"kagane:sp-baby · kagane · manga",
"ch 45",
"checked 2h0m",
"2 readers",
} {
if !strings.Contains(body, want) {
t.Errorf("series detail lacks %q:\n%s", want, body)
}
}
}
// A Series the poller has never read renders the never-read state — "ch —" and
// "checked never" — rather than a confident zero, and a Series no Reader holds
// renders its count as "0 readers" rather than as a blank.
func TestAdminSeriesDetailNeverReadOrphanState(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seed(t, st, store.Bookmark{
Key: "asura:abandoned", Site: "asura", SeriesID: "abandoned",
Title: "Abandoned", SeriesURL: "https://asurascans.com/series/abandoned",
Kind: "manga",
})
// Removing the only Bookmark orphans the Series: the row outlives it.
if err := st.Delete(st.OwnerID(), "asura:abandoned"); err != nil {
t.Fatalf("Delete: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/admin/series/asura:abandoned", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := rr.Body.String()
for _, want := range []string{"ch —", "checked never", "0 readers"} {
if !strings.Contains(body, want) {
t.Errorf("never-read orphan detail lacks %q:\n%s", want, body)
}
}
if strings.Contains(body, "ch 0") {
t.Errorf("a never-read Series renders chapter 0:\n%s", body)
}
}
// The meta row renders each hygiene mark exactly when the underlying fact
// holds: a Series with no page to fetch, no cover, no Reader and a chapter a
// Sighting raised carries all four, and a whole one carries none.
func TestAdminSeriesDetailRendersMarks(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seed(t, st, store.Bookmark{
Key: "demonic:broken", Site: "demonic", SeriesID: "broken",
Title: "Broken", Kind: "manga",
})
if err := st.Delete(st.OwnerID(), "demonic:broken"); err != nil {
t.Fatalf("Delete: %v", err)
}
if err := st.RecordSighting(st.OwnerID(), "demonic", "broken", floatPtr(7), time.Now().UnixMilli()); err != nil {
t.Fatalf("RecordSighting: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/admin/series/demonic:broken", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
for _, want := range []string{"unpollable", "no cover", "orphan", "sighting-raised"} {
if !strings.Contains(rr.Body.String(), want) {
t.Errorf("broken series detail lacks the %q mark:\n%s", want, rr.Body.String())
}
}
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/series/solo",
Kind: "manga", LatestChapter: "45", LatestChapterNum: floatPtr(45),
})
if err := st.SetSeriesCover("asura", "solo", "https://cdn.asurascans.com/covers/solo.webp",
[]byte("\x00webp-bytes"), "image/webp"); err != nil {
t.Fatalf("SetSeriesCover: %v", err)
}
req = httptest.NewRequest(http.MethodGet, "/admin/series/asura:solo", nil)
req.AddCookie(sessionCookie(t, st))
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := rr.Body.String()
for _, mark := range []string{"unpollable", "no cover", "orphan", "sighting-raised"} {
if strings.Contains(body, mark) {
t.Errorf("whole series detail carries the %q mark:\n%s", mark, body)
}
}
if !strings.Contains(body, `src="https://bookmarks.test/covers/`) {
t.Errorf("whole series detail does not render its stored cover:\n%s", body)
}
}
// A well-formed key naming no row is a 404, and so is a key with no ":",
// an empty Site or an empty SeriesID — the detail page never answers 500 for
// an address nobody can reach.
func TestAdminSeriesDetailUnknownKey404(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/series/solo",
Kind: "manga",
})
for _, path := range []string{
"/admin/series/asura:no-such-row",
"/admin/series/no-colon",
"/admin/series/:empty-site",
"/admin/series/asura:",
"/admin/series/unknown-site:row",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("GET %s status = %d, want 404", path, rr.Code)
}
}
}
// A Series past the first page of its Site's read (50 rows) must still
// render: the list shows it, so its detail link may not answer 404. The row
// lookup walks the window total, not just page 1.
func TestAdminSeriesDetailBeyondFirstPage(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
for i := 0; i < 55; i++ {
seed(t, st, store.Bookmark{
Key: fmt.Sprintf("asura:s%03d", i), Site: "asura", SeriesID: fmt.Sprintf("s%03d", i),
Title: "Bulk", SeriesURL: "https://asurascans.com/series/bulk",
Kind: "manga",
})
}
// Rows order by (latest_checked_at, site, series_id), all zero stamps, so
// "zzz" lands on page 2 behind the fifty-five "s*" rows.
seed(t, st, store.Bookmark{
Key: "asura:zzz", Site: "asura", SeriesID: "zzz",
Title: "Late", SeriesURL: "https://asurascans.com/series/zzz",
Kind: "manga",
})
req := httptest.NewRequest(http.MethodGet, "/admin/series/asura:zzz", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET /admin/series/asura:zzz status = %d, want 200 for a page-2 row", rr.Code)
}
if !strings.Contains(rr.Body.String(), "Late") {
t.Errorf("page-2 row did not render:\n%s", rr.Body.String())
}
}
// The title and the key line come from the database, so they must render
// escaped: a title that is markup stays markup in the response, never HTML.
func TestAdminSeriesDetailEscapesStoredStrings(t *testing.T) {
router, st := newWebTestServer(t, testConfig())
seed(t, st, store.Bookmark{
Key: "asura:evil", Site: "asura", SeriesID: "evil",
Title: `<script>alert("xss")</script>`, SeriesURL: "https://asurascans.com/series/evil",
Kind: "manga",
})
req := httptest.NewRequest(http.MethodGet, "/admin/series/asura:evil", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, "&lt;script&gt;") {
t.Errorf("title is not escaped:\n%s", body)
}
if strings.Contains(body, "<script>") {
t.Errorf("title rendered raw:\n%s", body)
}
}