From 21a2d077d82f1dae9c365d63076e730b272680b4 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 24 Aug 2026 14:24:07 +0000 Subject: [PATCH] feat: cached public status snapshot with licence gate --- server/internal/services/statussnapshot.go | 165 +++++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/server/internal/services/statussnapshot.go b/server/internal/services/statussnapshot.go index 15269e4..857dd0b 100644 --- a/server/internal/services/statussnapshot.go +++ b/server/internal/services/statussnapshot.go @@ -1,10 +1,15 @@ package services import ( + "encoding/json" + "log" "sort" "time" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db" "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/license" + "go.mongodb.org/mongo-driver/v2/bson" ) // HistoryDays is the width of the public history bar. It is also the window the @@ -368,6 +373,166 @@ func componentStatus(mon models.Monitor, known, inMaintenance bool, now time.Tim } } +const statusCacheTTL = 30 * time.Second + +// PublicStatusSnapshot is the whole public read path. +// +// A missing page, an unpublished page and a page belonging to another instance +// all return ErrPageNotFound, identically. A distinct error for "exists but +// unpublished" would confirm it exists. +func PublicStatusSnapshot(instanceID, pageID string) (*StatusSnapshot, error) { + if err := ValidatePageID(pageID); err != nil { + return nil, ErrPageNotFound + } + if snap := cachedSnapshot(instanceID, pageID); snap != nil { + return snap, nil + } + + page, err := GetStatusPage(instanceID, pageID) + if err != nil { + return nil, err + } + if !page.Published { + return nil, ErrPageNotFound + } + + // The licence check answers 200 with available:false rather than 403, + // because the reader is a member of the public who can do nothing about it + // and deserves an explanation rather than a browser error. + st := GetLicenseState(instanceID) + if !st.Active() { + return &StatusSnapshot{Available: false, Reason: "licence_inactive", Title: page.Title}, nil + } + if !st.Feature(license.FeatureStatusPages) { + return &StatusSnapshot{Available: false, Reason: "feature_unavailable", Title: page.Title}, nil + } + + in, err := loadSnapshotInput(instanceID, *page) + if err != nil { + return nil, err + } + snap := assembleSnapshot(in) + storeSnapshot(instanceID, pageID, snap) + return &snap, nil +} + +func loadSnapshotInput(instanceID string, page models.StatusPage) (snapshotInput, error) { + in := snapshotInput{ + Page: page, + Monitors: map[string]models.Monitor{}, + Rollups: map[string][]models.Rollup{}, + Now: time.Now().UTC(), + } + since := in.Now.AddDate(0, 0, -HistoryDays) + + ids := []string{} + for _, sec := range page.Sections { + for _, e := range sec.Entries { + ids = append(ids, e.MonitorID) + } + } + if len(ids) == 0 { + // A page with no components still renders: title, banner and any + // authored incidents. Skipping the monitor queries avoids three + // unbounded $in lookups on an empty list. + authored, err := ListStatusIncidentsForPage(instanceID, page.PageID, since) + if err != nil { + return in, err + } + in.Authored = authored + return in, nil + } + + ctx, cancel := spCtx() + defer cancel() + + cur, err := db.Col("monitors").Find(ctx, bson.M{ + "instance_id": instanceID, + "monitor_id": bson.M{"$in": ids}, + }) + if err != nil { + return in, err + } + mons := []models.Monitor{} + if err := cur.All(ctx, &mons); err != nil { + return in, err + } + for _, m := range mons { + in.Monitors[m.MonitorID] = m + } + + rc, err := db.Col("monitor_rollups").Find(ctx, bson.M{ + "instance_id": instanceID, + "monitor_id": bson.M{"$in": ids}, + "period_start": bson.M{"$gte": since}, + }) + if err != nil { + return in, err + } + rollups := []models.Rollup{} + if err := rc.All(ctx, &rollups); err != nil { + return in, err + } + for _, r := range rollups { + in.Rollups[r.MonitorID] = append(in.Rollups[r.MonitorID], r) + } + + ic, err := db.Col("incidents").Find(ctx, bson.M{ + "instance_id": instanceID, + "monitor_id": bson.M{"$in": ids}, + "started_at": bson.M{"$gte": since}, + }) + if err != nil { + return in, err + } + auto := []models.Incident{} + if err := ic.All(ctx, &auto); err != nil { + return in, err + } + in.AutoIncidents = auto + + authored, err := ListStatusIncidentsForPage(instanceID, page.PageID, since) + if err != nil { + return in, err + } + in.Authored = authored + return in, nil +} + +// A cache miss on Redis is a cache miss, never an error: the status page must +// survive the outage it exists to report. +func cachedSnapshot(instanceID, pageID string) *StatusSnapshot { + if statusRedis == nil { + return nil + } + ctx, cancel := spCtx() + defer cancel() + raw, err := statusRedis.Get(ctx, statusCacheKey(instanceID, pageID)).Bytes() + if err != nil || len(raw) == 0 { + return nil + } + var snap StatusSnapshot + if err := json.Unmarshal(raw, &snap); err != nil { + return nil + } + return &snap +} + +func storeSnapshot(instanceID, pageID string, snap StatusSnapshot) { + if statusRedis == nil { + return + } + raw, err := json.Marshal(snap) + if err != nil { + return + } + ctx, cancel := spCtx() + defer cancel() + if err := statusRedis.Set(ctx, statusCacheKey(instanceID, pageID), raw, statusCacheTTL).Err(); err != nil { + log.Printf("status cache store %s/%s: %v", instanceID, pageID, err) + } +} + func overallState(sections []PublicSection) string { worst := PublicUp anyDown, anyMaint, anyOther := false, false, false