package web import ( "log/slog" "net/http" "sort" "time" "pete/internal/storage" ) // isAdmin reports whether the request carries a signed-in session whose OIDC // subject is on the admin allowlist. False when auth is off, the allowlist is // empty, or the visitor is anonymous. func (s *Server) isAdmin(r *http.Request) bool { if s.auth == nil || len(s.adminSubs) == 0 { return false } u := s.auth.userFromRequest(r) if u == nil { return false } return s.adminSubs[u.Sub] } // sourceStatus is one row of the source-health dashboard: the configured feed // plus its persisted poll health and derived content stats. type sourceStatus struct { Name string Channel string Healthy bool // last poll succeeded (no consecutive failures) NeverRun bool // no poll recorded yet LastPollAt time.Time LastSuccessAt time.Time LastError string Failures int LastItemCount int Total int Classified int Paywalled int PaywallRate int // percent of retained stories that are gated LastSeenAt time.Time LastPostedAt time.Time } type statusPage struct { pageData Sources []sourceStatus DegradedCnt int // sources currently failing Admin bool // viewer is an admin: show the full diagnostic columns // Untemplated adventure event types seen since boot, busiest first. Admin-only: // it names game internals, and it is a to-do list for Pete's vocabulary rather // than anything a reader wants. Empty in the healthy case. UnknownAdv []unknownAdvType } // unknownAdvType is one event type gogobee sent that Pete had no template for. type unknownAdvType struct { EventType string Count int } // handleStatus renders the source-health page. It's public: everyone sees a // trimmed reader view (per-feed up/stale/idle and when each last updated), while // admins additionally get the operator diagnostics (poll cadence, item counts, // paywall rates, posting times, and raw fetch errors). func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { admin := s.isAdmin(r) s.track(r, "status") health, err := storage.ListSourceHealth() if err != nil { slog.Error("web: source health query failed", "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } stats, err := storage.SourceContentStats() if err != nil { slog.Error("web: source content stats failed", "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } rows := make([]sourceStatus, 0, len(s.sources)) degraded := 0 for _, src := range s.sources { h, hasHealth := health[src.Name] st := stats[src.Name] row := sourceStatus{ Name: src.Name, Channel: src.Channel, NeverRun: !hasHealth || h.LastPollAt == 0, LastError: h.LastError, Failures: h.ConsecutiveFailures, LastItemCount: h.LastItemCount, Total: st.Total, Classified: st.Classified, Paywalled: st.Paywalled, } row.Healthy = hasHealth && h.ConsecutiveFailures == 0 // Raw fetch errors leak feed-specific workarounds and upstream URLs, so // they stay out of the public payload entirely (not just hidden in CSS). if !admin { row.LastError = "" } if h.LastPollAt > 0 { row.LastPollAt = time.Unix(h.LastPollAt, 0) } if h.LastSuccessAt > 0 { row.LastSuccessAt = time.Unix(h.LastSuccessAt, 0) } if st.LastSeenAt > 0 { row.LastSeenAt = time.Unix(st.LastSeenAt, 0) } if st.LastPostedAt > 0 { row.LastPostedAt = time.Unix(st.LastPostedAt, 0) } if st.Total > 0 { row.PaywallRate = st.Paywalled * 100 / st.Total } if !row.NeverRun && !row.Healthy { degraded++ } rows = append(rows, row) } // Failing sources first (most consecutive failures), then healthy ones by // name, so the owner's eye lands on what needs attention. sort.SliceStable(rows, func(i, j int) bool { if rows[i].Failures != rows[j].Failures { return rows[i].Failures > rows[j].Failures } return rows[i].Name < rows[j].Name }) // Untemplated dispatch types, admin-only. These publish on the neutral // fallback rather than being rejected (see handleAdventureIngest), so nothing // is lost by not noticing — but a type sitting here with a rising count means // the section is carrying thin cards Pete could be writing properly. var unknownAdv []unknownAdvType if admin { for t, n := range AdvUnknownTypeCounts() { unknownAdv = append(unknownAdv, unknownAdvType{EventType: t, Count: n}) } sort.SliceStable(unknownAdv, func(i, j int) bool { if unknownAdv[i].Count != unknownAdv[j].Count { return unknownAdv[i].Count > unknownAdv[j].Count } return unknownAdv[i].EventType < unknownAdv[j].EventType }) } base := s.base(r) base.Active = "status" s.render(w, "status", statusPage{pageData: base, Sources: rows, DegradedCnt: degraded, Admin: admin, UnknownAdv: unknownAdv}) }