Files
mangaBookmark/backend/internal/web/admin_lanes.go
T
sulthan fc8a40cfc6 fix(latest,web): finish the atomic deletion and honest empty states (#145)
Review fixes on top of cb2b104:
- Delete the write-only in-memory refuseUntil map and setRefusalBackoff now
  that status.go is gone: the pass gate reads the durable stamp, so the map
  was half-deleted dead state (spec review C-1).
- An outcome-query failure no longer blanks the Lane table into the false
  'no data yet': rows render with 'none observed' chips instead (I-1).
- due-query and eligible-count skips get their own sentences instead of the
  merged 'check failed' (I-2).
- ADR-0012 constrain wording corrected and trailing newline added (M-1)
2026-08-21 20:31:50 +07:00

244 lines
8.2 KiB
Go

package web
import (
"fmt"
"log"
"net/http"
"time"
"bookmarkmanager/backend/internal/latest"
"bookmarkmanager/backend/internal/store"
)
// lanesView is the Lane status block: one row per Site's latest durable pass,
// plus the browser fact derived from that same log. No poller is consulted —
// the page answers from the database, so it is complete thirty seconds after
// a deploy (issue #145).
type lanesView struct {
Rows []laneRow
// PollerOff means latest-chapter polling is switched off in this
// deployment (LATEST_CHAPTER_POLL_ENABLED). It is a config fact, not a
// poller answering "absent": the browser line must not blame the sidecar
// when nothing polls.
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
Checked int
// Gap is the last pass's pace, or "—" when no pass has reached one yet —
// a refused Lane still reports the pace its last real pass chose, so a
// zero here would be a figure the row never measured.
Gap string
Ran string
// Chips are the named outcome counts over the owner's window, in the
// taxonomy's fixed order. Empty writes "none observed".
Chips []chip
HasChips bool
// StatePhrase is the reason this Lane declined to work: a skipped pass's
// own sentence, or the one true stall. Empty means the pass reached its
// loop and read normally. StateGood marks a healthy way to do nothing
// (paused, browser asleep, nothing eligible) rather than a fault.
StatePhrase string
StateGood bool
// Attention is the one flag the template colours on, so a Lane that
// needs the owner is found at a glance rather than read for.
Attention bool
}
// chip is one named outcome count over the owner's window.
type chip struct {
Name string
Count int
}
// adminLanes renders the page that hosts the live Lane fragment.
func (h *Handler) adminLanes(w http.ResponseWriter, r *http.Request) {
h.renderAdmin(w, adminView{Page: "lanes", 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 builds the Lane status block from the durable pass log. Both
// reads are the store's latest-per-Site projection, so the page's seam is a
// seeded row rather than a fake poller; errors degrade to the empty state and
// are logged, never shown to the owner in detail.
func (h *Handler) lanesView() lanesView {
v := lanesView{
PollerOff: !h.pollerEnabled,
BrowserConfigured: h.browserConfigured,
}
passes, err := h.store.LatestLanePasses()
if err != nil {
log.Printf("admin lanes: latest passes: %v", err)
return v
}
now := time.Now()
outcomes, err := h.store.LanePassOutcomes(now.Add(-ownerWindow).UnixMilli())
if err != nil {
// The rows are complete without the chips, so a failed outcome sum
// must not blank the table into "no data yet" — that is the confident
// wrong statement the page exists to avoid. Every row renders "none
// observed" instead, which is honest.
log.Printf("admin lanes: outcomes: %v", err)
outcomes = nil
}
bySite := make(map[string]store.SiteOutcomes, len(outcomes))
for _, o := range outcomes {
bySite[o.Site] = o
}
v.Rows = make([]laneRow, 0, len(passes))
v.BrowserReachable = browserReachable(passes, now)
for _, p := range passes {
v.Rows = append(v.Rows, buildLaneRow(p, bySite[p.Site], now))
}
return v
}
// browserReachable derives the sidecar's reachability from the pass log: a
// browser Site is down when its latest pass inside the refusal backoff is a
// sidecar loss, a missing fetcher, or an interrupted read. Only browser Sites
// ever produce those signals, so no Site registry leaks into the web layer.
// A configured browser with no such evidence reads as reachable; an unset
// BROWSER_WS_URL degrades identically to a browser that is down.
func browserReachable(passes []store.LanePass, now time.Time) bool {
backoff := latest.RefuseBackoff
for _, p := range passes {
ran := time.UnixMilli(p.RanAt)
if now.Sub(ran) >= backoff || ran.After(now) {
continue
}
if p.Skip == latest.SkipSidecarDown || p.Skip == latest.SkipNoFetcher || p.Unreachable > 0 {
return false
}
}
return true
}
// buildLaneRow turns one Site's latest pass and window outcome sums into the
// row the template prints. The skip column is the authority on why a pass did
// nothing; the outcomes render named and unlinked, because the pass row holds
// counts and never identities.
func buildLaneRow(p store.LanePass, o store.SiteOutcomes, now time.Time) laneRow {
row := laneRow{
Site: p.Site,
Due: p.Due,
Checked: p.Checked,
Gap: "—",
Ran: since(now, time.UnixMilli(p.RanAt)),
}
if p.GapMS > 0 {
row.Gap = (time.Duration(p.GapMS) * time.Millisecond).Truncate(time.Second).String()
}
row.Chips = outcomeChips(o)
row.HasChips = len(row.Chips) > 0
row.StatePhrase, row.StateGood, row.Attention = laneState(p, now)
return row
}
// outcomeChips lists a Site's nonzero window sums in the taxonomy's fixed
// order, so the chips never reorder as the window changes. None observed is
// written by the template, not drawn as a confident zero count.
func outcomeChips(o store.SiteOutcomes) []chip {
fixed := []struct {
name string
count int
}{
{"refused", o.Refused},
{"unreachable", o.Unreachable},
{"no chapter", o.NoChapter},
{"unfetchable", o.Unfetchable},
{"errors", o.Errors},
}
var out []chip
for _, f := range fixed {
if f.count > 0 {
out = append(out, chip{Name: f.name, Count: f.count})
}
}
return out
}
// laneState renders the reason a Lane's last pass did nothing, in one sentence
// per skip value with the one true stall kept apart from every Lane that
// declined and said why. Good states — a pause, a sleeping browser, nothing
// eligible — carry no Attention: the mark must stay spendable on the faults
// that actually need the owner.
func laneState(p store.LanePass, now time.Time) (phrase string, good, attention bool) {
switch p.Skip {
case latest.SkipPaused:
phrase = "paused · resumes in " + humanDuration(time.UnixMilli(p.PausedUntil).Sub(now))
good = true
case latest.SkipRefusing:
phrase = "refusing"
if until := time.UnixMilli(p.RefuseUntil); until.After(now) {
phrase += " · backs off until " + until.Format("15:04")
}
attention = true
case latest.SkipSidecarDown, latest.SkipNoFetcher:
// Known false positive shipped per spec: a sibling Lane's Chrome loss
// stamps this Site too, and the enum deliberately has no tenth value
// to separate it (issue #141). Render it as written.
phrase = "no browser"
attention = true
case latest.SkipAsleep:
phrase = "browser asleep"
good = true
case latest.SkipDueQuery:
phrase = "due query failed"
attention = true
case latest.SkipEligibleCount:
phrase = "eligible count failed"
attention = true
case latest.SkipNothingEligible:
phrase = "nothing eligible"
good = true
}
if phrase == "" && p.Due > 0 && p.Checked == 0 {
// The one true stall: the pass reached its loop, Series were waiting,
// and none were read. Every skip above is a Lane that said why.
phrase = "not checking"
attention = true
}
return phrase, good, attention
}
// humanDuration renders a positive duration compactly for a "resumes in" clue
// at the pause and refusal scales — minutes under an hour, then h and h+m.
func humanDuration(d time.Duration) string {
d = d.Round(time.Minute)
if d <= 0 {
return "soon"
}
if d < time.Hour {
return fmt.Sprintf("%dm", int(d/time.Minute))
}
h := int(d / time.Hour)
if m := int(d%time.Hour) / int(time.Minute); m == 0 {
return fmt.Sprintf("%dh", h)
} else {
return fmt.Sprintf("%dh%dm", h, m)
}
}
// 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"
}