diff --git a/backend/internal/latest/sites.go b/backend/internal/latest/sites.go index 5289346..7628a8d 100644 --- a/backend/internal/latest/sites.go +++ b/backend/internal/latest/sites.go @@ -623,6 +623,12 @@ func SiteNames() []string { return names } +// BrowserBackedSites is derived from the registry: the Sites whose pages are +// read through the browser sidecar. Sorted so callers that range it (the +// browser fetcher's dispatch) see a stable order instead of map-iteration +// noise. Exported so the web layer shares the same set the poller does. +func BrowserBackedSites() []string { return browserBackedSites() } + // browserBackedSites is derived from the registry: the Sites whose pages are // read through the browser sidecar. Sorted so callers that range it (the // browser fetcher's dispatch) see a stable order instead of map-iteration diff --git a/backend/internal/web/admin_overview.go b/backend/internal/web/admin_overview.go index aa9db27..9d1ca86 100644 --- a/backend/internal/web/admin_overview.go +++ b/backend/internal/web/admin_overview.go @@ -2,8 +2,10 @@ package web import ( "fmt" + "log" "time" + "bookmarkmanager/backend/internal/latest" "bookmarkmanager/backend/internal/store" ) @@ -92,7 +94,31 @@ func (h *Handler) overviewView() (overviewView, error) { view := overviewView{Waiting: waiting(passes)} view.Unchecked = totals[store.SeriesFilterStale] + totals[store.SeriesFilterNeverChecked] - view.Verdict, view.HasCounts = overviewVerdict(passes, now) + var refusingSince map[string]int64 + if m, err := h.store.RefusingSince(now.UnixMilli()); err != nil { + log.Printf("admin overview: refusing since: %v", err) + } else { + refusingSince = m + } + var sidecarOK map[string]int64 + if m, err := h.store.SidecarOK(latest.BrowserBackedSites()); err != nil { + log.Printf("admin overview: sidecar ok: %v", err) + } else { + sidecarOK = m + } + var noChapterShare map[string]float64 + if m, err := h.store.NoChapterShare(cutoff); err != nil { + log.Printf("admin overview: no-chapter share: %v", err) + } else { + noChapterShare = m + } + faults := latest.FaultsFrom(latest.FaultInput{ + Passes: passes, + RefusingSince: refusingSince, + SidecarOK: sidecarOK, + NoChapterShare: noChapterShare, + }, now) + view.Verdict, view.HasCounts = overviewVerdict(passes, faults) // The hygiene figures, in seriesFilterOrder's tail: the problem filters // in permanent-then-fixable order, then the informational tail — finished @@ -165,28 +191,26 @@ func door(label string, count int, href string) fig { return fig{Label: label, Href: href, Count: count} } -// overviewVerdict decides the landing page's one line from the latest pass -// per Site: no passes at all is "no Lane has reported yet" — never confident -// zeroes; otherwise the count of Lanes whose last pass needs the owner, or -// "all lanes healthy". The count comes from the same laneState judgement the -// Lanes page colours on, so the two pages cannot disagree on what a fault is. -func overviewVerdict(passes []store.LanePass, now time.Time) (phrase string, counts bool) { +// overviewVerdict decides the landing page's one line: no passes at all is +// "no Lane has reported yet" — never confident zeroes; otherwise the count of +// faults from the shared FaultsFrom judgement, so the page and the push +// cannot disagree. Zero faults is "all lanes healthy". A sidecar-down fault +// carries Site == "" and is still one fault. The Lanes page's per-row +// laneState is a different question (is this Lane's last pass healthy) from +// the notice class, and its known false positives live there deliberately, so +// the two now differ. +func overviewVerdict(passes []store.LanePass, faults []latest.Fault) (phrase string, counts bool) { if len(passes) == 0 { return "no Lane has reported yet", false } - attention := 0 - for _, p := range passes { - if _, _, attn := laneState(p, now); attn { - attention++ - } - } - if attention == 0 { + n := len(faults) + if n == 0 { return "all lanes healthy", true } - if attention == 1 { + if n == 1 { return "1 lane needs a look", true } - return fmt.Sprintf("%d lanes need a look", attention), true + return fmt.Sprintf("%d lanes need a look", n), true } // waiting sums Due over the latest pass per Site: how many Series the Lanes diff --git a/backend/web_test.go b/backend/web_test.go index 816204e..d5bc62c 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -3099,7 +3099,7 @@ func TestOverviewVerdictThreeStates(t *testing.T) { }) t.Run("lanes need a look", func(t *testing.T) { - seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.UnixMilli(), Skip: latest.SkipRefusing}) + seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.UnixMilli(), Due: 1, Checked: 0, Skip: "", Refused: 0}) body := overviewBody(t, router, st) if !strings.Contains(body, "1 lane needs a look") { t.Errorf("attention verdict missing:\n%s", body) @@ -3118,6 +3118,96 @@ func TestOverviewVerdictThreeStates(t *testing.T) { }) } +// TestOverviewVerdictFromFaults mirrors the four fault inputs the notifier judges +// (stall, no-browser-route, sidecar-down, adapter-broken); plus healthy and +// virgin. Each seeds the durable rows the page reads, with no poller running. +func TestOverviewVerdictFromFaults(t *testing.T) { + t.Run("stall is a fault", func(t *testing.T) { + router, st := newWebTestServer(t, testConfig()) + now := time.Now() + seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Due: 3, Checked: 0, Skip: "", Refused: 0}) + body := overviewBody(t, router, st) + if !strings.Contains(body, "1 lane needs a look") { + t.Errorf("stall fault verdict missing:\n%s", body) + } + }) + + t.Run("no-browser-route is a fault on plain TLS site", func(t *testing.T) { + router, st := newWebTestServer(t, testConfig()) + old := time.Now().Add(-13 * time.Hour).UnixMilli() + seedPass(t, st, store.LanePass{Site: "asura", RanAt: old, Skip: latest.SkipRefusing}) + body := overviewBody(t, router, st) + if !strings.Contains(body, "1 lane needs a look") { + t.Errorf("no-browser-route fault verdict missing:\n%s", body) + } + }) + + t.Run("sidecar-down is one fault even though every browser lane is down", func(t *testing.T) { + router, st := newWebTestServer(t, testConfig()) + now := time.Now().UnixMilli() + for _, site := range []string{"comix", "kagane", "novelfull"} { + seedPass(t, st, store.LanePass{Site: site, RanAt: now, Skip: latest.SkipSidecarDown}) + } + body := overviewBody(t, router, st) + if !strings.Contains(body, "1 lane needs a look") { + t.Errorf("sidecar-down fault should count as one, want %q:\n%s", "1 lane needs a look", body) + } + if strings.Contains(body, "3 lanes need a look") { + t.Errorf("sidecar-down fault must not count lanes, got 3:\n%s", body) + } + }) + + t.Run("adapter-broken is a fault over half no_chapter", func(t *testing.T) { + st, dsn := newTestStoreURL(t) + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatalf("open %s: %v", dsn, err) + } + defer db.Close() + router := newRouter(st, testConfig()) + // Four series of the same site; three hold an old no_chapter row (>12h). + now := time.Now() + old := now.Add(-13 * time.Hour).UnixMilli() + for i := 1; i <= 4; i++ { + key := fmt.Sprintf("asura:a%d", i) + url := fmt.Sprintf("https://asurascans.com/comics/a%d", i) + seedSeriesRow(t, st, db, seriesRowSeed{key: key, url: url, cover: "aaa", checkedAt: now.UnixMilli(), latestNum: floatPtr(1), bookmarks: 1}) + } + for i := 1; i <= 3; i++ { + if err := st.RecordSeriesFailure("asura", fmt.Sprintf("a%d", i), "no_chapter", old); err != nil { + t.Fatalf("seed no_chapter failure: %v", err) + } + } + seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Due: 1, Checked: 1}) + body := overviewBody(t, router, st) + if !strings.Contains(body, "1 lane needs a look") { + t.Errorf("adapter-broken fault verdict missing:\n%s", body) + } + }) + + t.Run("healthy when no fault holds", func(t *testing.T) { + router, st := newWebTestServer(t, testConfig()) + now := time.Now() + seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Due: 1, Checked: 1}) + seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.UnixMilli(), Skip: latest.SkipAsleep, Due: 1}) + body := overviewBody(t, router, st) + if !strings.Contains(body, "all lanes healthy") { + t.Errorf("healthy verdict missing:\n%s", body) + } + }) + + t.Run("virgin still draws no figures", func(t *testing.T) { + virgin, fresh := newWebTestServer(t, testConfig()) + body := overviewBody(t, virgin, fresh) + if !strings.Contains(body, "no Lane has reported yet") { + t.Errorf("virgin verdict missing:\n%s", body) + } + if strings.Contains(body, "all lanes healthy") || strings.Contains(body, "series waiting") { + t.Errorf("virgin verdict draws confident zeroes:\n%s", body) + } + }) +} + // The Overview never refreshes itself: the Lane rest is an hour, so a timer // would re-run a cross-Series join to redraw identical rows. Only the Lanes // block carries a refresh attribute.