5f9cfbb27e
laneRow.Stalled was Due > 0 && Checked == 0, which is exactly the shape of the on-demand wake gate declining to start Chrome (ADR-0005): under five due Series and none overdue by 15m, a browser Lane reads nothing on purpose. kagane and novelfull each have one bookmarked Series, so that is their state for most of every cycle and the page marked both "not checking" — spending the one mark that means "go look" on the commonest healthy state. LaneState.Asleep is set at the wake gate, excluded from Stalled, kept out of Attention, and rendered as "browser asleep". Verified in prod: those Lanes wake on the age rule 15m past due (kagane and novelfull both polled at 14:55Z for a 14:40Z due moment, novelfull picking up Chapter 4458).
257 lines
8.9 KiB
Go
257 lines
8.9 KiB
Go
package web
|
|
|
|
import (
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"bookmarkmanager/backend/internal/latest"
|
|
"bookmarkmanager/backend/internal/store"
|
|
)
|
|
|
|
// LaneReporter is the administrative page's whole window onto the running
|
|
// poller: one snapshot of Poll Lane state, copied out of memory on request.
|
|
// The Poller satisfies it in production and a fake with fixed values satisfies
|
|
// it in tests, so the page's tests need neither a poller nor a Site.
|
|
type LaneReporter interface {
|
|
LaneStatus() latest.Status
|
|
}
|
|
|
|
// adminView is what the administrative page and the roster fragment receive.
|
|
type adminView struct {
|
|
Readers []store.ReaderSummary
|
|
// OwnerID travels with the roster so it can tell the owner's own row from
|
|
// the Readers they may act on.
|
|
OwnerID int64
|
|
Lanes lanesView
|
|
}
|
|
|
|
// lanesView is the Lane status block: one row per Site that has run, plus the
|
|
// browser fact, which is shared by the three browser Sites rather than held
|
|
// once per Site.
|
|
type lanesView struct {
|
|
Rows []laneRow
|
|
// PollerOff means no poller is running at all (disabled by config, or its
|
|
// client could not be built). The browser line must not answer "not
|
|
// configured" then: the sidecar is not the reason nothing is polled.
|
|
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
|
|
Ran string
|
|
// Checked is how many Series the last pass read. Due without Checked is a
|
|
// Lane that has stopped working; the two figures side by side are what
|
|
// separate that from a Lane with nothing to do.
|
|
Checked int
|
|
// Gap is empty when no pass has reached the pace yet, so the row omits the
|
|
// figure instead of stating a zero.
|
|
Gap string
|
|
Clamped bool
|
|
Refusing bool
|
|
// BrowserLost marks a Lane whose pages can only be read through the
|
|
// sidecar while the sidecar is unreachable — including the case where none
|
|
// is configured, which stops those Series just as completely.
|
|
BrowserLost bool
|
|
// Stalled marks a Lane with Series waiting that its last pass did not read
|
|
// — the difference between a stopped Lane and a quiet one (story 13). A
|
|
// browser Lane holding Chrome asleep under the wake thresholds is neither,
|
|
// so it carries Asleep instead and never Stalled.
|
|
Stalled bool
|
|
Asleep bool
|
|
// Attention is the one flag the template colours on, so an unhealthy Lane
|
|
// is found at a glance rather than read for.
|
|
Attention bool
|
|
}
|
|
|
|
// adminRoute pairs a route pattern with its handler so the route list and the
|
|
// gate cannot drift apart.
|
|
type adminRoute struct {
|
|
pattern string
|
|
handler http.HandlerFunc
|
|
}
|
|
|
|
// adminRoutes is every route that reaches past the acting Reader. Register
|
|
// wraps each one in requireOwner, so a new administrative route is gated by
|
|
// being listed here rather than by remembering to write a check inside it.
|
|
func (h *Handler) adminRoutes() []adminRoute {
|
|
return []adminRoute{
|
|
{"GET /admin", h.admin},
|
|
{"GET /ui/admin/lanes", h.uiLanes},
|
|
{"POST /readers/{id}/revoke", h.revokeReaderSessions},
|
|
{"POST /readers/{id}/clear-marks", h.clearReaderMarks},
|
|
}
|
|
}
|
|
|
|
// AdminPatterns names every administrative route, so one test can prove the
|
|
// owner gate covers all of them rather than one test per route. The receiver is
|
|
// nil because only the patterns are read; the bound handlers are never called.
|
|
func AdminPatterns() []string {
|
|
routes := (*Handler)(nil).adminRoutes()
|
|
out := make([]string, 0, len(routes))
|
|
for _, rt := range routes {
|
|
out = append(out, rt.pattern)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// requireOwner is the owner test, in one place, layered on the session gate: no
|
|
// session is still 401, and a signed-in Reader who is not the owner gets 404
|
|
// rather than 403 — a refusal that confirms the address exists is a refusal
|
|
// that helps whoever is probing for it.
|
|
func (h *Handler) requireOwner(next http.HandlerFunc) http.HandlerFunc {
|
|
return h.requireSession(func(w http.ResponseWriter, r *http.Request) {
|
|
if readerOf(r) != h.store.OwnerID() {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
next(w, r)
|
|
})
|
|
}
|
|
|
|
// admin renders the owner's page: the Reader roster and Poll Lane status.
|
|
func (h *Handler) admin(w http.ResponseWriter, r *http.Request) {
|
|
readers, err := h.store.Readers()
|
|
if err != nil {
|
|
log.Printf("admin: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
h.render(w, http.StatusOK, "admin", adminView{
|
|
Readers: readers,
|
|
OwnerID: h.store.OwnerID(),
|
|
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 copies the poller's snapshot into display form. A nil reporter (no
|
|
// poller running) and a poller no Lane has reported to yet are the same thing
|
|
// to the page: no data, which it must say rather than draw as confident zeroes
|
|
// — an empty page a few seconds after a restart must not read as a stopped one.
|
|
func (h *Handler) lanesView() lanesView {
|
|
if h.lanes == nil {
|
|
return lanesView{PollerOff: true}
|
|
}
|
|
snap := h.lanes.LaneStatus()
|
|
v := lanesView{
|
|
Rows: make([]laneRow, 0, len(snap.Lanes)),
|
|
BrowserConfigured: snap.BrowserConfigured,
|
|
BrowserReachable: snap.BrowserReachable,
|
|
}
|
|
now := time.Now()
|
|
for _, l := range snap.Lanes {
|
|
lost := l.Browser && !snap.BrowserReachable
|
|
// Series waiting and none read is the shape of a Lane that has stopped
|
|
// working, as distinct from one that is quiet for want of work — or one
|
|
// deliberately leaving Chrome asleep until its group gathers.
|
|
stalled := l.Due > 0 && l.Checked == 0 && !l.Asleep
|
|
gap := ""
|
|
if l.Gap > 0 {
|
|
gap = l.Gap.Truncate(time.Second).String()
|
|
}
|
|
v.Rows = append(v.Rows, laneRow{
|
|
Site: l.Site,
|
|
Due: l.Due,
|
|
Ran: since(now, l.LastRun),
|
|
Checked: l.Checked,
|
|
Gap: gap,
|
|
Clamped: l.Clamped,
|
|
Refusing: l.Refusing,
|
|
BrowserLost: lost,
|
|
Stalled: stalled,
|
|
Asleep: l.Asleep,
|
|
Attention: l.Clamped || l.Refusing || lost || stalled,
|
|
})
|
|
}
|
|
return v
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
|
|
// revokeReaderSessions logs one Reader out of every browser they are signed in
|
|
// on. The owner gate is the route's, not this handler's.
|
|
func (h *Handler) revokeReaderSessions(w http.ResponseWriter, r *http.Request) {
|
|
target, ok := readerPathID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
// The owner is not one of the Readers this endpoint reaches: revoking
|
|
// themselves would sign out the browser making the request, which is what
|
|
// logout is for. The roster hides the button; this refuses the hand-rolled
|
|
// POST behind it.
|
|
if target == h.store.OwnerID() {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if err := h.store.DeleteReaderSessions(target); err != nil {
|
|
log.Printf("revoke sessions: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
h.renderRoster(w, "revoke sessions")
|
|
}
|
|
|
|
// clearReaderMarks zeroes one Reader's Sighting counters. The guard those
|
|
// counters feed has one known false positive — a Site changing its page shape
|
|
// makes a correct adapter read a wrong high number and marks every honest
|
|
// Reader of that Site at once (issue #103) — and this is its remedy. It
|
|
// restores a privilege rather than destroying anything, so the control is
|
|
// confirmed but never wears the destruction accent.
|
|
func (h *Handler) clearReaderMarks(w http.ResponseWriter, r *http.Request) {
|
|
target, ok := readerPathID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := h.store.ClearReaderMarks(target); err != nil {
|
|
log.Printf("clear marks: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
h.renderRoster(w, "clear marks")
|
|
}
|
|
|
|
// readerPathID reads the Reader a route names, answering the request itself
|
|
// when there is nobody to act on.
|
|
func readerPathID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "bad reader id", http.StatusBadRequest)
|
|
return 0, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
// renderRoster answers an action with the whole roster, so the counts and marks
|
|
// it shows cannot describe the state before the tap.
|
|
func (h *Handler) renderRoster(w http.ResponseWriter, what string) {
|
|
readers, err := h.store.Readers()
|
|
if err != nil {
|
|
log.Printf("%s: %v", what, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
h.render(w, http.StatusOK, "readers", adminView{Readers: readers, OwnerID: h.store.OwnerID()})
|
|
}
|