Make source-health page public with a trimmed reader view
/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.
This commit is contained in:
@@ -142,6 +142,11 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
mux.HandleFunc("GET /"+ch.Slug+"/feed.xml", func(w http.ResponseWriter, r *http.Request) { s.handleFeedXML(w, r, ch.Slug) })
|
||||
mux.HandleFunc("GET /"+ch.Slug+"/feed.json", func(w http.ResponseWriter, r *http.Request) { s.handleFeedJSON(w, r, ch.Slug) })
|
||||
}
|
||||
// Public source-health page. The handler renders a trimmed reader view for
|
||||
// everyone and the full diagnostic view only for admins, so it lives outside
|
||||
// the auth block.
|
||||
mux.HandleFunc("GET /status", s.handleStatus)
|
||||
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
@@ -158,7 +163,6 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
mux.HandleFunc("GET /api/state", s.handleState)
|
||||
mux.HandleFunc("GET /bookmarks", s.handleBookmarks)
|
||||
mux.HandleFunc("GET /for-you", s.handleForYou)
|
||||
mux.HandleFunc("GET /status", s.handleStatus)
|
||||
if s.cfg.Push.Enabled {
|
||||
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
||||
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
|
||||
|
||||
@@ -48,17 +48,16 @@ type sourceStatus struct {
|
||||
type statusPage struct {
|
||||
pageData
|
||||
Sources []sourceStatus
|
||||
DegradedCnt int // sources currently failing
|
||||
DegradedCnt int // sources currently failing
|
||||
Admin bool // viewer is an admin: show the full diagnostic columns
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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) {
|
||||
if !s.isAdmin(r) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
admin := s.isAdmin(r)
|
||||
s.track(r, "status")
|
||||
|
||||
health, err := storage.ListSourceHealth()
|
||||
@@ -92,6 +91,11 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
@@ -124,5 +128,5 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
base := s.base(r)
|
||||
base.Active = "status"
|
||||
s.render(w, "status", statusPage{pageData: base, Sources: rows, DegradedCnt: degraded})
|
||||
s.render(w, "status", statusPage{pageData: base, Sources: rows, DegradedCnt: degraded, Admin: admin})
|
||||
}
|
||||
|
||||
@@ -13,26 +13,47 @@ func TestStatusTemplateExecutes(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := statusPage{
|
||||
pageData: pageData{SiteTitle: "Pete", Channels: channels},
|
||||
DegradedCnt: 1,
|
||||
Sources: []sourceStatus{
|
||||
{Name: "Broken Feed", Channel: "tech", Failures: 3, LastError: "dial tcp: timeout",
|
||||
LastPollAt: time.Now(), NeverRun: false, Healthy: false, Total: 4, Paywalled: 2, PaywallRate: 50},
|
||||
{Name: "Good Feed", Channel: "eu", Healthy: true, LastPollAt: time.Now(),
|
||||
LastSuccessAt: time.Now(), LastItemCount: 12, Total: 30, Classified: 28,
|
||||
LastSeenAt: time.Now(), LastPostedAt: time.Now()},
|
||||
{Name: "Idle Feed", Channel: "music", NeverRun: true},
|
||||
},
|
||||
sources := []sourceStatus{
|
||||
{Name: "Broken Feed", Channel: "tech", Failures: 3, LastError: "dial tcp: timeout",
|
||||
LastPollAt: time.Now(), NeverRun: false, Healthy: false, Total: 4, Paywalled: 2, PaywallRate: 50},
|
||||
{Name: "Good Feed", Channel: "eu", Healthy: true, LastPollAt: time.Now(),
|
||||
LastSuccessAt: time.Now(), LastItemCount: 12, Total: 30, Classified: 28,
|
||||
LastSeenAt: time.Now(), LastPostedAt: time.Now()},
|
||||
{Name: "Idle Feed", Channel: "music", NeverRun: true},
|
||||
}
|
||||
var b strings.Builder
|
||||
if err := s.tpls["status"].ExecuteTemplate(&b, "layout", data); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
render := func(admin bool) string {
|
||||
data := statusPage{
|
||||
pageData: pageData{SiteTitle: "Pete", Channels: channels},
|
||||
DegradedCnt: 1,
|
||||
Admin: admin,
|
||||
Sources: sources,
|
||||
}
|
||||
var b strings.Builder
|
||||
if err := s.tpls["status"].ExecuteTemplate(&b, "layout", data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
out := b.String()
|
||||
|
||||
// Admin view: every feed plus the operator diagnostics and raw errors.
|
||||
admin := render(true)
|
||||
for _, want := range []string{"Broken Feed", "Good Feed", "dial tcp: timeout", "1 degraded", "Source health"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("rendered status page missing %q", want)
|
||||
if !strings.Contains(admin, want) {
|
||||
t.Errorf("admin status page missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// Public view: same feeds and states, but no error strings or ops columns.
|
||||
pub := render(false)
|
||||
for _, want := range []string{"Broken Feed", "Good Feed", "Idle Feed", "delayed", "Source health"} {
|
||||
if !strings.Contains(pub, want) {
|
||||
t.Errorf("public status page missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, leak := range []string{"dial tcp: timeout", "degraded", "Last posted", "Paywall"} {
|
||||
if strings.Contains(pub, leak) {
|
||||
t.Errorf("public status page leaked operator detail %q", leak)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,15 @@
|
||||
</button>
|
||||
<span data-aqi-chip
|
||||
class="hidden shrink-0 items-center gap-1.5 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 tabular-nums"></span>
|
||||
<a href="/status" data-status-link
|
||||
title="Source health"
|
||||
class="inline-flex shrink-0 items-center justify-center rounded-full bg-[color:var(--card)] p-2 shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition{{if eq .Active "status"}} bg-[color:var(--accent)]/20{{end}}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
|
||||
<path d="M3 12h4l2 6 4-14 2 8h6"></path>
|
||||
</svg>
|
||||
<span class="sr-only">Source health</span>
|
||||
</a>
|
||||
{{if .AuthEnabled}}
|
||||
{{if .User}}
|
||||
<a href="/bookmarks" data-bookmarks-link
|
||||
@@ -99,17 +108,6 @@
|
||||
</svg>
|
||||
<span class="sr-only">Bookmarks</span>
|
||||
</a>
|
||||
{{if .IsAdmin}}
|
||||
<a href="/status" data-status-link
|
||||
title="Source health"
|
||||
class="inline-flex shrink-0 items-center justify-center rounded-full bg-[color:var(--card)] p-2 shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition{{if eq .Active "status"}} bg-[color:var(--accent)]/20{{end}}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
|
||||
<path d="M3 12h4l2 6 4-14 2 8h6"></path>
|
||||
</svg>
|
||||
<span class="sr-only">Source health</span>
|
||||
</a>
|
||||
{{end}}
|
||||
<a href="/auth/logout" data-account
|
||||
title="Signed in as {{.User.Display}}{{if .User.Email}} · {{.User.Email}}{{end}} — sign out"
|
||||
class="inline-flex shrink-0 items-center gap-2 rounded-full bg-[color:var(--card)] px-2.5 py-1.5 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
{{define "title"}}Source health — {{.SiteTitle}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
{{$admin := .Admin}}
|
||||
<section class="mt-2 mb-8">
|
||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 sm:p-8 shadow-pete">
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-[color:var(--ink)]/50">owner view</p>
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-[color:var(--ink)]/50">{{if $admin}}owner view{{else}}live status{{end}}</p>
|
||||
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-1">Source health</h1>
|
||||
<p class="mt-2 max-w-2xl text-[color:var(--ink)]/70">
|
||||
Per-feed poll status and content stats. Rows that are currently failing float to the top.
|
||||
{{if $admin}}Per-feed poll status and content stats. Rows that are currently failing float to the top.
|
||||
{{else}}Every feed Pete follows and whether it's still updating. This runs on a best-effort basis, so a source going quiet for a bit is normal.{{end}}
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-[color:var(--ink)]/5 px-3 py-1 text-sm font-semibold tabular-nums">
|
||||
@@ -14,11 +16,11 @@
|
||||
</span>
|
||||
{{if .DegradedCnt}}
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-red-500/15 text-red-700 dark:text-red-300 px-3 py-1 text-sm font-semibold tabular-nums">
|
||||
⚠ {{.DegradedCnt}} degraded
|
||||
⚠ {{.DegradedCnt}} {{if $admin}}degraded{{else}}delayed{{end}}
|
||||
</span>
|
||||
{{else}}
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 px-3 py-1 text-sm font-semibold">
|
||||
✓ all healthy
|
||||
✓ all updating
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
@@ -26,19 +28,21 @@
|
||||
</section>
|
||||
|
||||
<div class="overflow-x-auto rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete">
|
||||
<table class="w-full min-w-[52rem] text-sm">
|
||||
<table class="w-full {{if $admin}}min-w-[52rem]{{else}}min-w-[28rem]{{end}} text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-[color:var(--ink)]/50 uppercase text-xs tracking-wider border-b-2 border-[color:var(--ink)]/10">
|
||||
<th class="px-4 py-3 font-semibold">Source</th>
|
||||
<th class="px-4 py-3 font-semibold">Channel</th>
|
||||
<th class="px-4 py-3 font-semibold">Status</th>
|
||||
<th class="px-4 py-3 font-semibold">Last update</th>
|
||||
{{if $admin}}
|
||||
<th class="px-4 py-3 font-semibold">Last poll</th>
|
||||
<th class="px-4 py-3 font-semibold">Last success</th>
|
||||
<th class="px-4 py-3 font-semibold text-right">Items</th>
|
||||
<th class="px-4 py-3 font-semibold text-right">Stories</th>
|
||||
<th class="px-4 py-3 font-semibold text-right">Paywall</th>
|
||||
<th class="px-4 py-3 font-semibold">Last story</th>
|
||||
<th class="px-4 py-3 font-semibold">Last posted</th>
|
||||
{{end}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -50,14 +54,17 @@
|
||||
{{if .NeverRun}}
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-[color:var(--ink)]/10 px-2.5 py-1 text-xs font-semibold">◌ idle</span>
|
||||
{{else if .Healthy}}
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 px-2.5 py-1 text-xs font-semibold">● ok</span>
|
||||
{{else}}
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 px-2.5 py-1 text-xs font-semibold">● live</span>
|
||||
{{else if $admin}}
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-red-500/15 text-red-700 dark:text-red-300 px-2.5 py-1 text-xs font-semibold"
|
||||
title="{{.LastError}}">✕ {{.Failures}} fail{{if ne .Failures 1}}s{{end}}</span>
|
||||
{{else}}
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-red-500/15 text-red-700 dark:text-red-300 px-2.5 py-1 text-xs font-semibold">✕ delayed</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-[color:var(--ink)]/70 whitespace-nowrap">{{if .LastPollAt.IsZero}}never{{else}}{{timeAgo .LastPollAt}}{{end}}</td>
|
||||
<td class="px-4 py-3 text-[color:var(--ink)]/70 whitespace-nowrap">{{if .LastSuccessAt.IsZero}}never{{else}}{{timeAgo .LastSuccessAt}}{{end}}</td>
|
||||
{{if $admin}}
|
||||
<td class="px-4 py-3 text-[color:var(--ink)]/70 whitespace-nowrap">{{if .LastPollAt.IsZero}}never{{else}}{{timeAgo .LastPollAt}}{{end}}</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums">{{.LastItemCount}}</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums" title="{{.Classified}} classified of {{.Total}}">{{.Total}}</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums {{if gt .PaywallRate 50}}text-amber-600 dark:text-amber-400 font-semibold{{end}}">
|
||||
@@ -65,14 +72,15 @@
|
||||
</td>
|
||||
<td class="px-4 py-3 text-[color:var(--ink)]/70 whitespace-nowrap">{{if .LastSeenAt.IsZero}}—{{else}}{{timeAgo .LastSeenAt}}{{end}}</td>
|
||||
<td class="px-4 py-3 text-[color:var(--ink)]/70 whitespace-nowrap">{{if .LastPostedAt.IsZero}}—{{else}}{{timeAgo .LastPostedAt}}{{end}}</td>
|
||||
{{end}}
|
||||
</tr>
|
||||
{{if .LastError}}{{if not .Healthy}}
|
||||
{{if $admin}}{{if .LastError}}{{if not .Healthy}}
|
||||
<tr class="border-b border-[color:var(--ink)]/5">
|
||||
<td colspan="10" class="px-4 pb-3 -mt-1 text-xs text-red-600 dark:text-red-400 font-mono break-all">{{.LastError}}</td>
|
||||
</tr>
|
||||
{{end}}{{end}}
|
||||
{{end}}{{end}}{{end}}
|
||||
{{else}}
|
||||
<tr><td colspan="10" class="px-4 py-8 text-center text-[color:var(--ink)]/50">No sources configured.</td></tr>
|
||||
<tr><td colspan="{{if $admin}}10{{else}}4{{end}}" class="px-4 py-8 text-center text-[color:var(--ink)]/50">No sources configured.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user