#173: page verdict shares FaultsFrom judgement so push and landing cannot disagree
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+91
-1
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user