adventure: stop dropping dispatches Pete has no words for
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
This commit is contained in:
+120
-7
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
@@ -101,12 +102,27 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render the template first. It validates the event type, and it is the
|
// Render the template first. It is the fallback for every fact whose LLM
|
||||||
// fallback for every fact whose LLM prose is absent or fails the guard.
|
// prose is absent or fails the guard.
|
||||||
headline, lede, ok := renderAdventure(f)
|
//
|
||||||
if !ok {
|
// An event type Pete has no template for is NOT an error. It used to be a 400,
|
||||||
http.Error(w, "unknown event_type", http.StatusBadRequest)
|
// and that was the wrong call: gogobee retries a 400 to its cap and then parks
|
||||||
return
|
// the dispatch forever, so the only thing the rejection accomplished was
|
||||||
|
// deleting a real game event that Pete simply hadn't learned to phrase yet.
|
||||||
|
// `companion_hire` was dropped that way from the day it shipped, and the
|
||||||
|
// mitigation on the books ("always deploy Pete first") is a rule a human has
|
||||||
|
// to remember rather than a property of the system.
|
||||||
|
//
|
||||||
|
// So: an unknown type publishes on the neutral fallback and is counted for the
|
||||||
|
// operator. gogobee can ship a new event type any day; the worst case is a
|
||||||
|
// thin card until Pete learns the words. 400 stays for facts that are actually
|
||||||
|
// invalid — no guid, or a failed name guard.
|
||||||
|
headline, lede, known := renderAdventure(f)
|
||||||
|
if !known {
|
||||||
|
advNoteUnknownType(f.EventType)
|
||||||
|
slog.Warn("adventure ingest: no template for event_type, publishing on fallback",
|
||||||
|
"guid", f.GUID, "event_type", f.EventType)
|
||||||
|
headline, lede = advFallbackRender(f)
|
||||||
}
|
}
|
||||||
// Idempotent: a re-delivered fact (gogobee retry) is a no-op success. Checked
|
// Idempotent: a re-delivered fact (gogobee retry) is a no-op success. Checked
|
||||||
// before the prose-guard because the guard runs a board query (KnownCharacterNames);
|
// before the prose-guard because the guard runs a board query (KnownCharacterNames);
|
||||||
@@ -198,7 +214,13 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// PRIORITY beats post live to Matrix; BULLETIN beats wait for the daily
|
// PRIORITY beats post live to Matrix; BULLETIN beats wait for the daily
|
||||||
// digest. Website section always gets the row above regardless of tier.
|
// digest. Website section always gets the row above regardless of tier.
|
||||||
if f.Tier == "priority" && s.advPost != nil && s.adv.Channel != "" {
|
//
|
||||||
|
// An untemplated type never interrupts the room, whatever tier it claims.
|
||||||
|
// Publishing one to the site is cheap and reversible — a thin card among
|
||||||
|
// cards. Pinging everyone in Matrix with a dispatch Pete couldn't phrase is
|
||||||
|
// neither. It still reaches Matrix through the daily digest, one line among
|
||||||
|
// many, which is the right volume for something we don't understand yet.
|
||||||
|
if f.Tier == "priority" && known && s.advPost != nil && s.adv.Channel != "" {
|
||||||
// No ImageURL: the emblem is an SVG (Matrix clients often block SVG
|
// No ImageURL: the emblem is an SVG (Matrix clients often block SVG
|
||||||
// media), and the link's og:image carries the preview instead.
|
// media), and the link's og:image carries the preview instead.
|
||||||
// No Source: the source tag exists to credit an outlet Pete is relaying
|
// No Source: the source tag exists to credit an outlet Pete is relaying
|
||||||
@@ -254,10 +276,83 @@ func advEventMeta(eventType string) (label, emoji string) {
|
|||||||
return "The contract landed", "💀"
|
return "The contract landed", "💀"
|
||||||
case "mischief_fizzled":
|
case "mischief_fizzled":
|
||||||
return "Nobody home", "🚪"
|
return "Nobody home", "🚪"
|
||||||
|
case "companion_hire":
|
||||||
|
return "Pete tags along", "🎒"
|
||||||
}
|
}
|
||||||
return "Dispatch", "📣"
|
return "Dispatch", "📣"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// advUnknownTypes counts event types that arrived without a template, so an
|
||||||
|
// operator can see what Pete needs to learn to write. Before the unknown-type
|
||||||
|
// inversion these were 400s: gogobee retried to its cap and then parked the
|
||||||
|
// dispatch forever, which is how `companion_hire` was silently dropped for
|
||||||
|
// months. Now they publish on the neutral fallback and land here instead, where
|
||||||
|
// the admin status page can say "companion_hire ×12, still no template".
|
||||||
|
var advUnknownTypes = struct {
|
||||||
|
sync.Mutex
|
||||||
|
counts map[string]int
|
||||||
|
}{counts: map[string]int{}}
|
||||||
|
|
||||||
|
func advNoteUnknownType(eventType string) {
|
||||||
|
advUnknownTypes.Lock()
|
||||||
|
defer advUnknownTypes.Unlock()
|
||||||
|
advUnknownTypes.counts[eventType]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdvUnknownTypeCounts returns a copy of the untemplated-type tally.
|
||||||
|
func AdvUnknownTypeCounts() map[string]int {
|
||||||
|
advUnknownTypes.Lock()
|
||||||
|
defer advUnknownTypes.Unlock()
|
||||||
|
out := make(map[string]int, len(advUnknownTypes.counts))
|
||||||
|
for k, v := range advUnknownTypes.counts {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// withArticle prefixes a noun with "a"/"an". Class names are a small closed set
|
||||||
|
// from the game ("cleric", "artificer"), so first-letter vowel is enough — this
|
||||||
|
// is not trying to be a general English article engine.
|
||||||
|
func withArticle(noun string) string {
|
||||||
|
if noun == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch noun[0] {
|
||||||
|
case 'a', 'e', 'i', 'o', 'u':
|
||||||
|
return "an " + noun
|
||||||
|
}
|
||||||
|
return "a " + noun
|
||||||
|
}
|
||||||
|
|
||||||
|
// advFallbackRender is the dispatch for an event type Pete has no template for.
|
||||||
|
//
|
||||||
|
// It is deliberately thin and deliberately honest: it says something happened
|
||||||
|
// and admits the details aren't in yet, rather than guessing at semantics Pete
|
||||||
|
// doesn't have. Only guarded or game-authored fields reach it — Subject has
|
||||||
|
// already passed factGuard, and Zone is game-authored — so it carries no more
|
||||||
|
// exposure than any templated branch.
|
||||||
|
//
|
||||||
|
// In practice it is rarely what publishes. gogobee authors LLM prose for every
|
||||||
|
// fact from the fact's fields with no per-type switch (authorDispatch in
|
||||||
|
// pete_dispatch_voice.go), so an unknown type still arrives with a real headline
|
||||||
|
// and lede, and this only shows through when the model is off or the prose-guard
|
||||||
|
// rejected it.
|
||||||
|
func advFallbackRender(f AdvFact) (headline, lede string) {
|
||||||
|
const stillGetting = " I'm still getting the details on this one — I'll fill it in properly when I have them."
|
||||||
|
switch {
|
||||||
|
case f.Subject != "" && f.Zone != "":
|
||||||
|
return fmt.Sprintf("Word in about %s.", f.Subject),
|
||||||
|
fmt.Sprintf("Something happened out in %s involving %s.%s", f.Zone, f.Subject, stillGetting)
|
||||||
|
case f.Subject != "":
|
||||||
|
return fmt.Sprintf("Word in about %s.", f.Subject),
|
||||||
|
fmt.Sprintf("%s has been up to something.%s", f.Subject, stillGetting)
|
||||||
|
case f.Zone != "":
|
||||||
|
return fmt.Sprintf("Something's happened in %s.", f.Zone),
|
||||||
|
"Word just came in from the field." + stillGetting
|
||||||
|
}
|
||||||
|
return "Word in from the realm.", "Something happened out there." + stillGetting
|
||||||
|
}
|
||||||
|
|
||||||
// advArtURL is the card/OG image for a dispatch: a themed SVG emblem served by
|
// advArtURL is the card/OG image for a dispatch: a themed SVG emblem served by
|
||||||
// handleAdventureArt, keyed on event_type. Local (root-relative) so it bypasses
|
// handleAdventureArt, keyed on event_type. Local (root-relative) so it bypasses
|
||||||
// the external-image thumbnailer.
|
// the external-image thumbnailer.
|
||||||
@@ -631,6 +726,24 @@ func renderAdventure(f AdvFact) (headline, lede string, ok bool) {
|
|||||||
case "milestone":
|
case "milestone":
|
||||||
return fmt.Sprintf("%s hits %s.", f.Subject, f.Milestone),
|
return fmt.Sprintf("%s hits %s.", f.Subject, f.Milestone),
|
||||||
fmt.Sprintf("One for the books — %s just reached %s. The long road continues.", f.Subject, f.Milestone), true
|
fmt.Sprintf("One for the books — %s just reached %s. The long road continues.", f.Subject, f.Milestone), true
|
||||||
|
case "companion_hire":
|
||||||
|
// Pete himself has been hired onto somebody's expedition. He is the one
|
||||||
|
// being reported on here, so this is the one family besides his duels that
|
||||||
|
// is properly first-person. Subject is the LEADER who hired him (and whose
|
||||||
|
// opt-out therefore applies), class_race is the seat he's filling.
|
||||||
|
seat, seatArticled := strings.ToLower(f.ClassRace), ""
|
||||||
|
if seat == "" {
|
||||||
|
seat, seatArticled = "an extra pair of hands", "an extra pair of hands"
|
||||||
|
} else {
|
||||||
|
seatArticled = withArticle(seat)
|
||||||
|
}
|
||||||
|
intoZone := ""
|
||||||
|
if f.Zone != "" {
|
||||||
|
intoZone = " into " + f.Zone
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Filling in as %s for %s.", seat, f.Subject),
|
||||||
|
fmt.Sprintf("%s needed %s and I wasn't doing much, so I'm tagging along%s%s. I'll pull my weight — the reporting can wait till we're home.",
|
||||||
|
f.Subject, seatArticled, intoZone, atLevel), true
|
||||||
}
|
}
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -340,8 +340,10 @@ func TestAdventureDisabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestRenderMischief: gogobee's four mischief event types must all render. An
|
// TestRenderMischief: gogobee's four mischief event types must all render. An
|
||||||
// unknown event_type is a 400 at ingest, which gogobee retries and then parks
|
// untemplated type no longer 400s — it publishes on the neutral fallback (see
|
||||||
// forever — so "Pete deploys first" only helps if Pete actually knows the types.
|
// TestUnknownEventTypePublishes) — so what is at stake here is voice, not data
|
||||||
|
// loss: these four carry the anonymity mechanic, and the generic fallback would
|
||||||
|
// strip out the part that makes it work.
|
||||||
//
|
//
|
||||||
// It also pins the anonymity contract, which is the feature's whole social
|
// It also pins the anonymity contract, which is the feature's whole social
|
||||||
// engine: an unsigned contract must not name the buyer, and a survival must.
|
// engine: an unsigned contract must not name the buyer, and a survival must.
|
||||||
@@ -396,3 +398,142 @@ func TestRenderMischief(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRenderCompanionHire pins the template whose absence was a live bug.
|
||||||
|
//
|
||||||
|
// gogobee has emitted companion_hire from `!expedition hire` since the combat-
|
||||||
|
// engine work landed (expedition_companion_cmd.go). Pete had no case for it, so
|
||||||
|
// every one of those dispatches 400'd, retried to peteclient's cap, and parked
|
||||||
|
// forever. Nothing surfaced the loss: the game logged a successful emit, the
|
||||||
|
// queue row just never sent.
|
||||||
|
//
|
||||||
|
// The unknown-type inversion (TestUnknownEventTypePublishes) means a repeat of
|
||||||
|
// this costs a thin card rather than a deleted event — but the template is still
|
||||||
|
// the point, and this test is what says so.
|
||||||
|
func TestRenderCompanionHire(t *testing.T) {
|
||||||
|
f := AdvFact{EventType: "companion_hire", Tier: "bulletin",
|
||||||
|
Subject: "Josie", ClassRace: "Cleric", Zone: "holymachina", Level: 14}
|
||||||
|
hl, lede, ok := renderAdventure(f)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("companion_hire did not render — this is the bug, do not re-break it")
|
||||||
|
}
|
||||||
|
if !strings.Contains(hl, "cleric") {
|
||||||
|
t.Errorf("headline lost the seat Pete is filling: %q", hl)
|
||||||
|
}
|
||||||
|
if !strings.Contains(lede, "Josie") || !strings.Contains(lede, "holymachina") {
|
||||||
|
t.Errorf("lede lost the leader or the zone: %q", lede)
|
||||||
|
}
|
||||||
|
// He is talking about himself here, like his duels. Third-person Pete filling
|
||||||
|
// in as a cleric reads as someone else reporting on him.
|
||||||
|
if !strings.Contains(lede, "I'm") && !strings.Contains(lede, "I ") {
|
||||||
|
t.Errorf("companion_hire should be first-person Pete: %q", lede)
|
||||||
|
}
|
||||||
|
// "needed a cleric", never "needed cleric".
|
||||||
|
if !strings.Contains(lede, "a cleric") {
|
||||||
|
t.Errorf("seat needs its article in the lede: %q", lede)
|
||||||
|
}
|
||||||
|
if lbl, _ := advEventMeta("companion_hire"); lbl == "Dispatch" {
|
||||||
|
t.Error("companion_hire has no permalink label")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing class must not produce "needed a ." — the fallback seat carries
|
||||||
|
// its own article.
|
||||||
|
bare := AdvFact{EventType: "companion_hire", Subject: "Josie"}
|
||||||
|
_, bareLede, ok := renderAdventure(bare)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("companion_hire with no class did not render")
|
||||||
|
}
|
||||||
|
if strings.Contains(bareLede, "a .") || strings.Contains(bareLede, "needed ") {
|
||||||
|
t.Errorf("empty class produced malformed prose: %q", bareLede)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownEventTypePublishes is the regression for the whole class of bug.
|
||||||
|
//
|
||||||
|
// An event type Pete has no template for must PUBLISH, not 400. A 400 is retried
|
||||||
|
// to peteclient's cap and then parked forever, so rejecting an unrecognised type
|
||||||
|
// does not defer the event — it deletes it, permanently, and that is how
|
||||||
|
// companion_hire went missing. The site can carry a thin card; it cannot recover
|
||||||
|
// a dispatch gogobee has given up on.
|
||||||
|
func TestUnknownEventTypePublishes(t *testing.T) {
|
||||||
|
const token = "s3cret-token"
|
||||||
|
s, posted := newAdvServer(t, token)
|
||||||
|
|
||||||
|
f := AdvFact{
|
||||||
|
GUID: "brand_new_thing:abc:5000", EventType: "brand_new_thing",
|
||||||
|
Tier: "priority", // claims priority, and still must not interrupt Matrix
|
||||||
|
Subject: "Josie", Actors: []string{"Josie"}, Zone: "holymachina",
|
||||||
|
OccurredAt: 5000,
|
||||||
|
}
|
||||||
|
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||||
|
t.Fatalf("unknown event_type: status = %d, want 200 — a 400 parks the dispatch forever", rw.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := storage.GetStoryByGUID("brand_new_thing:abc:5000")
|
||||||
|
if err != nil || got == nil {
|
||||||
|
t.Fatal("unknown event_type was not stored; the event is lost")
|
||||||
|
}
|
||||||
|
if !strings.Contains(got.Headline+got.Lede, "Josie") {
|
||||||
|
t.Errorf("fallback dropped the subject: %q / %q", got.Headline, got.Lede)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Untemplated types never post live, whatever tier they claim: a thin card on
|
||||||
|
// the site is cheap, a thin ping to everyone in the room is not. It still
|
||||||
|
// reaches Matrix via the daily digest.
|
||||||
|
if len(*posted) != 0 {
|
||||||
|
t.Errorf("untemplated priority fact posted live to Matrix: %+v", *posted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the operator can see what Pete needs to learn.
|
||||||
|
if AdvUnknownTypeCounts()["brand_new_thing"] == 0 {
|
||||||
|
t.Error("unknown type was not counted for the status page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownEventTypeUsesProse: the inversion is not a downgrade in practice.
|
||||||
|
// gogobee authors LLM prose from the fact's fields with no per-type switch
|
||||||
|
// (authorDispatch), so a type Pete has never heard of still arrives with a real
|
||||||
|
// headline and lede — and must be allowed to use them. The thin fallback is only
|
||||||
|
// for when the model is off or the prose-guard rejected the output.
|
||||||
|
func TestUnknownEventTypeUsesProse(t *testing.T) {
|
||||||
|
const token = "s3cret-token"
|
||||||
|
s, _ := newAdvServer(t, token)
|
||||||
|
|
||||||
|
f := AdvFact{
|
||||||
|
GUID: "another_new_thing:def:6000", EventType: "another_new_thing",
|
||||||
|
Tier: "bulletin", Subject: "Josie", Actors: []string{"Josie"},
|
||||||
|
OccurredAt: 6000,
|
||||||
|
Headline: "Josie has taken up beekeeping.",
|
||||||
|
Lede: "Not the news I expected today, but there she is, out behind the chapel with a smoker and a very calm expression.",
|
||||||
|
}
|
||||||
|
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||||
|
t.Fatalf("status = %d, want 200", rw.Code)
|
||||||
|
}
|
||||||
|
got, err := storage.GetStoryByGUID("another_new_thing:def:6000")
|
||||||
|
if err != nil || got == nil {
|
||||||
|
t.Fatal("story not stored")
|
||||||
|
}
|
||||||
|
if got.Headline != f.Headline {
|
||||||
|
t.Errorf("LLM prose was discarded for an unknown type: got %q", got.Headline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownEventTypeStillGuarded: publishing an untemplated type must not
|
||||||
|
// weaken the name guards. The fact-guard rejection is still a 400, because a
|
||||||
|
// fact naming someone it did not authorise is genuinely invalid — unlike a type
|
||||||
|
// Pete simply hasn't learned to phrase.
|
||||||
|
func TestUnknownEventTypeStillGuarded(t *testing.T) {
|
||||||
|
const token = "s3cret-token"
|
||||||
|
s, _ := newAdvServer(t, token)
|
||||||
|
|
||||||
|
f := AdvFact{
|
||||||
|
GUID: "unknowable:evil:1", EventType: "unknowable",
|
||||||
|
Subject: "Josie", Actors: []string{"Brannigan"}, OccurredAt: 1,
|
||||||
|
}
|
||||||
|
if rw := postFact(t, s, token, f); rw.Code != 400 {
|
||||||
|
t.Errorf("unguarded subject on an unknown type: status = %d, want 400", rw.Code)
|
||||||
|
}
|
||||||
|
if storage.IsGUIDSeen("unknowable:evil:1") {
|
||||||
|
t.Error("fact-guard rejection was stored anyway")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+29
-1
@@ -50,6 +50,16 @@ type statusPage struct {
|
|||||||
Sources []sourceStatus
|
Sources []sourceStatus
|
||||||
DegradedCnt int // sources currently failing
|
DegradedCnt int // sources currently failing
|
||||||
Admin bool // viewer is an admin: show the full diagnostic columns
|
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
|
// handleStatus renders the source-health page. It's public: everyone sees a
|
||||||
@@ -126,7 +136,25 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
return rows[i].Name < rows[j].Name
|
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 := s.base(r)
|
||||||
base.Active = "status"
|
base.Active = "status"
|
||||||
s.render(w, "status", statusPage{pageData: base, Sources: rows, DegradedCnt: degraded, Admin: admin})
|
s.render(w, "status", statusPage{pageData: base, Sources: rows, DegradedCnt: degraded, Admin: admin,
|
||||||
|
UnknownAdv: unknownAdv})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{{/* Adventure dispatch types gogobee sent that Pete has no template for. These
|
||||||
|
published on the neutral fallback — nothing was dropped — so this is a
|
||||||
|
vocabulary to-do, not an incident. Admin-only; absent when there are none. */}}
|
||||||
|
{{if and $admin .UnknownAdv}}
|
||||||
|
<section class="mb-8">
|
||||||
|
<div class="rounded-3xl bg-amber-500/10 border-2 border-amber-500/30 p-6">
|
||||||
|
<h2 class="font-display text-xl font-bold">Dispatches with no template</h2>
|
||||||
|
<p class="mt-1 text-sm text-[color:var(--ink)]/70 max-w-2xl">
|
||||||
|
gogobee sent these event types since the last restart and Pete published them on the
|
||||||
|
generic fallback. Nothing was lost — they just read thin until he learns the words.
|
||||||
|
</p>
|
||||||
|
<ul class="mt-3 flex flex-wrap gap-2">
|
||||||
|
{{range .UnknownAdv}}
|
||||||
|
<li class="inline-flex items-center gap-2 rounded-full bg-[color:var(--card)] border border-amber-500/40 px-3 py-1 text-sm">
|
||||||
|
<code class="font-mono">{{.EventType}}</code>
|
||||||
|
<span class="tabular-nums text-[color:var(--ink)]/60">×{{.Count}}</span>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="overflow-x-auto rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete">
|
<div class="overflow-x-auto rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete">
|
||||||
<table class="w-full {{if $admin}}min-w-[52rem]{{else}}min-w-[28rem]{{end}} text-sm">
|
<table class="w-full {{if $admin}}min-w-[52rem]{{else}}min-w-[28rem]{{end}} text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
Reference in New Issue
Block a user