f85908d3b4
The Series list row's fact line gains the failure word and its age in one mark before the Notes chips, spelled through outcomeWord — the one vocabulary shared with the Lanes chips. The detail page adds one sentence beside the correction control while a Reader's number stands behind a failure past the owner window, without naming the Reader. Each Lanes Site name is a plain link to that Site's failing Series; the outcome chips stay unlinked, and outcomeChips records why the promise is withdrawn.
351 lines
13 KiB
Go
351 lines
13 KiB
Go
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
|
|
// FailingHref is the one navigation the row offers: the Site's name
|
|
// links to that Site's failing Series. The chips beside it stay
|
|
// unlinked; the withdrawn promise lives on outcomeChips (issue #167).
|
|
FailingHref string
|
|
}
|
|
|
|
// 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.FailingHref = seriesListHref(store.SeriesFilterFailing, p.Site, "", 0)
|
|
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. The names are
|
|
// spelled through outcomeWord, the one vocabulary the failure surface shares
|
|
// with the Series list's fact line (issue #167). The counts stay unlinked
|
|
// permanently — a withdrawn promise: two of the six words write no per-Series
|
|
// state, and the other four count attempts inside the owner window while the
|
|
// failing filter lists Series failing now, so neither set contains the other
|
|
// and no chip can be a door to its list.
|
|
func outcomeChips(o store.SiteOutcomes) []chip {
|
|
fixed := []struct {
|
|
name string
|
|
count int
|
|
}{
|
|
{outcomeWord("refused"), o.Refused},
|
|
{outcomeWord("unreachable"), o.Unreachable},
|
|
{outcomeWord("no_chapter"), o.NoChapter},
|
|
{outcomeWord("unfetchable"), o.Unfetchable},
|
|
{outcomeWord("not_found"), o.NotFound},
|
|
{outcomeWord("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"
|
|
}
|