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 } // 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 }) base := s.base(r) base.Active = "status" s.render(w, "status", statusPage{pageData: base, Sources: rows, DegradedCnt: degraded, Admin: admin}) }