package web import ( "fmt" "log" "net/http" "slices" "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 // Paused is the live pause state — the poll_lanes stamp the pass row // joins on, still in the future — not the pass's skip: the control must // offer Resume from the moment the owner presses Pause, with no pass // having run to record it (issue #147). Paused 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()) } // pauseDurations are the offered pause lengths, by their wire value. A fixed // allow-list rather than time.ParseDuration: the unoffered value must be // refused, and a permissive parser turns the offered set into "anything Go // can read" (issue #147). var pauseDurations = map[string]time.Duration{ "1h": time.Hour, "6h": 6 * time.Hour, "24h": 24 * time.Hour, } // laneSite reads the Site a lane route names, answering the request itself // when it is not a registry Site. The path value is client-supplied, so it // is checked against the registry before it reaches the store. func laneSite(w http.ResponseWriter, r *http.Request) (string, bool) { site := r.PathValue("site") if !slices.Contains(latest.SiteNames(), site) { http.Error(w, "unknown site", http.StatusBadRequest) return "", false } return site, true } // adminLanePause writes a bounded pause for one Site and answers with the // freshly rendered Lanes block, so the figures describe the state after the // press. The pause is a fact about the Site — the Lane's next pass reads it // from the durable row, never from this process — so it survives a restart. // The owner gate is the route's, not this handler's; the body is capped like // the API path caps its bodies; the Site and the duration are validated // here, before the store sees them (issue #147). func (h *Handler) adminLanePause(w http.ResponseWriter, r *http.Request) { site, ok := laneSite(w, r) if !ok { return } r.Body = http.MaxBytesReader(w, r.Body, 1<<16) if err := r.ParseForm(); err != nil { http.Error(w, "invalid form", http.StatusBadRequest) return } d, ok := pauseDurations[r.PostFormValue("duration")] if !ok { http.Error(w, "unknown pause duration", http.StatusBadRequest) return } if err := h.store.PauseLane(site, time.Now().Add(d).UnixMilli()); err != nil { log.Printf("pause lane %s: %v", site, err) http.Error(w, "internal error", http.StatusInternalServerError) return } h.render(w, http.StatusOK, "lanes", h.lanesView()) } // adminLaneResume zeroes one Site's pause and answers with the freshly // rendered Lanes block. Resume is the reversal of a bounded pause, so it // fires instantly with no confirm row (issue #147). func (h *Handler) adminLaneResume(w http.ResponseWriter, r *http.Request) { site, ok := laneSite(w, r) if !ok { return } r.Body = http.MaxBytesReader(w, r.Body, 1<<16) if err := r.ParseForm(); err != nil { http.Error(w, "invalid form", http.StatusBadRequest) return } if err := h.store.ResumeLane(site); err != nil { log.Printf("resume lane %s: %v", site, err) http.Error(w, "internal error", http.StatusInternalServerError) return } 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) // No evidence of a lost sidecar reads as reachable, per the same rule // browserReachable applies: a store failure must not condemn the // browser. The empty table already says no Lane has recorded a pass. v.BrowserReachable = true 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) row.Paused = time.UnixMilli(p.PausedUntil).After(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}, {"not found", o.NotFound}, {"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) { // The pause phrase reads the live poll_lanes stamp the pass row joins // on, not the pass's skip: the owner's press must render as paused on // the very answer it gets, with no pass having run to record it. The // pause is a fact about the Site, and the join delivers it (issue #147). if pausedUntil := time.UnixMilli(p.PausedUntil); pausedUntil.After(now) { phrase = "paused · resumes in " + humanDuration(pausedUntil.Sub(now)) good = true return phrase, good, attention } switch p.Skip { case latest.SkipPaused: // A paused pass whose stamp has since lapsed: the Lane still // declined with a reason, so it is never the one true stall. 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" }