feat(web,latest): pause and resume one Site's Lane with a mandatory expiry (#147)

Two owner-gated POST routes write the durable poll_lanes pause stamp the
poller's top-of-pass gate already reads: /admin/lanes/{site}/pause validates
the duration against the fixed 1h/6h/24h allow-list and the Site against the
registry, and /admin/lanes/{site}/resume zeroes the stamp. Both cap the form
body like the API path, answer with the freshly rendered Lanes block, and
never command the poller — the pause is a fact about the Site, so it
survives a restart. The Lanes page's c-ctrl slot now carries the pausebar:
Resume while paused, the duration select plus Pause while running, with the
paused phrase read from the live poll_lanes stamp so a press renders as
paused with no pass having run. Tests cover the round trips, rejections,
body caps, the pause-before-refusal ordering, fresh-poller survival, resume
restoring the full queue, and acquisition being unaffected.
This commit is contained in:
2026-08-22 01:15:57 +07:00
parent e45445cb20
commit cbe0a28921
6 changed files with 441 additions and 1 deletions
+91
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"log"
"net/http"
"slices"
"time"
"bookmarkmanager/backend/internal/latest"
@@ -50,6 +51,11 @@ type laneRow struct {
// 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.
@@ -69,6 +75,79 @@ 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
@@ -147,6 +226,7 @@ func buildLaneRow(p store.LanePass, o store.SiteOutcomes, now time.Time) laneRow
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
}
@@ -179,8 +259,19 @@ func outcomeChips(o store.SiteOutcomes) []chip {
// 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: