/status was admin-only (404 for everyone else). Serve it to all: a reader view with per-feed live/idle/delayed status and last-update time, while admins additionally get poll cadence, item/story counts, paywall rates, posting times, and raw fetch errors. Error strings are stripped server-side for non-admins so feed-specific workarounds and upstream URLs never reach the public payload. Nav status link now shows for everyone.
133 lines
3.7 KiB
Go
133 lines
3.7 KiB
Go
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})
|
|
}
|