An event_type with no template was a 400 at ingest. That reads like caution and behaves like deletion: gogobee retries a 400 to its cap and then parks the row forever, so rejecting a type Pete hadn't learned to phrase didn't defer the event, it destroyed it. companion_hire went that way. It has been emitted from `!expedition hire` since the combat-engine work landed and has never once reached the site — the game logged a successful emit every time, and the queue row simply never sent. The mitigation on the books was "always deploy Pete first", which is a thing a person has to remember rather than a property of the system. So invert it. An unknown type now warns, gets counted, and publishes on a neutral fallback. 400 is kept for facts that are actually invalid: no guid, or a name that failed the fact-guard. gogobee can ship a new event type any day of the week now; the worst case is a thin card until Pete learns the words. It is thinner than it sounds in practice. gogobee authors dispatch prose from the fact's fields with no per-type switch, so an unrecognised type still arrives with a real headline and lede and is allowed to use them. The fallback only shows through when the model is off or the prose-guard refused the output. Untemplated types never post live to Matrix, whatever tier they claim. A thin card among cards is cheap and reversible; pinging everyone in the room with a dispatch Pete couldn't phrase is neither. The daily digest still carries it, one line among many, which is the right volume for something we don't understand yet. And give companion_hire its template. Pete is the one being hired, so it is first-person like his duels — third-person Pete filling in as a cleric reads as somebody else reporting on him. The admin status page grows a "dispatches with no template" panel, so the next one of these is a to-do list Pete can see rather than an archaeology dig. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
161 lines
4.8 KiB
Go
161 lines
4.8 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
|
|
// Untemplated adventure event types seen since boot, busiest first. Admin-only:
|
|
// it names game internals, and it is a to-do list for Pete's vocabulary rather
|
|
// than anything a reader wants. Empty in the healthy case.
|
|
UnknownAdv []unknownAdvType
|
|
}
|
|
|
|
// unknownAdvType is one event type gogobee sent that Pete had no template for.
|
|
type unknownAdvType struct {
|
|
EventType string
|
|
Count int
|
|
}
|
|
|
|
// 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
|
|
})
|
|
|
|
// Untemplated dispatch types, admin-only. These publish on the neutral
|
|
// fallback rather than being rejected (see handleAdventureIngest), so nothing
|
|
// is lost by not noticing — but a type sitting here with a rising count means
|
|
// the section is carrying thin cards Pete could be writing properly.
|
|
var unknownAdv []unknownAdvType
|
|
if admin {
|
|
for t, n := range AdvUnknownTypeCounts() {
|
|
unknownAdv = append(unknownAdv, unknownAdvType{EventType: t, Count: n})
|
|
}
|
|
sort.SliceStable(unknownAdv, func(i, j int) bool {
|
|
if unknownAdv[i].Count != unknownAdv[j].Count {
|
|
return unknownAdv[i].Count > unknownAdv[j].Count
|
|
}
|
|
return unknownAdv[i].EventType < unknownAdv[j].EventType
|
|
})
|
|
}
|
|
|
|
base := s.base(r)
|
|
base.Active = "status"
|
|
s.render(w, "status", statusPage{pageData: base, Sources: rows, DegradedCnt: degraded, Admin: admin,
|
|
UnknownAdv: unknownAdv})
|
|
}
|