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 } // handleStatus renders the owner-facing source-health dashboard. Access is // restricted to admin subjects; everyone else gets a 404 so the page's // existence isn't advertised. func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { if !s.isAdmin(r) { http.NotFound(w, r) return } 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 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}) }