From 1e6f1e985daf7b6a867f205a7e4619306a15c2a6 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sun, 16 Aug 2026 15:02:13 +0700 Subject: [PATCH] Owner-only admin page: Reader roster plus Poll Lane status (#102) (#107) Closes #102. The only operational surface was /healthz and a fold-out roster inside the owner's own reading page. This adds /admin: an owner-only page carrying the Reader roster and one row per Poll Lane. - **Poller seam.** `latest.Poller` records each Lane's last pass (`Site`, `Due`, `Checked`, `LastRun`, `Gap`, `Clamped`, `Browser`) and answers `LaneStatus()`; the page reads that snapshot, never a table. A pass that returns before computing its figures (refusal backoff, sidecar down) carries the previous pass's figures forward rather than recording zeroes, and a Lane that has never reached a pace renders no gap at all. Refusal and sidecar reachability are derived at snapshot time. - **Owner gate at registration.** Every route reaching past the acting Reader lives in `adminRoutes()` and is wrapped in `requireOwner` when it is registered, so a missing gate is visible in the route list rather than hidden in a handler. `web.AdminPatterns()` is what the gate test walks, so a new route cannot be added without being tested. A non-owner gets 404, never 403. - **Nil poller is a first-class state.** `main.newRouter` takes the reporter as an interface and converts a nil `*Poller` to a nil interface; no poller and no completed pass both render "No data yet" with the reason spelled out, rather than confident zeroes. - **Roster moved** off the reading page onto /admin, with the Sighting counters and a confirm-gated `Clear marks` control. #103 fills those counters, so on delivery they read zero for everyone - deliberate ordering. - **One accent, `--patina`** (verdigris, both colour branches): the far side of the wheel from ember's crimson and clear of the archive blue. Ember still means new chapter only; revocation still wears --danger. Verification: `go vet ./...` and `go test ./...` green (Docker-backed); admin page screenshotted at 1100px and 390px in both colour schemes. Reviewed on both axes (spec, standards); findings on the accent hue, zero-figure honesty and three tests that could not fail are fixed in 58014eb. Reviewed-on: https://gitea.violetcrown.my.id/sulthan/mangaBookmark/pulls/107 Co-authored-by: Sulthan Zaki Co-committed-by: Sulthan Zaki --- backend/AGENTS.md | 25 +- backend/api_test.go | 8 +- backend/cover_test.go | 2 +- backend/internal/latest/poller.go | 24 +- backend/internal/latest/poller_test.go | 88 + backend/internal/latest/status.go | 74 + .../migrations/0010_reader_sightings.sql | 6 + backend/internal/store/store.go | 41 +- backend/internal/store/store_test.go | 80 + backend/internal/web/admin.go | 251 + backend/internal/web/static/style.css | 57 +- backend/internal/web/templates/admin.html | 39 + backend/internal/web/templates/app.html | 6 +- backend/internal/web/templates/lanes.html | 41 + backend/internal/web/templates/readers.html | 53 +- backend/internal/web/web.go | 74 +- backend/main.go | 27 +- backend/reader_credential_test.go | 6 +- backend/web_test.go | 262 +- docs/design-system.md | 21 +- graphify-out/.graphify_labels.json | 5 +- graphify-out/GRAPH_REPORT.md | 103 +- graphify-out/graph.html | 10 +- graphify-out/graph.json | 4133 +++++++++++------ graphify-out/manifest.json | 119 +- 25 files changed, 3822 insertions(+), 1733 deletions(-) create mode 100644 backend/internal/latest/status.go create mode 100644 backend/internal/store/migrations/0010_reader_sightings.sql create mode 100644 backend/internal/web/admin.go create mode 100644 backend/internal/web/templates/admin.html create mode 100644 backend/internal/web/templates/lanes.html 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"}} +
+

Poll Lanes

+ {{if .Rows}} +
    + {{range .Rows}} + + {{.Site}} + {{.Due}} due + {{.Checked}} checked + ran {{.Ran}} + {{if .Gap}}gap {{.Gap}}{{end}} + {{if .Clamped}}gap at floor{{end}} + {{if .Refusing}}refusing{{end}} + {{if .BrowserLost}}no browser{{end}} + {{if .Stalled}}not checking{{end}} + + {{end}} +
+ {{else}} +

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 `` (`viewBox="0 0 200 172"`), defined once in `chrome.html`'s `mark` template and reused by `app.html` and `login.html` so it takes the page's `--ink`/`currentColor`/`--ember` rather diff --git a/graphify-out/.graphify_labels.json b/graphify-out/.graphify_labels.json index eb7606b..35bc990 100644 --- a/graphify-out/.graphify_labels.json +++ b/graphify-out/.graphify_labels.json @@ -151,6 +151,7 @@ "150": "Reviewer Subagent (opencode)", "151": "Finding Severity Rubric", "152": "Spec Compliance Review", + "156": "ResponseWriter", "161": "Why Use samber/oops", "162": "singleflight Cache Stampede Prevention", "163": "Struct Field Alignment", @@ -205,10 +206,6 @@ "245": "wayfinder map/ticket mechanism", "246": "Triage labels: canonical roles to tracker labels", "247": "Canonical triage role labels (needs-triage ... wontfix)", - "248": "Cinder (BookmarkManager Web UI design system)", - "249": "Heat is typographic: ember reserved for unread chapters", - "250": "Design tokens (dark + light branches, no hardcoded hex)", - "251": "Three type roles: display serif / mono small-caps / sans", "252": "a[aria-label='All Chapter'] priority pointer", "253": "Research: lightnovelworld chapter slug vs series slug", "254": "Gitea issue #77 (chapter vs series slug)", diff --git a/graphify-out/GRAPH_REPORT.md b/graphify-out/GRAPH_REPORT.md index edb1344..c117854 100644 --- a/graphify-out/GRAPH_REPORT.md +++ b/graphify-out/GRAPH_REPORT.md @@ -1,16 +1,16 @@ # Graph Report - mangaBookmark (2026-08-16) ## Corpus Check -- 113 files · ~279,161 words +- 116 files · ~284,419 words - Verdict: corpus is large enough that graph structure adds value. ## Summary -- 1668 nodes · 3379 edges · 222 communities (68 shown, 154 thin omitted) -- Extraction: 91% EXTRACTED · 9% INFERRED · 0% AMBIGUOUS · INFERRED: 320 edges (avg confidence: 0.77) +- 1701 nodes · 3463 edges · 219 communities (69 shown, 150 thin omitted) +- Extraction: 91% EXTRACTED · 9% INFERRED · 0% AMBIGUOUS · INFERRED: 326 edges (avg confidence: 0.77) - Token cost: 0 input · 0 output ## Graph Freshness -- Built from commit: `4f1cbcfd` +- Built from commit: `58014eb8` - Run `git rev-parse HEAD` and compare to check if the graph is stale. - Run `graphify update .` after code changes (no API cost). @@ -45,8 +45,8 @@ - [[_COMMUNITY_Find Skills Guide|Find Skills Guide]] - [[_COMMUNITY_Allocation Patterns|Allocation Patterns]] - [[_COMMUNITY_Observability & Alerting|Observability & Alerting]] +- [[_COMMUNITY_AGENTS|AGENTS.md]] - [[_COMMUNITY_Store|Store]] -- [[_COMMUNITY_Open|Open]] - [[_COMMUNITY_Go Testing Guide|Go Testing Guide]] - [[_COMMUNITY_Session Store|Session Store]] - [[_COMMUNITY_Web UI Filter Logic|Web UI Filter Logic]] @@ -166,6 +166,7 @@ - [[_COMMUNITY_Reviewer Subagent (opencode)|Reviewer Subagent (opencode)]] - [[_COMMUNITY_Finding Severity Rubric|Finding Severity Rubric]] - [[_COMMUNITY_Spec Compliance Review|Spec Compliance Review]] +- [[_COMMUNITY_ResponseWriter|ResponseWriter]] - [[_COMMUNITY_Why Use samberoops|Why Use samber/oops]] - [[_COMMUNITY_singleflight Cache Stampede Prevention|singleflight Cache Stampede Prevention]] - [[_COMMUNITY_Struct Field Alignment|Struct Field Alignment]] @@ -220,10 +221,6 @@ - [[_COMMUNITY_wayfinder mapticket mechanism|wayfinder map/ticket mechanism]] - [[_COMMUNITY_Triage labels canonical roles to tracker labels|Triage labels: canonical roles to tracker labels]] - [[_COMMUNITY_Canonical triage role labels (needs-triage ... wontfix)|Canonical triage role labels (needs-triage ... wontfix)]] -- [[_COMMUNITY_Cinder (BookmarkManager Web UI design system)|Cinder (BookmarkManager Web UI design system)]] -- [[_COMMUNITY_Heat is typographic ember reserved for unread chapters|Heat is typographic: ember reserved for unread chapters]] -- [[_COMMUNITY_Design tokens (dark + light branches, no hardcoded hex)|Design tokens (dark + light branches, no hardcoded hex)]] -- [[_COMMUNITY_Three type roles display serif mono small-caps sans|Three type roles: display serif / mono small-caps / sans]] - [[_COMMUNITY_aaria-label='All Chapter' priority pointer|a[aria-label='All Chapter'] priority pointer]] - [[_COMMUNITY_Research lightnovelworld chapter slug vs series slug|Research: lightnovelworld chapter slug vs series slug]] - [[_COMMUNITY_Gitea issue 77 (chapter vs series slug)|Gitea issue #77 (chapter vs series slug)]] @@ -238,16 +235,16 @@ - [[_COMMUNITY_Userscript CLAUDE guidance|Userscript CLAUDE guidance]] ## God Nodes (most connected - your core abstractions) -1. `testConfig()` - 53 edges -2. `newWebTestServer()` - 49 edges -3. `newTestStore()` - 48 edges -4. `newTestStore()` - 43 edges +1. `testConfig()` - 54 edges +2. `newTestStore()` - 49 edges +3. `newWebTestServer()` - 49 edges +4. `newTestStore()` - 44 edges 5. `e()` - 33 edges -6. `Open()` - 29 edges +6. `Open()` - 30 edges 7. `Handler` - 29 edges -8. `ne()` - 28 edges -9. `De()` - 28 edges -10. `Store` - 27 edges +8. `Store` - 28 edges +9. `ne()` - 28 edges +10. `De()` - 28 edges ## Surprising Connections (you probably didn't know these) - `el()` --indirect_call--> `c()` [INFERRED] @@ -267,21 +264,19 @@ ## Hyperedges (group relationships) - **Batch Ticket Implementation Pipeline** — _claude_skills_implement_tickets_skill_implement_tickets, _omp_agents_ticket_implementer_ticket_implementer, _omp_agents_ticket_implementer_cr_spec, _omp_agents_ticket_implementer_cr_standards [INFERRED 0.85] - **Subagent-Driven Development Pipeline** — _opencode_agent_implementer_implementer, _opencode_agent_reviewer_reviewer, _opencode_agent_implementer_subagent_driven_development [INFERRED 0.85] -- **Go HTML Template Family** — backend_internal_web_templates_app_doc, backend_internal_web_templates_card_doc, backend_internal_web_templates_list_doc, backend_internal_web_templates_chrome_doc, backend_internal_web_templates_login_doc, backend_internal_web_templates_setup_doc, backend_internal_web_templates_readers_doc, backend_internal_web_templates_icons_doc [INFERRED 0.95] -- **htmx Fragment Swap Flow** — backend_internal_web_templates_app_doc, backend_internal_web_templates_card_doc, backend_internal_web_templates_chrome_doc, backend_internal_web_templates_setup_doc, backend_internal_web_templates_readers_doc [INFERRED 0.95] - **Backend owns the truth (single-writer ownership of shared facts)** — docs_adr_0003_series_shared_and_poll_owned_poll_owned_writes, docs_adr_0004_wire_format_does_not_mirror_the_schema_flat_wire_contract, docs_adr_0007_backend_hosts_cover_bytes_server_side_covers [INFERRED 0.85] - **Headless browser infrastructure (sidecar, on-demand, home deployment)** — docs_adr_0005_on_demand_browser_headless_shell, docs_adr_0005_on_demand_browser_cdp, docs_adr_0005_on_demand_browser_on_demand_start, docs_adr_0006_browser_on_the_home_machine_home_machine_rationale [INFERRED 0.85] - **lightnovelworld series-identity investigation and fix** — docs_research_lightnovelworld_chapter_vs_series_slug_issue_77, docs_research_lightnovelworld_chapter_vs_series_slug_unscoped_regex, docs_adr_0008_series_identity_is_discovered_not_derived_discovered_identity [INFERRED 0.85] -## Communities (222 total, 154 thin omitted) +## Communities (219 total, 150 thin omitted) ### Community 0 - "HTMX Library Internals" Cohesion: 0.08 Nodes (101): A(), ae(), an(), at(), B(), be(), bn(), bt() (+93 more) ### Community 1 - "Cover Fetch Test Helpers" -Cohesion: 0.10 -Nodes (84): floatPtr(), testConfig(), getCover(), Cookie, Handler, ResponseRecorder, T, TestListRendersAcquiredCover() (+76 more) +Cohesion: 0.09 +Nodes (89): floatPtr(), testConfig(), getCover(), Cookie, Handler, ResponseRecorder, T, TestListRendersAcquiredCover() (+81 more) ### Community 2 - "Manga Userscript Adapters" Cohesion: 0.06 @@ -296,16 +291,16 @@ Cohesion: 0.08 Nodes (78): bookmarkNewKaganeSeries(), bookmarkNewNovelfullSeries(), bookmarkNewSeries(), Context, Store, T, newAcquirer(), readBookmark() (+70 more) ### Community 5 - "Bookmarks API Tests" -Cohesion: 0.06 -Nodes (74): auth(), getBookmarks(), Handler, Request, Store, T, newTestServer(), newTestStore() (+66 more) +Cohesion: 0.07 +Nodes (70): auth(), getBookmarks(), Handler, Request, Store, T, newTestServer(), newTestStore() (+62 more) ### Community 7 - "Cover & Acquire Internals" -Cohesion: 0.08 -Nodes (37): Addr, Context, Store, WaitGroup, isInterstitial(), defaultCoverResolver(), fetchCoverBytes(), Client (+29 more) +Cohesion: 0.07 +Nodes (43): Addr, fakeLanes, Context, Store, WaitGroup, defaultCoverResolver(), fetchCoverBytes(), Client (+35 more) ### Community 8 - "System Architecture Concepts" -Cohesion: 0.13 -Nodes (20): app.html — App Shell Template, Manga/Novel Library Switch, Bookmark Bucket Tabs, Confirm Row (Archive/Finish/Remove), card.html — Series Card Template, htmx /ui/* Mutation Endpoints, chrome.html — Out-of-Band Regions, Action Key (+12 more) +Cohesion: 0.15 +Nodes (15): Confirm Row (Archive/Finish/Remove), card.html — Series Card Template, htmx /ui/* Mutation Endpoints, chrome.html — Out-of-Band Regions, Action Key, Brand Mark SVG, Continue Reading Strip, icons.html — Icon Sprite Template (+7 more) ### Community 9 - "Session Middleware" Cohesion: 0.08 @@ -317,7 +312,7 @@ Nodes (39): Test Helpers, Test Timeout, Basic Handler Test, HTTP Handler Testing ### Community 11 - "Store Tests" Cohesion: 0.14 -Nodes (45): scanSeries(), Store, T, newTestStore(), readLatestCheckedAt(), readSeries(), secondReader(), seedForCheck() (+37 more) +Nodes (45): Store, T, newTestStore(), readLatestCheckedAt(), readSeries(), secondReader(), seedForCheck(), seedSecondReader() (+37 more) ### Community 12 - "Bookmarks API Handler" Cohesion: 0.08 @@ -332,8 +327,8 @@ Cohesion: 0.06 Nodes (33): Creating Errors, Custom Error Types, Custom types that wrap other errors, Decision table: which error strategy to use, Error Creation, Error String Conventions, Errors as Values, `errors.New` — static error messages (+25 more) ### Community 15 - "CDP Browser Client" -Cohesion: 0.06 -Nodes (56): awaitPromise(), browserConnectionLost(), classifyBrowserError(), comixRead(), comixSeriesPageURL(), Action, Context, Mutex (+48 more) +Cohesion: 0.20 +Nodes (17): applyMigration(), migrate(), Open(), refreshOwnerToken(), seedOwner(), TestCoverIsContentAddressedOnFilesystem(), TestCoverPersistsAcrossReopen(), TestMigration0002BackfillsExistingBookmarks() (+9 more) ### Community 16 - "Cloudflare bot scoring and poll cadence — what is actually documented" Cohesion: 0.06 @@ -372,8 +367,8 @@ Cohesion: 0.33 Nodes (12): coverResponse(), Request, T, TestCoverFetcherCanonicalisesJpgAlias(), TestCoverFetcherFetchesPublicHTTPSImage(), TestCoverFetcherRefusesUnsafeDestinationsBeforeRequest(), TestCoverFetcherRejectsNonImage(), TestCoverFetcherRejectsOversizedBody() (+4 more) ### Community 26 - "Open" -Cohesion: 0.40 -Nodes (5): Map of pointers for large, frequently updated structs, Memory Layout, Pointer receivers for large structs, Struct field alignment, Zero-size field at end of struct +Cohesion: 0.05 +Nodes (62): awaitPromise(), browserConnectionLost(), classifyBrowserError(), comixRead(), comixSeriesPageURL(), Action, Context, Mutex (+54 more) ### Community 27 - "Find Skills Guide" Cohesion: 0.14 @@ -387,13 +382,13 @@ Nodes (14): Allocation Patterns, Backing Array Leaks, Direct indexing vs append, Cohesion: 0.22 Nodes (9): Alerting rules (examples), CPU saturation, GC pressure, Goroutine leaks, Grafana Dashboards, Memory leaks, Prometheus Metrics for Go, PromQL Queries for Performance Diagnosis (+1 more) -### Community 31 - "Store" -Cohesion: 0.12 -Nodes (3): coverRelativePath(), coverSourceAddress(), Store +### Community 30 - "AGENTS.md" +Cohesion: 0.40 +Nodes (5): Map of pointers for large, frequently updated structs, Memory Layout, Pointer receivers for large structs, Struct field alignment, Zero-size field at end of struct -### Community 32 - "Open" -Cohesion: 0.14 -Nodes (20): applyMigration(), displayChapter(), migrate(), Open(), refreshOwnerToken(), seedOwner(), TestCoverIsContentAddressedOnFilesystem(), TestCoverPersistsAcrossReopen() (+12 more) +### Community 31 - "Store" +Cohesion: 0.08 +Nodes (8): coverRelativePath(), coverSourceAddress(), displayChapter(), Store, scanSeries(), TestDisplayChapter(), Bookmark, ReaderSummary ### Community 33 - "Go Testing Guide" Cohesion: 0.20 @@ -539,29 +534,33 @@ Nodes (3): ADR-0005: On-demand browser sidecar, Constraints, Decision Cohesion: 0.48 Nodes (6): T, TestCreateAndGetSession(), TestDeleteSessionIsPerReader(), TestDeleteSessionRevokes(), TestExpiredSessionIsGone(), TestGetSessionUnknownID() +### Community 156 - "ResponseWriter" +Cohesion: 0.18 +Nodes (13): AdminPatterns(), HandlerFunc, Request, ResponseWriter, Time, Handler, readerPathID(), since() (+5 more) + ### Community 273 - "AGENTS.md" Cohesion: 0.50 Nodes (3): Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trust), Second script: `novel-bookmark.user.js`, Userscript structure (single IIFE, `manga-bookmark.user.js`) ## Knowledge Gaps -- **524 isolated node(s):** `bookmarkmanager/backend`, `ctxKey`, `loginView`, `ctxKey`, `test` (+519 more) +- **520 isolated node(s):** `bookmarkmanager/backend`, `ctxKey`, `loginView`, `ctxKey`, `test` (+515 more) These have ≤1 connection - possible missing edges or undocumented components. -- **154 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes. +- **150 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes. ## Suggested Questions _Questions this graph is uniquely positioned to answer:_ -- **Why does `New()` connect `Series Acquisition Tests` to `Open`, `Bookmarks API Tests`, `Cover & Acquire Internals`, `Session Middleware`, `Web UI Handlers`?** - _High betweenness centrality (0.045) - this node is a cross-community bridge._ -- **Why does `Open()` connect `Open` to `Cover Fetch Test Helpers`, `Series Acquisition Tests`, `Bookmarks API Tests`, `pgtest.go`, `Store Tests`, `Store`?** - _High betweenness centrality (0.033) - this node is a cross-community bridge._ -- **Why does `newRouter()` connect `Bookmarks API Tests` to `Cover Fetch Test Helpers`, `Bookmarks API Handler`, `Series Acquisition Tests`?** - _High betweenness centrality (0.027) - this node is a cross-community bridge._ -- **Are the 47 inferred relationships involving `testConfig()` (e.g. with `TestListRendersAcquiredCover()` and `TestPublicCoverNeverEchoesNonImage()`) actually correct?** - _`testConfig()` has 47 INFERRED edges - model-reasoned connections that need verification._ -- **Are the 8 inferred relationships involving `newWebTestServer()` (e.g. with `TestListRendersAcquiredCover()` and `TestPublicCoverRejectsUnknownAddress()`) actually correct?** - _`newWebTestServer()` has 8 INFERRED edges - model-reasoned connections that need verification._ +- **Why does `Open()` connect `CDP Browser Client` to `Cover Fetch Test Helpers`, `Series Acquisition Tests`, `Bookmarks API Tests`, `pgtest.go`, `Store Tests`, `Store`?** + _High betweenness centrality (0.050) - this node is a cross-community bridge._ +- **Why does `New()` connect `Series Acquisition Tests` to `Bookmarks API Tests`, `Cover & Acquire Internals`, `Session Middleware`, `Web UI Handlers`, `CDP Browser Client`, `ResponseWriter`?** + _High betweenness centrality (0.048) - this node is a cross-community bridge._ +- **Why does `newRouter()` connect `Bookmarks API Tests` to `Cover Fetch Test Helpers`, `Bookmarks API Handler`, `Series Acquisition Tests`, `ResponseWriter`?** + _High betweenness centrality (0.031) - this node is a cross-community bridge._ +- **Are the 48 inferred relationships involving `testConfig()` (e.g. with `TestListRendersAcquiredCover()` and `TestPublicCoverNeverEchoesNonImage()`) actually correct?** + _`testConfig()` has 48 INFERRED edges - model-reasoned connections that need verification._ - **Are the 12 inferred relationships involving `newTestStore()` (e.g. with `TestAcquireDoesNotBlockTheWrite()` and `TestAcquireFailureLeavesTheBookmarkIntact()`) actually correct?** _`newTestStore()` has 12 INFERRED edges - model-reasoned connections that need verification._ +- **Are the 8 inferred relationships involving `newWebTestServer()` (e.g. with `TestListRendersAcquiredCover()` and `TestPublicCoverRejectsUnknownAddress()`) actually correct?** + _`newWebTestServer()` has 8 INFERRED edges - model-reasoned connections that need verification._ - **What connects `bookmarkmanager/backend`, `ctxKey`, `loginView` to the rest of the system?** - _557 weakly-connected nodes found - possible documentation gaps or missing edges._ \ No newline at end of file + _552 weakly-connected nodes found - possible documentation gaps or missing edges._ \ No newline at end of file diff --git a/graphify-out/graph.html b/graphify-out/graph.html index 544976f..ceb0d3e 100644 --- a/graphify-out/graph.html +++ b/graphify-out/graph.html @@ -63,12 +63,12 @@
-
1668 nodes · 3379 edges · 222 communities
+
1701 nodes · 3463 edges · 219 communities