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.
129 lines
3.3 KiB
Go
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})
|
|
}
|