diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index f365f31..6c3c5d3 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -211,6 +211,25 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
`POST /rotate-token` (atomic epoch bump + hash
rewrite; invalidates every installed copy, so the panel warns to reinstall
on all devices).
- Owner-only `POST /readers/{id}/revoke` (drops one Reader's session rows and
- re-renders the `readers` panel; 404 for any non-owner) is the only route that
- reaches across Readers.
+- **Owner-only admin page (`internal/web/admin.go`, issue #102):** `GET /admin`
+ carries the Reader roster (sessions, Sighting counters, `POST
+ /readers/{id}/revoke` and `POST /readers/{id}/clear-marks`) and Poll Lane
+ status (`GET /ui/admin/lanes`, self-refreshing every 30s). Every route that
+ reaches past the acting Reader is listed in `adminRoutes()` and wrapped in
+ `requireOwner` at registration — add a route there, not a check inside a
+ handler; `web.AdminPatterns()` is what the gate test walks. A non-owner gets
+ 404, never 403. Lane figures come from the running poller through the
+ `web.LaneReporter` seam (`latest.Poller.LaneStatus`), never from a table: a
+ nil reporter or a Lane that has not finished a pass renders "no data yet"
+ rather than zeroes. `main.newRouter` takes the reporter as an interface and
+ converts a nil `*Poller` to a nil interface — a typed nil would make the page
+ claim a poller exists.
+ The one owner comparison left outside `requireOwner` is in `index`
+ (`view.Owner = readerID == h.store.OwnerID()`): it gates a link, not an
+ endpoint, so it is a rendering decision a registration-time wrapper cannot
+ express — do not "unify" it into the gate.
+ A Lane pass that returns before computing its figures (refusal backoff,
+ sidecar down) carries the previous pass's due count and gap forward rather
+ than recording zeroes; a Lane that has never reached a pace renders no gap at
+ all. `Checked` next to `Due` is what separates a stopped Lane from a quiet
+ one, so neither figure may be dropped from the row.
diff --git a/backend/api_test.go b/backend/api_test.go
index 44880d4..d2458db 100644
--- a/backend/api_test.go
+++ b/backend/api_test.go
@@ -48,7 +48,7 @@ func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) }
func newTestServer(t *testing.T) http.Handler {
t.Helper()
- return newRouter(newTestStore(t), testConfig())
+ return newRouter(newTestStore(t), testConfig(), nil)
}
func newTestStore(t *testing.T) *store.Store {
@@ -599,7 +599,7 @@ func TestLoadConfigDiscord(t *testing.T) {
// cooldown and the poller would re-fetch that series on every single tick.
func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
s := newTestStore(t)
- srv := newRouter(s, testConfig())
+ srv := newRouter(s, testConfig(), nil)
seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", 777)
@@ -637,7 +637,7 @@ func TestUserscriptServedWithWebUIDisabled(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/u/"+ownerCredential()+"/manga-bookmark.user.js", nil)
- newRouter(s, cfg).ServeHTTP(rr, req)
+ newRouter(s, cfg, nil).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
@@ -659,7 +659,7 @@ func TestNovelUserscriptServed(t *testing.T) {
cfg := testConfig()
cfg.NovelUserscriptPath = novelPath
- srv := newRouter(s, cfg)
+ srv := newRouter(s, cfg, nil)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
diff --git a/backend/cover_test.go b/backend/cover_test.go
index 9c3c93f..77ea269 100644
--- a/backend/cover_test.go
+++ b/backend/cover_test.go
@@ -98,7 +98,7 @@ func TestPublicCoverNeverEchoesNonImage(t *testing.T) {
if _, err := db.Exec(`UPDATE covers SET content_type = 'text/html' WHERE address = $1`, address); err != nil {
t.Fatalf("poison row: %v", err)
}
- rr := getCover(t, newRouter(st, testConfig()), "/covers/"+address, nil)
+ rr := getCover(t, newRouter(st, testConfig(), nil), "/covers/"+address, nil)
if rr.Code == http.StatusOK {
t.Fatalf("status = 200, want a refusal for a non-image row (body %q)", rr.Body.String())
}
diff --git a/backend/internal/latest/poller.go b/backend/internal/latest/poller.go
index 433b6f8..90028e0 100644
--- a/backend/internal/latest/poller.go
+++ b/backend/internal/latest/poller.go
@@ -57,6 +57,10 @@ type Poller struct {
mu sync.Mutex
refuseUntil map[string]time.Time
browserDownAt time.Time
+ // laneStates is the owner's page snapshot of each Lane's last pass
+ // (issue #102), keyed by Site. Guarded by mu; a Site appears only after
+ // its first pass, so a restart renders "no data yet" rather than zeroes.
+ laneStates map[string]LaneState
// coverWG tracks in-flight cover work. Covers heal in the background so a
// slow cover host cannot delay the next Series-page Poll; tests join it
// before asserting on cover fetches.
@@ -218,6 +222,10 @@ func (p *Poller) runOnce(ctx context.Context) {
// production Lane's rate limit; the deterministic test entry runs back to back.
func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.Duration {
now := p.Now()
+ // Snapshot this pass for the owner's page (issue #102). Recorded on every
+ // return path, with the figures filled in where the pass computes them.
+ st := LaneState{Site: name, LastRun: now, Browser: isBrowserSite(name)}
+ defer func() { p.recordLaneState(st) }()
if until := p.refusalBackoff(name); now.Before(until) {
// Cooling down after a refusal: do not attempt this Site at all.
return until.Sub(now)
@@ -238,18 +246,23 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
// No fetcher at all right now (browser absent, no fallback): every
// Series stays unstamped and due, so a browser that appears after a
// restart finds its full queue waiting (issue #100).
+ st.Gap = defaultGap
return defaultGap
}
due, err := p.Store.DueForLatestCheck(name, now.Add(-s.Rest).UnixMilli())
if err != nil {
log.Printf("latest poll %s: due query: %v", name, err)
+ st.Gap = defaultGap
return defaultGap
}
+ st.Due = len(due)
if s.Browser != nil && f == p.BrowserFetch && !browserWakeDue(due, now, s.Rest) {
// Below both thresholds Chrome stays asleep (ADR-0005 on-demand
// browser): waking it for a single Poll would cost a challenge solve
- // per request.
+ // per request. The Lane still paces at the default gap, which is what
+ // the owner's page must show rather than a zero.
+ st.Gap = defaultGap
return defaultGap
}
if s.Browser != nil {
@@ -265,9 +278,11 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
eligible, err := p.Store.EligibleSeriesCount(name)
if err != nil {
log.Printf("latest poll %s: eligible count: %v", name, err)
+ st.Gap = defaultGap
return defaultGap
}
gap, clamped := effectiveGap(s, eligible)
+ st.Gap, st.Clamped = gap, clamped
if clamped {
log.Printf("latest poll %s: gap clamped to %s floor (eligible series=%d)", name, minGap, eligible)
}
@@ -278,7 +293,6 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
}
refusals := 0
- checked := 0
for i, sr := range due {
if ctx.Err() != nil {
break
@@ -313,10 +327,10 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
} else {
refusals = 0
}
- checked++
+ st.Checked++
}
- if checked > 0 {
- log.Printf("latest poll %s: due=%d checked=%d", name, len(due), checked)
+ if st.Checked > 0 {
+ log.Printf("latest poll %s: due=%d checked=%d", name, len(due), st.Checked)
}
if refusals >= 2 {
p.setRefusalBackoff(name, now.Add(refuseBackoff))
diff --git a/backend/internal/latest/poller_test.go b/backend/internal/latest/poller_test.go
index 4f9d8ce..aaedcc7 100644
--- a/backend/internal/latest/poller_test.go
+++ b/backend/internal/latest/poller_test.go
@@ -1580,3 +1580,91 @@ func TestRunOnceClampWarningNamesTheSite(t *testing.T) {
t.Fatalf("clamp warning = %q, want it to name asura and 3601", got)
}
}
+
+// The owner's admin page (issue #102) reads Lane state out of the poller.
+// Before any pass the snapshot is empty — a restart must render "no data
+// yet", not zeroes — and each pass records what it saw: the frozen clock,
+// the due count and the pace, with refusal backoff derived at snapshot time.
+func TestLaneStatus(t *testing.T) {
+ s, _ := newTestStore(t)
+ now := time.UnixMilli(5_000_000)
+ p := newTestPoller(t, s, &fakeFetcher{body: asuraSeriesFixture, status: 200}, now)
+
+ if st := p.LaneStatus(); len(st.Lanes) != 0 {
+ t.Fatalf("lanes before any pass = %d, want 0 (nothing has run)", len(st.Lanes))
+ } else if st.BrowserConfigured || st.BrowserReachable {
+ t.Fatalf("browser before any pass = configured=%v reachable=%v, want false without a browser fetcher", st.BrowserConfigured, st.BrowserReachable)
+ }
+
+ seedForCheck(t, s, "asura:chronicles", "https://asurascans.com/series/chronicles", 0)
+ // Two refusals put kagane's Lane into backoff; asura sits on its own Lane.
+ browser := &fakeFetcher{status: 403}
+ p.BrowserFetch = browser
+ for i := range 2 {
+ key := fmt.Sprintf("kagane:s%d", i)
+ seedForCheck(t, s, key, "https://kagane.to/series/"+key[7:], 0)
+ }
+ p.runOnce(context.Background())
+
+ st := p.LaneStatus()
+ var asura, kagane LaneState
+ for _, lane := range st.Lanes {
+ switch lane.Site {
+ case "asura":
+ asura = lane
+ case "kagane":
+ kagane = lane
+ }
+ }
+ if st.Lanes[0].Site != "asura" {
+ t.Fatalf("first lane = %q, want asura (snapshot sorted by Site)", st.Lanes[0].Site)
+ }
+ if asura.Site == "" {
+ t.Fatalf("asura missing from snapshot: %+v", st.Lanes)
+ }
+ if !asura.LastRun.Equal(now) {
+ t.Fatalf("asura LastRun = %s, want the frozen clock %s", asura.LastRun, now)
+ }
+ if asura.Due != 1 {
+ t.Fatalf("asura Due = %d, want 1", asura.Due)
+ }
+ if asura.Gap == 0 {
+ t.Fatal("asura Gap = 0, want the Lane's pace")
+ }
+ if asura.Browser || asura.Refusing {
+ t.Fatalf("asura = %+v, want a TLS Lane that is not refusing", asura)
+ }
+ if !kagane.Browser || !kagane.Refusing {
+ t.Fatalf("kagane = %+v, want a browser Lane in refusal backoff", kagane)
+ }
+ if !st.BrowserConfigured || !st.BrowserReachable {
+ t.Fatalf("browser after round = configured=%v reachable=%v, want true/true (sidecar never lost)", st.BrowserConfigured, st.BrowserReachable)
+ }
+ if asura.Checked != 1 {
+ t.Fatalf("asura Checked = %d, want the one Series it read", asura.Checked)
+ }
+
+ // A pass that declines to look (kagane is now in backoff) must not restate
+ // the figures it never gathered as zeroes: the last real pass's due count
+ // and pace stand until a pass replaces them.
+ before := kagane
+ if before.Due == 0 || before.Gap == 0 {
+ t.Fatalf("kagane after its refusing pass = %+v, want the figures that pass gathered", before)
+ }
+ p.runOnce(context.Background())
+ for _, lane := range p.LaneStatus().Lanes {
+ if lane.Site != "kagane" {
+ continue
+ }
+ if lane.Due != before.Due || lane.Gap != before.Gap {
+ t.Fatalf("kagane after a skipped pass = due %d gap %s, want the previous pass's %d / %s",
+ lane.Due, lane.Gap, before.Due, before.Gap)
+ }
+ }
+
+ // A lost sidecar reads as unreachable for the same window the Lanes skip.
+ p.setBrowserDown(now)
+ if st := p.LaneStatus(); !st.BrowserConfigured || st.BrowserReachable {
+ t.Fatalf("browser after loss = configured=%v reachable=%v, want true/false", st.BrowserConfigured, st.BrowserReachable)
+ }
+}
diff --git a/backend/internal/latest/status.go b/backend/internal/latest/status.go
new file mode 100644
index 0000000..0c0ba08
--- /dev/null
+++ b/backend/internal/latest/status.go
@@ -0,0 +1,74 @@
+package latest
+
+import "time"
+
+// LaneState is the administrative page's view of one Poll Lane (issue #102):
+// what the Lane's last pass saw. Due, Gap and Checked are filled in as the
+// pass computes them; a pass that returned before reaching a figure (refusal
+// backoff, sidecar down) carries the previous pass's figures forward rather
+// than overwriting them with zeroes the page would state as fact.
+type LaneState struct {
+ Site string
+ Due int
+ LastRun time.Time
+ Gap time.Duration
+ // Checked is how many Series this pass actually read. A Lane with Series
+ // due and nothing checked has stopped working; one with nothing due is
+ // merely quiet, and the page must not draw the two the same (story 13).
+ Checked int
+ Clamped bool
+ Refusing bool
+ Browser bool
+}
+
+// Status is the owner's page snapshot of the whole poller (issue #102).
+type Status struct {
+ Lanes []LaneState
+ BrowserConfigured bool
+ BrowserReachable bool
+}
+
+// LaneStatus returns a copy of the poller's Lane state for the owner's page.
+// Only Sites that have completed a pass appear — a restart therefore renders
+// "no data yet" instead of confident zeroes — in the same order Run iterates.
+// Refusing is derived at snapshot time from the refusal backoff, not stored,
+// so a Lane that cooled down between passes reports false without a new pass.
+// BrowserReachable mirrors the Lanes' own gate: the sidecar is down only
+// within the refuseBackoff window since its last loss.
+func (p *Poller) LaneStatus() Status {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ lanes := make([]LaneState, 0, len(p.laneStates))
+ now := p.Now()
+ for _, name := range laneNames() {
+ st, ok := p.laneStates[name]
+ if !ok {
+ continue
+ }
+ st.Refusing = now.Before(p.refuseUntil[name])
+ lanes = append(lanes, st)
+ }
+ configured := p.BrowserFetch != nil
+ reachable := configured
+ if reachable && !p.browserDownAt.IsZero() && now.Sub(p.browserDownAt) < refuseBackoff {
+ reachable = false
+ }
+ return Status{Lanes: lanes, BrowserConfigured: configured, BrowserReachable: reachable}
+}
+
+// recordLaneState stores one Lane's last pass for LaneStatus. Called deferred
+// from runLanePass so every return path records, even a pass that refused.
+// A pass that never reached the pace (Gap zero) keeps the last pass's figures:
+// the Lane's due count and gap did not become zero because this pass declined
+// to look, and the row's own marks say why it declined.
+func (p *Poller) recordLaneState(st LaneState) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.laneStates == nil {
+ p.laneStates = make(map[string]LaneState)
+ }
+ if prev, ok := p.laneStates[st.Site]; ok && st.Gap == 0 {
+ st.Due, st.Gap, st.Clamped, st.Checked = prev.Due, prev.Gap, prev.Clamped, prev.Checked
+ }
+ p.laneStates[st.Site] = st
+}
diff --git a/backend/internal/store/migrations/0010_reader_sightings.sql b/backend/internal/store/migrations/0010_reader_sightings.sql
new file mode 100644
index 0000000..13fb4e2
--- /dev/null
+++ b/backend/internal/store/migrations/0010_reader_sightings.sql
@@ -0,0 +1,6 @@
+-- The owner's administrative page (issue #102) renders these counters and
+-- offers a control to clear them, deliberately shipped before the Sighting
+-- feature (issue #103) that fills them, so a false mark never needs SQL
+-- against production. Zero counters mean a trusted Reader.
+ALTER TABLE readers ADD COLUMN sighting_agreements integer NOT NULL DEFAULT 0;
+ALTER TABLE readers ADD COLUMN sighting_disagreements integer NOT NULL DEFAULT 0;
diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go
index f9f8d5d..7df73aa 100644
--- a/backend/internal/store/store.go
+++ b/backend/internal/store/store.go
@@ -315,25 +315,42 @@ func (s *Store) EnsureReader(discordID string, epochZeroHash [32]byte) (int64, e
return id, nil
}
+// SightingDisagreementLimit is the number of contradictions that stop that
+// Reader's Sightings from deferring a Poll (issue #103). The counters exist
+// before the mechanism that moves them, so the admin page (issue #102) can
+// clear a false mark without waiting for the Sighting feature.
+const SightingDisagreementLimit = 3
+
+// Blocked reports whether this Reader's Sighting marks have reached the
+// disagreement limit, which stops their Sightings from deferring a Poll.
+func (r ReaderSummary) Blocked() bool {
+ return r.Disagreements >= SightingDisagreementLimit
+}
+
// ReaderSummary is one Reader as the owner's administration panel sees them:
-// who they are and how many live sessions they hold. No credential material,
-// hashed or otherwise, is exposed.
+// who they are, how many live sessions they hold, and their Sighting marks.
+// No credential material, hashed or otherwise, is exposed.
type ReaderSummary struct {
ID int64
DiscordID string
// Sessions counts unexpired session rows — what the owner revokes.
Sessions int
+ // Agreements and Disagreements are the Sighting counters (issue #102);
+ // zero means a trusted Reader.
+ Agreements int
+ Disagreements int
}
-// Readers lists every Reader with their live session count, oldest first, so
-// the owner row (always the oldest) heads the list.
+// Readers lists every Reader with their live session count and Sighting
+// marks, oldest first, so the owner row (always the oldest) heads the list.
func (s *Store) Readers() ([]ReaderSummary, error) {
rows, err := s.db.Query(`
SELECT r.id, r.discord_id,
+ r.sighting_agreements, r.sighting_disagreements,
count(sess.id) FILTER (WHERE sess.expires_at > now()) AS sessions
FROM readers r
LEFT JOIN sessions sess ON sess.reader_id = r.id
- GROUP BY r.id, r.discord_id
+ GROUP BY r.id, r.discord_id, r.sighting_agreements, r.sighting_disagreements
ORDER BY r.id`)
if err != nil {
return nil, fmt.Errorf("query readers: %w", err)
@@ -343,7 +360,7 @@ func (s *Store) Readers() ([]ReaderSummary, error) {
out := []ReaderSummary{}
for rows.Next() {
var r ReaderSummary
- if err := rows.Scan(&r.ID, &r.DiscordID, &r.Sessions); err != nil {
+ if err := rows.Scan(&r.ID, &r.DiscordID, &r.Agreements, &r.Disagreements, &r.Sessions); err != nil {
return nil, fmt.Errorf("scan reader: %w", err)
}
out = append(out, r)
@@ -351,6 +368,18 @@ func (s *Store) Readers() ([]ReaderSummary, error) {
return out, rows.Err()
}
+// ClearReaderMarks zeroes a Reader's Sighting counters. It is the owner's
+// remedy for a mark produced by a broken Site adapter rather than a dishonest
+// Reader: it restores a privilege, it is not destruction.
+func (s *Store) ClearReaderMarks(readerID int64) error {
+ if _, err := s.db.Exec(`
+ UPDATE readers SET sighting_agreements = 0, sighting_disagreements = 0
+ WHERE id = $1`, readerID); err != nil {
+ return fmt.Errorf("clear reader marks for reader %d: %w", readerID, err)
+ }
+ return nil
+}
+
// readersMigration is the version that creates the readers table. The owner
// seed runs between two migrate passes, so that the run-once migration which
// attaches existing bookmarks (0004) finds the owner row.
diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go
index 93d93ab..0ae24b2 100644
--- a/backend/internal/store/store_test.go
+++ b/backend/internal/store/store_test.go
@@ -1334,6 +1334,86 @@ func TestReadersAndSessionRevocation(t *testing.T) {
}
}
+// The Sighting counters ship before the mechanism that fills them (issue
+// #102 before #103), so the admin page depends on their default: a fresh
+// Reader reads back trusted. Marking one directly proves Readers() reports
+// the counters and Blocked() flips at the limit, and that ClearReaderMarks —
+// the owner's remedy for a false mark — zeroes them again.
+func TestReaderSightingMarks(t *testing.T) {
+ s := newTestStore(t)
+ other := secondReader(t, s)
+
+ readers, err := s.Readers()
+ if err != nil {
+ t.Fatalf("Readers: %v", err)
+ }
+ if len(readers) != 2 {
+ t.Fatalf("readers = %d, want the owner and the second Reader", len(readers))
+ }
+ for _, r := range readers {
+ if r.Agreements != 0 || r.Disagreements != 0 || r.Blocked() {
+ t.Fatalf("fresh reader %d has marks: %+v", r.ID, r)
+ }
+ }
+
+ // The counters' default is the trusted state; writing them directly is
+ // the only way to exercise the read path until issue #103 moves them.
+ if _, err := s.db.Exec(`
+ UPDATE readers SET sighting_agreements = 5, sighting_disagreements = 2
+ WHERE id = $1`, other); err != nil {
+ t.Fatalf("mark reader: %v", err)
+ }
+ readers, err = s.Readers()
+ if err != nil {
+ t.Fatalf("Readers: %v", err)
+ }
+ var marked *ReaderSummary
+ for i := range readers {
+ if readers[i].ID == other {
+ marked = &readers[i]
+ }
+ }
+ if marked == nil || marked.Agreements != 5 || marked.Disagreements != 2 {
+ t.Fatalf("marked reader = %+v, want agreements 5, disagreements 2", marked)
+ }
+ if marked.Blocked() {
+ t.Fatalf("reader with 2 disagreements is blocked; limit is %d", SightingDisagreementLimit)
+ }
+
+ if _, err := s.db.Exec(`
+ UPDATE readers SET sighting_disagreements = 3 WHERE id = $1`, other); err != nil {
+ t.Fatalf("block reader: %v", err)
+ }
+ readers, err = s.Readers()
+ if err != nil {
+ t.Fatalf("Readers: %v", err)
+ }
+ for _, r := range readers {
+ if r.ID == other && !r.Blocked() {
+ t.Fatalf("reader at the disagreement limit is not blocked: %+v", r)
+ }
+ if r.ID == s.OwnerID() && r.Blocked() {
+ t.Fatalf("untouched owner became blocked: %+v", r)
+ }
+ }
+
+ if err := s.ClearReaderMarks(other); err != nil {
+ t.Fatalf("ClearReaderMarks: %v", err)
+ }
+ if err := s.ClearReaderMarks(other + 9999); err != nil {
+ t.Fatalf("ClearReaderMarks(unknown id): %v", err)
+ }
+ readers, err = s.Readers()
+ if err != nil {
+ t.Fatalf("Readers: %v", err)
+ }
+ for _, r := range readers {
+ if r.Agreements != 0 || r.Disagreements != 0 || r.Blocked() {
+ t.Fatalf("reader %d not cleared: %+v", r.ID, r)
+ }
+ }
+}
+
// Two Readers on one Series: one series row, two independent progresses. The
// second Reader starts at zero however far the first has read, and the shared
// row is still due exactly once.
diff --git a/backend/internal/web/admin.go b/backend/internal/web/admin.go
new file mode 100644
index 0000000..efb925b
--- /dev/null
+++ b/backend/internal/web/admin.go
@@ -0,0 +1,251 @@
+package web
+
+import (
+ "log"
+ "net/http"
+ "strconv"
+ "time"
+
+ "bookmarkmanager/backend/internal/latest"
+ "bookmarkmanager/backend/internal/store"
+)
+
+// LaneReporter is the administrative page's whole window onto the running
+// poller: one snapshot of Poll Lane state, copied out of memory on request.
+// The Poller satisfies it in production and a fake with fixed values satisfies
+// it in tests, so the page's tests need neither a poller nor a Site.
+type LaneReporter interface {
+ LaneStatus() latest.Status
+}
+
+// adminView is what the administrative page and the roster fragment receive.
+type adminView struct {
+ Readers []store.ReaderSummary
+ // OwnerID travels with the roster so it can tell the owner's own row from
+ // the Readers they may act on.
+ OwnerID int64
+ Lanes lanesView
+}
+
+// lanesView is the Lane status block: one row per Site that has run, plus the
+// browser fact, which is shared by the three browser Sites rather than held
+// once per Site.
+type lanesView struct {
+ Rows []laneRow
+ // PollerOff means no poller is running at all (disabled by config, or its
+ // client could not be built). The browser line must not answer "not
+ // configured" then: the sidecar is not the reason nothing is polled.
+ PollerOff bool
+ BrowserConfigured bool
+ BrowserReachable bool
+}
+
+// laneRow is one Lane formatted for reading rather than for arithmetic: the
+// template renders strings and flags, and every judgement about what they mean
+// is made here.
+type laneRow struct {
+ Site string
+ Due int
+ Ran string
+ // Checked is how many Series the last pass read. Due without Checked is a
+ // Lane that has stopped working; the two figures side by side are what
+ // separate that from a Lane with nothing to do.
+ Checked int
+ // Gap is empty when no pass has reached the pace yet, so the row omits the
+ // figure instead of stating a zero.
+ Gap string
+ Clamped bool
+ Refusing bool
+ // BrowserLost marks a Lane whose pages can only be read through the
+ // sidecar while the sidecar is unreachable — including the case where none
+ // is configured, which stops those Series just as completely.
+ BrowserLost bool
+ // Stalled marks a Lane with Series waiting that its last pass did not read
+ // — the difference between a stopped Lane and a quiet one (story 13).
+ Stalled bool
+ // Attention is the one flag the template colours on, so an unhealthy Lane
+ // is found at a glance rather than read for.
+ Attention bool
+}
+
+// adminRoute pairs a route pattern with its handler so the route list and the
+// gate cannot drift apart.
+type adminRoute struct {
+ pattern string
+ handler http.HandlerFunc
+}
+
+// adminRoutes is every route that reaches past the acting Reader. Register
+// wraps each one in requireOwner, so a new administrative route is gated by
+// being listed here rather than by remembering to write a check inside it.
+func (h *Handler) adminRoutes() []adminRoute {
+ return []adminRoute{
+ {"GET /admin", h.admin},
+ {"GET /ui/admin/lanes", h.uiLanes},
+ {"POST /readers/{id}/revoke", h.revokeReaderSessions},
+ {"POST /readers/{id}/clear-marks", h.clearReaderMarks},
+ }
+}
+
+// AdminPatterns names every administrative route, so one test can prove the
+// owner gate covers all of them rather than one test per route. The receiver is
+// nil because only the patterns are read; the bound handlers are never called.
+func AdminPatterns() []string {
+ routes := (*Handler)(nil).adminRoutes()
+ out := make([]string, 0, len(routes))
+ for _, rt := range routes {
+ out = append(out, rt.pattern)
+ }
+ return out
+}
+
+// requireOwner is the owner test, in one place, layered on the session gate: no
+// session is still 401, and a signed-in Reader who is not the owner gets 404
+// rather than 403 — a refusal that confirms the address exists is a refusal
+// that helps whoever is probing for it.
+func (h *Handler) requireOwner(next http.HandlerFunc) http.HandlerFunc {
+ return h.requireSession(func(w http.ResponseWriter, r *http.Request) {
+ if readerOf(r) != h.store.OwnerID() {
+ http.NotFound(w, r)
+ return
+ }
+ next(w, r)
+ })
+}
+
+// admin renders the owner's page: the Reader roster and Poll Lane status.
+func (h *Handler) admin(w http.ResponseWriter, r *http.Request) {
+ readers, err := h.store.Readers()
+ if err != nil {
+ log.Printf("admin: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ h.render(w, http.StatusOK, "admin", adminView{
+ Readers: readers,
+ OwnerID: h.store.OwnerID(),
+ Lanes: h.lanesView(),
+ })
+}
+
+// uiLanes answers the status block's own refresh. Only the block refreshes on a
+// timer; the roster re-renders after an action, as it always has.
+func (h *Handler) uiLanes(w http.ResponseWriter, r *http.Request) {
+ h.render(w, http.StatusOK, "lanes", h.lanesView())
+}
+
+// lanesView copies the poller's snapshot into display form. A nil reporter (no
+// poller running) and a poller no Lane has reported to yet are the same thing
+// to the page: no data, which it must say rather than draw as confident zeroes
+// — an empty page a few seconds after a restart must not read as a stopped one.
+func (h *Handler) lanesView() lanesView {
+ if h.lanes == nil {
+ return lanesView{PollerOff: true}
+ }
+ snap := h.lanes.LaneStatus()
+ v := lanesView{
+ Rows: make([]laneRow, 0, len(snap.Lanes)),
+ BrowserConfigured: snap.BrowserConfigured,
+ BrowserReachable: snap.BrowserReachable,
+ }
+ now := time.Now()
+ for _, l := range snap.Lanes {
+ lost := l.Browser && !snap.BrowserReachable
+ // Series waiting and none read is the shape of a Lane that has stopped
+ // working, as distinct from one that is quiet for want of work.
+ stalled := l.Due > 0 && l.Checked == 0
+ gap := ""
+ if l.Gap > 0 {
+ gap = l.Gap.Truncate(time.Second).String()
+ }
+ v.Rows = append(v.Rows, laneRow{
+ Site: l.Site,
+ Due: l.Due,
+ Ran: since(now, l.LastRun),
+ Checked: l.Checked,
+ Gap: gap,
+ Clamped: l.Clamped,
+ Refusing: l.Refusing,
+ BrowserLost: lost,
+ Stalled: stalled,
+ Attention: l.Clamped || l.Refusing || lost || stalled,
+ })
+ }
+ return v
+}
+
+// since formats how long ago a Lane last ran, at second resolution: the block
+// refreshes every thirty seconds, so anything finer is noise the owner would
+// have to ignore.
+func since(now, then time.Time) string {
+ d := now.Sub(then).Truncate(time.Second)
+ if d < time.Second {
+ return "just now"
+ }
+ return d.String() + " ago"
+}
+
+// revokeReaderSessions logs one Reader out of every browser they are signed in
+// on. The owner gate is the route's, not this handler's.
+func (h *Handler) revokeReaderSessions(w http.ResponseWriter, r *http.Request) {
+ target, ok := readerPathID(w, r)
+ if !ok {
+ return
+ }
+ // The owner is not one of the Readers this endpoint reaches: revoking
+ // themselves would sign out the browser making the request, which is what
+ // logout is for. The roster hides the button; this refuses the hand-rolled
+ // POST behind it.
+ if target == h.store.OwnerID() {
+ http.NotFound(w, r)
+ return
+ }
+ if err := h.store.DeleteReaderSessions(target); err != nil {
+ log.Printf("revoke sessions: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ h.renderRoster(w, "revoke sessions")
+}
+
+// clearReaderMarks zeroes one Reader's Sighting counters. The guard those
+// counters feed has one known false positive — a Site changing its page shape
+// makes a correct adapter read a wrong high number and marks every honest
+// Reader of that Site at once (issue #103) — and this is its remedy. It
+// restores a privilege rather than destroying anything, so the control is
+// confirmed but never wears the destruction accent.
+func (h *Handler) clearReaderMarks(w http.ResponseWriter, r *http.Request) {
+ target, ok := readerPathID(w, r)
+ if !ok {
+ return
+ }
+ if err := h.store.ClearReaderMarks(target); err != nil {
+ log.Printf("clear marks: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ h.renderRoster(w, "clear marks")
+}
+
+// readerPathID reads the Reader a route names, answering the request itself
+// when there is nobody to act on.
+func readerPathID(w http.ResponseWriter, r *http.Request) (int64, bool) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ http.Error(w, "bad reader id", http.StatusBadRequest)
+ return 0, false
+ }
+ return id, true
+}
+
+// renderRoster answers an action with the whole roster, so the counts and marks
+// it shows cannot describe the state before the tap.
+func (h *Handler) renderRoster(w http.ResponseWriter, what string) {
+ readers, err := h.store.Readers()
+ if err != nil {
+ log.Printf("%s: %v", what, err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ h.render(w, http.StatusOK, "readers", adminView{Readers: readers, OwnerID: h.store.OwnerID()})
+}
diff --git a/backend/internal/web/static/style.css b/backend/internal/web/static/style.css
index fbf07ec..4a1ba46 100644
--- a/backend/internal/web/static/style.css
+++ b/backend/internal/web/static/style.css
@@ -87,6 +87,11 @@
--moss: #7fae86; /* finished */
--clay: #b5906f; /* set chapter */
--trash: #977671; /* remove, resting — icons need 3:1, not 4.5:1 */
+ /* A Lane needing attention: the admin page's only accent. Verdigris — cool,
+ the far side of the wheel from ember's crimson, and clear of the archive
+ blue. Neither ember (new chapter) nor danger (destruction) may say
+ "system unhealthy". */
+ --patina: #5fb3a6;
/* Desktop cell borders for the two coloured action states. */
--play-hot-line: #3a1d18;
@@ -146,6 +151,7 @@
--moss: #3d6c46;
--clay: #7c5533;
--trash: #8c6558;
+ --patina: #1f6f66;
--play-hot-line: #f0cfc6;
--fav-line: #e3d3a4;
@@ -290,9 +296,21 @@ button { cursor: pointer; }
letter-spacing: .04em;
}
-/* ---- reader roster (owner only): same hairline panel, one row per Reader ---- */
-.readerlist { margin: 0; padding: 0; list-style: none; }
-.readerlist li {
+/* ---- admin page: two sections on the same measured sheet, no cards ----
+ The reading page is a list of series; this is a list of facts. Both are
+ sheets of hairline-separated rows, so the roster keeps the shape it had as
+ a fold-out and the Lane block copies it. */
+.readers, .lanes { margin: 0 20px; padding: 12px 0 16px; border-bottom: 1px solid var(--rule); }
+.readers h2, .lanes h2 {
+ margin: 0;
+ padding: 8px 0;
+ font: 500 10px/1 var(--font-mono);
+ letter-spacing: .2em;
+ text-transform: uppercase;
+ color: var(--mute-2);
+}
+.readerlist, .lanelist { margin: 0; padding: 0; list-style: none; }
+.readerlist li, .lanelist li {
display: flex;
align-items: center;
flex-wrap: wrap;
@@ -300,7 +318,8 @@ button { cursor: pointer; }
min-height: 44px;
border-top: 1px solid var(--rule);
}
-.readerlist form { margin: 0 0 0 auto; }
+.reader-actions { display: flex; gap: 18px; margin-left: auto; }
+.readerlist form { margin: 0; }
.reader-id {
font: 500 13px/1.4 var(--font-mono);
letter-spacing: .04em;
@@ -312,6 +331,30 @@ button { cursor: pointer; }
text-transform: uppercase;
color: var(--mute);
}
+.reader-sightings, .lane-fact {
+ font: 500 10px/1 var(--font-mono);
+ letter-spacing: .14em;
+ text-transform: uppercase;
+ color: var(--mute-2);
+}
+/* Two states the owner is meant to find rather than read for: a Reader whose
+ reports no longer defer a Poll, and a Lane that is not keeping its promise.
+ Both wear --patina — never ember, which means one thing, and never danger,
+ which is destruction. */
+.reader-blocked, .lane-mark {
+ font: 500 10px/1 var(--font-mono);
+ letter-spacing: .14em;
+ text-transform: uppercase;
+ color: var(--patina);
+}
+.lane-site {
+ font: 400 19px/1.2 var(--font-display);
+ color: var(--paper-dim);
+}
+/* The whole row leans patina when the Lane needs attention, so the scan is one
+ pass down the left edge rather than a read of every mark. */
+.lanelist li.attention .lane-site { color: var(--patina); }
+.lane-browser { padding: 12px 0 0; }
/* Revocation cuts someone off, so it wears --danger. Ember stays reserved for
the new-chapter signal. */
.ghost.danger { color: var(--danger); }
@@ -600,6 +643,9 @@ button { cursor: pointer; }
box-shadow: inset 0 -2px 0 var(--ember);
}
.topbar form { margin-left: 18px; }
+/* The admin page's topbar has no switch to fill the middle, so its back link
+ keeps company with Log out at the right edge instead of floating centre. */
+.topbar .back { margin-left: auto; }
/* At phone width brand + switch + Log out do not fit on one line, so the
switch takes its own row under the wordmark rather than pushing Log out
off-screen. */
@@ -609,6 +655,9 @@ button { cursor: pointer; }
.libswitch { order: 3; margin-left: 0; }
.libswitch a { flex: 1; text-align: center; padding: 8px 14px; }
.topbar form { margin-left: 12px; }
+ /* The admin page has no switch to take the second row, so its brand claims
+ the first outright and the back link keeps Log out company below. */
+ .topbar:has(.back) .brand { flex: 1 1 100%; }
}
/* ---- action strip: full-width on a phone, hairline-divided cells ---- */
diff --git a/backend/internal/web/templates/admin.html b/backend/internal/web/templates/admin.html
new file mode 100644
index 0000000..f2f8e0d
--- /dev/null
+++ b/backend/internal/web/templates/admin.html
@@ -0,0 +1,39 @@
+{{/* The owner's administrative page: everything that reaches past one Reader,
+ at its own address so it can be bookmarked rather than hunted for inside
+ the reading page. Owner-only at route registration (requireOwner), which
+ is why nothing in here re-tests who is asking. */}}
+{{define "admin"}}
+
+
+
+
+
+
+ BookmarkManager — Admin
+
+
+
+
+
+
+
+
+
{{template "mark" .}}BookmarkManager
+ {{/* Back to the library, no switch: this page belongs to neither library,
+ and the ember-lit switch says which library you are reading. */}}
+ Library
+
+
+
+ {{/* The live region wraps the swapped block rather than being it: the
+ refresh replaces the section wholesale, and a region recreated on every
+ update is never announced. */}}
+
{{template "lanes" .Lanes}}
+
+ {{template "readers" .}}
+
+
+
+{{end}}
diff --git a/backend/internal/web/templates/app.html b/backend/internal/web/templates/app.html
index ae6586a..d761b95 100644
--- a/backend/internal/web/templates/app.html
+++ b/backend/internal/web/templates/app.html
@@ -28,6 +28,10 @@
Novels
+ {{/* The owner's only difference on this page: a link out to the
+ administrative one. It sits beside Log out rather than in the library
+ switch — that switch says which library, not which page. */}}
+ {{if .Owner}}Admin{{end}}
@@ -76,8 +80,6 @@
{{template "setup" .}}
- {{if .Owner}}{{template "readers" .}}{{end}}
-
{{template "keyrow" .}}
{{template "recent" .}}
diff --git a/backend/internal/web/templates/lanes.html b/backend/internal/web/templates/lanes.html
new file mode 100644
index 0000000..6cf61b7
--- /dev/null
+++ b/backend/internal/web/templates/lanes.html
@@ -0,0 +1,41 @@
+{{/* Poll Lane status: one row per Site, refreshing itself so a run can be
+ watched rather than sampled by reloading. The refresh is one attribute on
+ the fragment root and the endpoint answers with this same fragment, so the
+ swap replaces the element that asked for it.
+
+ Every figure here is read out of the running poller, never out of a table:
+ a Site absent from Rows has not completed a pass since the last restart,
+ which the empty state must say — zeroes would read as a stopped Lane. */}}
+{{define "lanes"}}
+
+
No data yet — no Lane has completed a pass since the
+ backend started.
+ {{end}}
+
+ {{if .PollerOff}}Polling is switched off in this deployment: no Lane runs,
+ and Latest Chapter comes from the userscripts alone.
+ {{else}}Browser sidecar:
+ {{if not .BrowserConfigured}}not configured — comix, kagane and novelfull
+ pages are not fetched through it{{else if .BrowserReachable}}reachable
+ {{else}}unreachable{{end}}.{{end}}
+
+{{end}}
diff --git a/backend/internal/web/templates/readers.html b/backend/internal/web/templates/readers.html
index 85b844c..0cef186 100644
--- a/backend/internal/web/templates/readers.html
+++ b/backend/internal/web/templates/readers.html
@@ -1,29 +1,48 @@
-{{/* The owner's Reader roster. Rendered only for the owner (listView.Owner),
- and re-rendered whole as the response to a revocation so the session
- counts cannot describe the state before the tap. Revocation is
- confirm-gated: it signs someone out of every device at once. */}}
+{{/* The Reader roster, on the owner's administrative page. Re-rendered whole
+ as the response to an action so the counts and marks it shows cannot
+ describe the state before the tap. Both actions are confirm-gated: one
+ signs a Reader out of every device at once, the other wipes a record.
+
+ Owner-only at route registration, so nothing here re-tests who is asking. */}}
{{define "readers"}}
-
- Readers
+
+
Readers
Everyone who has signed in through Discord. Revoking
signs a Reader out of every device; their library and bookmarks are
- untouched, and they can sign in again.
+ untouched, and they can sign in again. The Sighting counters record how
+ often a later Poll confirmed or contradicted what that Reader's browser
+ reported; enough contradictions stop their reports deferring a Poll, and
+ clearing the marks gives that back.
{{range .Readers}}
{{.DiscordID}}{{.Sessions}} session{{if ne .Sessions 1}}s{{end}}
- {{/* The owner's own row never offers Revoke: it is the one row where the
- button would sign the tapping browser out, and the endpoint refuses
- it anyway. Logout is the deliberate way to do that. */}}
- {{if and .Sessions (ne .ID $.OwnerID)}}
-
- {{end}}
+ {{.Agreements}} confirmed / {{.Disagreements}} contradicted
+ {{/* Blocked is spelled out rather than left to be worked out from two
+ numbers and a threshold. */}}
+ {{if .Blocked}}deferral blocked{{end}}
+
+ {{/* Clearing restores a privilege, so it is a plain ghost button —
+ the destruction accent belongs to revocation alone. It is offered
+ on every row, including one reading zero: the remedy must be
+ findable before the counters climb, not after. */}}
+
+ {{/* The owner's own row never offers Revoke: it is the one row where the
+ button would sign the tapping browser out, and the endpoint refuses
+ it anyway. Logout is the deliberate way to do that. */}}
+ {{if and .Sessions (ne .ID $.OwnerID)}}
+
+ {{end}}
+
{{end}}
-
+
{{end}}
diff --git a/backend/internal/web/web.go b/backend/internal/web/web.go
index 4c3c753..e1f3a69 100644
--- a/backend/internal/web/web.go
+++ b/backend/internal/web/web.go
@@ -50,6 +50,9 @@ type Handler struct {
// httpClient is the plain stdlib client that talks to Discord. It is not
// an injected interface: tests point APIBase at a stub server instead.
httpClient *http.Client
+ // lanes is the Poll Lane snapshot source the administrative page reads.
+ // Nil is a running deployment with no poller, not a bug.
+ lanes LaneReporter
}
// listView is what every list-rendering template receives.
@@ -77,14 +80,9 @@ type listView struct {
// It is not "newly registered": a Reader who deletes their last bookmark is
// in the same position and needs the same links.
EmptyLibrary bool
- // Owner marks the acting Reader as the deployment's owner, which unlocks
- // the Readers panel. Nothing else in the UI differs.
+ // Owner marks the acting Reader as the deployment's owner, which offers
+ // the link to the administrative page. Nothing else in the UI differs.
Owner bool
- // Readers is the owner's roster, populated only for the owner's own page
- // render and the revocation fragment. OwnerID travels with it so the roster
- // can tell the owner's own row apart from the Readers they may revoke.
- Readers []store.ReaderSummary
- OwnerID int64
}
// PageURL and ListURL are the two link shapes every tab needs. Building them
@@ -111,7 +109,10 @@ type loginView struct {
// New parses every template up front so a broken one kills the process at
// startup rather than the first request that touches it.
-func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string) (*Handler, error) {
+//
+// lanes is the administrative page's window onto the running Poller; nil means
+// nothing is polling, which the page reports rather than hides.
+func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string, lanes LaneReporter) (*Handler, error) {
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
if err != nil {
return nil, err
@@ -126,6 +127,7 @@ func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, nove
states: newOAuthStates(),
limiter: session.NewLoginLimiter(),
httpClient: &http.Client{Timeout: discordTimeout},
+ lanes: lanes,
}, nil
}
@@ -149,9 +151,11 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /install/manga-bookmark.user.js", h.requireSession(h.installUserscript("manga-bookmark.user.js")))
mux.HandleFunc("GET /install/novel-bookmark.user.js", h.requireSession(h.installUserscript("novel-bookmark.user.js")))
mux.HandleFunc("POST /rotate-token", h.requireSession(h.rotateToken))
-
- // Owner-only: the one place the UI crosses the Reader boundary.
- mux.HandleFunc("POST /readers/{id}/revoke", h.requireSession(h.revokeReaderSessions))
+ // Owner-only: every route that reaches past the acting Reader is gated in
+ // one place, so a missing gate is visible in the route list.
+ for _, rt := range h.adminRoutes() {
+ mux.HandleFunc(rt.pattern, h.requireOwner(rt.handler))
+ }
}
// staticHandler serves the embedded assets. An hour, not longer: assets are
@@ -240,14 +244,9 @@ func (h *Handler) index(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
- if readerID == h.store.OwnerID() {
- view.Owner, view.OwnerID = true, readerID
- if view.Readers, err = h.store.Readers(); err != nil {
- log.Printf("index readers: %v", err)
- http.Error(w, "internal error", http.StatusInternalServerError)
- return
- }
- }
+ // The owner's page differs only by the link to the administrative page:
+ // the roster lives there now, so the page read every day is only reading.
+ view.Owner = readerID == h.store.OwnerID()
h.render(w, http.StatusOK, "app", view)
}
@@ -618,40 +617,3 @@ func (h *Handler) rotateToken(w http.ResponseWriter, r *http.Request) {
view := listView{Lib: store.KindManga, Rotated: true}
h.render(w, http.StatusOK, "setup", view)
}
-
-// revokeReaderSessions logs one Reader out of every browser they are signed
-// in on. Owner-only: it reaches across the Reader boundary every other handler
-// respects, so the guard is a comparison against the seeded owner rather than
-// a role a Reader could acquire. A non-owner gets 404 — the panel does not
-// exist for them, so neither should the endpoint.
-func (h *Handler) revokeReaderSessions(w http.ResponseWriter, r *http.Request) {
- if readerOf(r) != h.store.OwnerID() {
- http.NotFound(w, r)
- return
- }
- target, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
- if err != nil {
- http.Error(w, "bad reader id", http.StatusBadRequest)
- return
- }
- // The owner is not one of the Readers this endpoint reaches: revoking
- // themselves would sign out the browser making the request, which is what
- // logout is for. The roster hides the button; this refuses the hand-rolled
- // POST behind it.
- if target == h.store.OwnerID() {
- http.NotFound(w, r)
- return
- }
- if err := h.store.DeleteReaderSessions(target); err != nil {
- log.Printf("revoke sessions: %v", err)
- http.Error(w, "internal error", http.StatusInternalServerError)
- return
- }
- readers, err := h.store.Readers()
- if err != nil {
- log.Printf("revoke sessions: %v", err)
- http.Error(w, "internal error", http.StatusInternalServerError)
- return
- }
- h.render(w, http.StatusOK, "readers", listView{Owner: true, Readers: readers, OwnerID: h.store.OwnerID()})
-}
diff --git a/backend/main.go b/backend/main.go
index 74f16cd..bebff8d 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -129,7 +129,10 @@ func loadConfig() Config {
// newRouter wires routes and middleware. CORS is the outermost layer so
// preflight OPTIONS short-circuits before auth; /bookmarks* is auth-protected,
// /healthz is public.
-func newRouter(s *store.Store, cfg Config) http.Handler {
+//
+// lanes may be nil — polling disabled, or its client could not be built. The
+// admin page reports that rather than pretending Lanes exist.
+func newRouter(s *store.Store, cfg Config, lanes web.LaneReporter) http.Handler {
mux := http.NewServeMux()
h := &api.Handler{Store: s}
mux.HandleFunc("GET /healthz", api.Healthz)
@@ -160,7 +163,7 @@ func newRouter(s *store.Store, cfg Config) http.Handler {
// The browser UI is always registered; signing in is Discord OAuth, so
// there is no password to forget and no gate to leave unset.
wh, err := web.New(s, cfg.Discord, []byte(cfg.TokenKey),
- cfg.UserscriptPath, cfg.NovelUserscriptPath)
+ cfg.UserscriptPath, cfg.NovelUserscriptPath, lanes)
if err != nil {
log.Fatalf("web handler: %v", err)
}
@@ -275,11 +278,17 @@ func main() {
}
s.OnSeriesCreated = acq.Acquire
}
- startLatestPoller(pollCtx, s, cfg.LatestPoll, browser)
+ // A nil *Poller must not become a non-nil interface holding a nil pointer:
+ // the admin page tests the reporter for nil to decide whether anything is
+ // polling at all.
+ var lanes web.LaneReporter
+ if poller := startLatestPoller(pollCtx, s, cfg.LatestPoll, browser); poller != nil {
+ lanes = poller
+ }
srv := &http.Server{
Addr: ":" + cfg.Port,
- Handler: newRouter(s, cfg),
+ Handler: newRouter(s, cfg, lanes),
ReadHeaderTimeout: 10 * time.Second,
}
@@ -326,16 +335,17 @@ func newLatestPoller(s *store.Store, cfg LatestPoll, fetch, browser latest.Fetch
// startLatestPoller launches the background poller unless it is disabled or its
// HTTP client cannot be built. Any problem here is logged and skipped: this
// feature going missing degrades the service to userscript-only latest-chapter
-// tracking, which is exactly how it behaved before.
-func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll, browser latest.Fetcher) {
+// tracking, which is exactly how it behaved before. It returns the running
+// Poller, or nil when there is none — the admin page's Lane status reads it.
+func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll, browser latest.Fetcher) *latest.Poller {
if !cfg.Enabled {
log.Println("latest-chapter poller: disabled by config")
- return
+ return nil
}
f, err := latest.NewTLSFetcher()
if err != nil {
log.Printf("latest-chapter poller: disabled, cannot build client: %v", err)
- return
+ return nil
}
// Nil browser: sites behind a JavaScript challenge are simply not polled,
// and their latest_chapter comes from the userscript alone — which is how
@@ -343,4 +353,5 @@ func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll, brow
p := newLatestPoller(s, cfg, f, browser)
go p.Run(ctx)
+ return p
}
diff --git a/backend/reader_credential_test.go b/backend/reader_credential_test.go
index e66970c..03d2a85 100644
--- a/backend/reader_credential_test.go
+++ b/backend/reader_credential_test.go
@@ -49,7 +49,7 @@ func withBody(req *http.Request, body string) *http.Request {
// A refused credential is refused however plausible it looks: only a hash the
// readers table holds authenticates anything.
func TestUnknownCredentialRejected(t *testing.T) {
- srv := newRouter(newTestStore(t), testConfig())
+ srv := newRouter(newTestStore(t), testConfig(), nil)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", readerCredential("never-registered")))
@@ -69,7 +69,7 @@ func TestUnknownCredentialRejected(t *testing.T) {
func TestPerReaderIsolation(t *testing.T) {
s := newTestStore(t)
registerReader(t, s, "other-reader")
- srv := newRouter(s, testConfig())
+ srv := newRouter(s, testConfig(), nil)
ownerKey := "asura:solo"
putBookmark(t, srv, ownerKey, store.Bookmark{
@@ -267,7 +267,7 @@ func TestRotateCredentialViaWebUI(t *testing.T) {
}
cfg := testConfig()
cfg.UserscriptPath = path
- srv := newRouter(s, cfg)
+ srv := newRouter(s, cfg, nil)
oldCred := ownerCredential()
rr := httptest.NewRecorder()
diff --git a/backend/web_test.go b/backend/web_test.go
index eb3c704..445f0bb 100644
--- a/backend/web_test.go
+++ b/backend/web_test.go
@@ -1,6 +1,7 @@
package main
import (
+ "database/sql"
"encoding/json"
"fmt"
"io"
@@ -15,6 +16,7 @@ import (
"testing"
"time"
+ "bookmarkmanager/backend/internal/latest"
"bookmarkmanager/backend/internal/session"
"bookmarkmanager/backend/internal/store"
"bookmarkmanager/backend/internal/web"
@@ -26,11 +28,17 @@ import (
const testOwnerID = "owner-snowflake"
// newWebTestServer returns the full router plus the store behind it, so tests
-// can seed rows and assert on what the handlers wrote back.
-func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *store.Store) {
+// can seed rows and assert on what the handlers wrote back. An optional lane
+// reporter stands in for the running poller; omitted means none is running,
+// which is what every test that is not about the admin page wants.
+func newWebTestServer(t *testing.T, cfg Config, lanes ...web.LaneReporter) (http.Handler, *store.Store) {
t.Helper()
st := newTestStore(t)
- return newRouter(st, cfg), st
+ var reporter web.LaneReporter
+ if len(lanes) > 0 {
+ reporter = lanes[0]
+ }
+ return newRouter(st, cfg, reporter), st
}
// sessionCookie mints a live session row for the owner and returns the cookie
@@ -141,12 +149,12 @@ func discordConfig(stubURL string) web.DiscordConfig {
// oauthWebTestServer returns the full router, its store, and a Discord stub
// wired as the configured API — the starting point for sign-in tests.
-func oauthWebTestServer(t *testing.T) (http.Handler, *store.Store, *discordStub) {
+func oauthWebTestServer(t *testing.T, lanes ...web.LaneReporter) (http.Handler, *store.Store, *discordStub) {
t.Helper()
stub, srv := newDiscordStub(t)
cfg := testConfig()
cfg.Discord = discordConfig(srv.URL)
- router, st := newWebTestServer(t, cfg)
+ router, st := newWebTestServer(t, cfg, lanes...)
return router, st, stub
}
@@ -410,7 +418,7 @@ func TestDiscordLoginRefusesNonMember(t *testing.T) {
cfg.Discord = discordConfig(srv.URL)
cfg.Discord.RequiredRole = tc.require
st := newTestStore(t)
- router := newRouter(st, cfg)
+ router := newRouter(st, cfg, nil)
rr := completeSignIn(t, router, startSignIn(t, router))
if rr.Code != http.StatusForbidden {
@@ -626,24 +634,52 @@ func TestOwnerRevokesAnotherReadersSessions(t *testing.T) {
}
}
-// The owner's own page carries the roster; nobody else's does.
-func TestOwnerSeesReadersPanel(t *testing.T) {
+// fakeLanes is the admin page's poller stand-in: one fixed snapshot, so the
+// page's tests need neither a poller nor a Site.
+type fakeLanes struct{ status latest.Status }
+
+func (f fakeLanes) LaneStatus() latest.Status { return f.status }
+
+// The roster moved off the reading page onto its own address: the owner gets a
+// link, everyone else gets nothing, and the page itself lists every Reader with
+// the counters and the two controls.
+func TestAdminPageCarriesRosterAndOwnerLink(t *testing.T) {
router, st, _ := oauthWebTestServer(t)
- signInCookie(t, router)
+ theirCookie := signInCookie(t, router)
+ ownerCookie := sessionCookie(t, st)
req := httptest.NewRequest(http.MethodGet, "/", nil)
- req.AddCookie(sessionCookie(t, st))
+ req.AddCookie(ownerCookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
body := rr.Body.String()
- if !strings.Contains(body, `id="readers"`) {
- t.Fatal("the owner's page lacks the Readers panel")
+ if strings.Contains(body, `id="readers"`) {
+ t.Error("the reading page still carries the roster; it belongs on /admin")
}
- if !strings.Contains(body, testOwnerID) {
- t.Fatalf("the roster does not list the registered Reader:\n%s", body)
+ if !strings.Contains(body, `href="/admin"`) {
+ t.Error("the owner's reading page offers no link to the admin page")
}
- if !strings.Contains(body, "Revoke sessions") {
- t.Fatal("the roster offers no revocation control for a signed-in Reader")
+
+ req = httptest.NewRequest(http.MethodGet, "/", nil)
+ req.AddCookie(theirCookie)
+ rr = httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+ if strings.Contains(rr.Body.String(), `href="/admin"`) {
+ t.Error("a non-owner was offered the admin link")
+ }
+
+ req = httptest.NewRequest(http.MethodGet, "/admin", nil)
+ req.AddCookie(ownerCookie)
+ rr = httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Fatalf("GET /admin status = %d, want 200", rr.Code)
+ }
+ body = rr.Body.String()
+ for _, want := range []string{`id="readers"`, testOwnerID, "Revoke sessions", "Clear marks", "confirmed"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("admin page lacks %q:\n%s", want, body)
+ }
}
// Exactly one revocable row: the other Reader's. The owner's own row carries
// the same session count and no button.
@@ -652,6 +688,200 @@ func TestOwnerSeesReadersPanel(t *testing.T) {
}
}
+// Every administrative route is gated the same way, so the test walks the list
+// the router registers rather than naming routes by hand: no session is 401,
+// a signed-in non-owner is 404, and the address is not confirmed to either.
+func TestAdminRoutesAreOwnerOnly(t *testing.T) {
+ router, st, _ := oauthWebTestServer(t)
+ theirCookie := signInCookie(t, router)
+ ownerCookie := sessionCookie(t, st)
+ target := strconv.FormatInt(st.OwnerID(), 10)
+
+ patterns := web.AdminPatterns()
+ if len(patterns) == 0 {
+ t.Fatal("no administrative routes to test")
+ }
+ for _, pattern := range patterns {
+ method, path, ok := strings.Cut(pattern, " ")
+ if !ok {
+ t.Fatalf("route pattern %q has no method", pattern)
+ }
+ path = strings.Replace(path, "{id}", target, 1)
+
+ for _, tc := range []struct {
+ name string
+ cookie *http.Cookie
+ want int
+ }{
+ {"no session", nil, http.StatusUnauthorized},
+ {"non-owner", theirCookie, http.StatusNotFound},
+ } {
+ req := httptest.NewRequest(method, path, nil)
+ if tc.cookie != nil {
+ req.AddCookie(tc.cookie)
+ }
+ rr := httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+ if rr.Code != tc.want {
+ t.Errorf("%s %s as %s: status = %d, want %d", method, path, tc.name, rr.Code, tc.want)
+ }
+ }
+
+ req := httptest.NewRequest(method, path, nil)
+ req.AddCookie(ownerCookie)
+ rr := httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+ if rr.Code == http.StatusUnauthorized {
+ t.Errorf("%s %s as the owner: status = 401, the gate rejects the owner", method, path)
+ }
+ }
+}
+
+// The Lane block reports what the poller says, and marks the Lanes that need
+// attention — a clamped gap, a refusal, a Site whose pages can only be read
+// through a sidecar that is not there, and a Lane with Series waiting that its
+// last pass did not read.
+func TestAdminPageShowsLaneStatus(t *testing.T) {
+ lanes := fakeLanes{latest.Status{
+ Lanes: []latest.LaneState{
+ {Site: "asura", Due: 12, Checked: 12, LastRun: time.Now().Add(-90 * time.Second), Gap: 40 * time.Second},
+ {Site: "kagane", Due: 3, Checked: 3, LastRun: time.Now().Add(-time.Minute), Gap: time.Minute, Browser: true},
+ {Site: "demonic", Due: 400, Checked: 400, LastRun: time.Now(), Gap: 8 * time.Second, Clamped: true},
+ {Site: "comix", Due: 7, LastRun: time.Now(), Gap: time.Minute, Browser: true},
+ },
+ BrowserConfigured: true,
+ BrowserReachable: true,
+ }}
+ router, st, _ := oauthWebTestServer(t, lanes)
+
+ req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil)
+ req.AddCookie(sessionCookie(t, st))
+ rr := httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Fatalf("GET /ui/admin/lanes status = %d, want 200", rr.Code)
+ }
+ body := rr.Body.String()
+ for _, want := range []string{"asura", "kagane", "12 due", "12 checked", "gap 40s", "ran 1m30s ago", "gap at floor", "not checking", "reachable"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("lane status lacks %q:\n%s", want, body)
+ }
+ }
+ // Nothing is refusing and the sidecar is up, so neither mark may appear:
+ // a mark the owner cannot act on is worse than none.
+ for _, unwanted := range []string{"refusing", "no browser"} {
+ if strings.Contains(body, unwanted) {
+ t.Errorf("lane status marks %q on a healthy run:\n%s", unwanted, body)
+ }
+ }
+}
+
+// A Lane whose pass never reached a figure must not have that figure drawn as
+// a zero: a refusing Lane still reports the due count and gap its last real
+// pass saw, and a Lane that has never reached one omits it entirely.
+func TestLaneStatusOmitsUnknownGap(t *testing.T) {
+ lanes := fakeLanes{latest.Status{
+ Lanes: []latest.LaneState{{Site: "comix", LastRun: time.Now(), Refusing: true, Browser: true}},
+ BrowserConfigured: true,
+ BrowserReachable: true,
+ }}
+ router, st, _ := oauthWebTestServer(t, lanes)
+
+ req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil)
+ req.AddCookie(sessionCookie(t, st))
+ rr := httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+ body := rr.Body.String()
+ if strings.Contains(body, "gap 0s") {
+ t.Errorf("a Lane with no pace yet states a zero gap:\n%s", body)
+ }
+ if !strings.Contains(body, "refusing") {
+ t.Errorf("a refusing Lane is not marked as such:\n%s", body)
+ }
+}
+
+// No poller and a poller that has not finished a pass both render "no data
+// yet" rather than zeroes that read as a stopped backend — but they are not
+// the same fact, so the page must not blame the sidecar when nothing polls.
+func TestAdminPageWithoutAPollerSaysSo(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ lanes []web.LaneReporter
+ want, unwant string
+ }{
+ {"no poller", nil, "Polling is switched off", "not configured"},
+ {"poller, no pass yet", []web.LaneReporter{fakeLanes{}}, "not configured", "Polling is switched off"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ router, st, _ := oauthWebTestServer(t, tc.lanes...)
+ req := httptest.NewRequest(http.MethodGet, "/admin", nil)
+ req.AddCookie(sessionCookie(t, st))
+ rr := httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+ body := rr.Body.String()
+ if !strings.Contains(body, "No data yet") {
+ t.Errorf("admin page with no Lane data does not say so:\n%s", body)
+ }
+ if !strings.Contains(body, tc.want) {
+ t.Errorf("admin page lacks %q:\n%s", tc.want, body)
+ }
+ if strings.Contains(body, tc.unwant) {
+ t.Errorf("admin page states %q, which is not what is wrong:\n%s", tc.unwant, body)
+ }
+ })
+ }
+}
+
+// A Reader past the disagreement threshold is rendered as blocked, and
+// clearing their marks both zeroes the counters and lifts the block in the
+// roster the response carries back.
+func TestOwnerClearsReaderMarks(t *testing.T) {
+ st, dsn := newTestStoreURL(t)
+ router := newRouter(st, testConfig(), nil)
+ cookie := sessionCookie(t, st)
+ // The counters are filled by issue #103; until it lands the only way to
+ // stand a marked Reader up is to write the columns directly.
+ db, err := sql.Open("pgx", dsn)
+ if err != nil {
+ t.Fatalf("open %s: %v", dsn, err)
+ }
+ defer db.Close()
+ if _, err := db.Exec(`UPDATE readers SET sighting_agreements = 4, sighting_disagreements = 3 WHERE id = $1`, st.OwnerID()); err != nil {
+ t.Fatalf("mark reader: %v", err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/admin", nil)
+ req.AddCookie(cookie)
+ rr := httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+ body := rr.Body.String()
+ if !strings.Contains(body, "4 confirmed / 3 contradicted") {
+ t.Errorf("roster does not report the Reader's marks:\n%s", body)
+ }
+ if !strings.Contains(body, "deferral blocked") {
+ t.Errorf("a Reader at the threshold is not rendered as blocked:\n%s", body)
+ }
+
+ req = httptest.NewRequest(http.MethodPost,
+ "/readers/"+strconv.FormatInt(st.OwnerID(), 10)+"/clear-marks", nil)
+ req.AddCookie(cookie)
+ rr = httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Fatalf("clear marks: status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
+ }
+ body = rr.Body.String()
+ if !strings.Contains(body, `id="readers"`) {
+ t.Fatalf("clear marks did not re-render the roster:\n%s", body)
+ }
+ if !strings.Contains(body, "0 confirmed / 0 contradicted") {
+ t.Errorf("roster does not report the cleared counters:\n%s", body)
+ }
+ if strings.Contains(body, "deferral blocked") {
+ t.Errorf("a cleared Reader is still marked blocked:\n%s", body)
+ }
+}
+
func TestDiscordLoginTokenEndpointDown(t *testing.T) {
stub, srv := newDiscordStub(t)
stub.tokenStatus = http.StatusInternalServerError
diff --git a/docs/design-system.md b/docs/design-system.md
index 9f4aeb7..e060c91 100644
--- a/docs/design-system.md
+++ b/docs/design-system.md
@@ -10,7 +10,7 @@ Implemented in:
| Surface | Files |
| --- | --- |
-| Web UI (login, list, card, empty, errors) | `backend/internal/web/static/style.css`, `backend/internal/web/templates/{app,card,list,login,chrome,icons}.html`, `backend/internal/web/static/filter.js` |
+| Web UI (login, list, card, empty, errors, admin) | `backend/internal/web/static/style.css`, `backend/internal/web/templates/{app,admin,lanes,readers,card,list,login,chrome,icons}.html`, `backend/internal/web/static/filter.js` |
| Userscript panel (Shadow DOM) | `userscript/manga-bookmark.user.js` — `TEMPLATE` and `CSS` at the bottom of the IIFE |
## 1. The one idea
@@ -73,6 +73,7 @@ Defined once in `backend/internal/web/static/style.css` `:root`, mirrored in the
| `--moss` | `#7fae86` | `#3d6c46` | finished accent |
| `--clay` | `#b5906f` | `#7c5533` | set-chapter accent |
| `--trash` | `#977671` | `#8c6558` | remove, at rest — icons need 3:1, not 4.5:1 |
+| `--patina` | `#5fb3a6` | `#1f6f66` | admin page only — a Poll Lane needing attention, a Reader whose reports are blocked |
| `--play-hot-line` | `#3a1d18` | `#f0cfc6` | desktop cell border, play when `.is-new` |
| `--fav-line` | `#332b14` | `#e3d3a4` | desktop cell border, favourite when on |
| `--asura` | `#7d93a5` | `#4f6b80` | site tag |
@@ -81,9 +82,13 @@ Defined once in `backend/internal/web/static/style.css` `:root`, mirrored in the
| `--kagane` | `#9a8aa5` | `#6f5f7d` | site tag |
| `--hatch` / `--hatch-dim` | 135° 5px stripe | paper stripe | missing-cover slot |
-`--slate`/`--moss`/`--clay`/`--brass` are held at the same weight deliberately:
-one accent per action, so a press says which lane it belongs to, with none of
-them competing with ember. Dark is the default (`color-scheme: dark light`);
+`--slate`/`--moss`/`--clay`/`--brass`/`--patina` are held at the same weight
+deliberately: one accent per meaning, so a press says which lane it belongs to,
+with none of them competing with ember. `--patina` is the admin page's only
+colour — a cool verdigris, the far side of the wheel from ember's crimson and
+clear of the archive blue: system health is neither a new chapter nor
+destruction, so it borrows neither `--ember` nor `--danger`.
+Dark is the default (`color-scheme: dark light`);
light is a `@media (prefers-color-scheme: light)` override of the same names.
**Any new colour must be added in both branches** — light is not a filter over
dark, the hues are re-tuned.
@@ -140,6 +145,14 @@ Recurring specs (copy these rather than inventing sizes):
main#list article.card … | .empty
```
+The owner's admin page (`admin.html`) is the same sheet with two sections in
+place of the list — `.lanes` (Poll Lane rows) and `.readers` (the roster) —
+and no library switch: it belongs to neither library, so its topbar carries a
+plain `.ghost.back` link home. Both sections are eyebrow + hairline-separated
+rows, the shape the roster already had as a fold-out. `.lanes` refreshes itself
+every 30s via `hx-get="/ui/admin/lanes"` with `hx-swap="outerHTML"`; the roster
+re-renders only in answer to an action.
+
**Brand mark**: an inline `