Files
Pete/internal/web/status.go
prosolis 71f7050f41 Add personalization, outbound feeds, and PWA/push to the web UI
A multi-session build turning Pete's read-only web UI into something people
return to. Five phases, signed-in features keyed off the OIDC subject; anonymous
visitors keep the reverse-chron feed and localStorage-only state.

Phase 1 — per-user read + bookmark state: user_story_state table +
storage/userstate.go; auth-gated /api/read, /api/bookmark, /api/state and a
/bookmarks page; reader.js syncs state server-side for signed-in users. Also
hides the Matrix-posting UI when posting.enabled=false (web-only mode).

Phase 2 — outbound feeds: storage.ListForFeed + web/feed.go hand-build RSS 2.0
(content:encoded) and JSON Feed 1.1 (no new dep); /feed.xml, /feed.json and
per-channel variants; <link rel=alternate> discovery tags.

Phase 3 — "For you" + related: storage/rank.go scores recent unread candidates
by channel/source affinity + recency decay; RelatedStories via FTS5. ForYou rail
+ /for-you page; public /api/related feeds the reader's "You might also like".

Phase 4 — source-health dashboard: source_health table + storage/sourcehealth.go
(RecordPollResult, ListSourceHealth, SourceContentStats), written by the poller;
admin-gated /status page behind web.admin_subs.

Phase 5 — PWA + offline reader + Web Push: root-scoped manifest.webmanifest and
sw.js (app-shell precache, /api/article runtime cache for offline reading,
offline fallback, push/notificationclick handlers); PNG icons from pete.avif;
pwa.js registers the SW and drives a notifications toggle. Web Push adds
webpush-go, a [web.push] config block (pete -genvapid mints VAPID keys), a
push_subscriptions table, auth-gated subscribe/unsubscribe endpoints, and a
digest sender that pings each subscriber "N new stories" past their watermark,
honoring disabled-sources and pruning gone endpoints.

Tests added beside each new storage/web file; go test ./... and go vet clean.
2026-07-07 00:07:19 -07:00

129 lines
3.3 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
}
// 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})
}