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.
This commit is contained in:
128
internal/web/status.go
Normal file
128
internal/web/status.go
Normal file
@@ -0,0 +1,128 @@
|
||||
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})
|
||||
}
|
||||
Reference in New Issue
Block a user