From 6f998ff50603ad605d904294d64b02aa66211e2f Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 24 Aug 2026 14:15:25 +0000 Subject: [PATCH] feat: status page CRUD and cache invalidation --- server/cmd/main.go | 2 + server/internal/services/statuspages.go | 147 ++++++++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/server/cmd/main.go b/server/cmd/main.go index 7139f61..61f1796 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -266,6 +266,8 @@ func serve() { r.Use(gin.Recovery()) r.Use(gin.LoggerWithConfig(gin.LoggerConfig{SkipPaths: []string{"/api/console/tunnel"}})) r.Use(corsMiddleware()) + services.SetStatusRedis(auth.Redis()) + api.RegisterRoutes(r) if err := api.AssertScopeMapComplete(r); err != nil { diff --git a/server/internal/services/statuspages.go b/server/internal/services/statuspages.go index 64610e7..9f8e4d4 100644 --- a/server/internal/services/statuspages.go +++ b/server/internal/services/statuspages.go @@ -3,10 +3,13 @@ package services import ( "context" "errors" + "log" "regexp" "time" "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "github.com/redis/go-redis/v9" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" @@ -67,3 +70,147 @@ func EnsureStatusPageIndexes() error { } return nil } + +var ( + ErrPageNotFound = errors.New("status page not found") + ErrPageIDTaken = errors.New("that page id is already in use") +) + +// statusRedis is set by main.go at boot. It is nil in any process that has not +// set it, and every use below treats nil as "no cache" rather than an error. +var statusRedis *redis.Client + +func SetStatusRedis(c *redis.Client) { statusRedis = c } + +// InvalidateStatusCache drops the assembled snapshot so an operator posting an +// incident update sees it immediately rather than wondering for half a minute +// whether it saved. Best effort: a stale entry expires in 30s anyway, and a +// Redis error here must not fail the write that already succeeded. +func InvalidateStatusCache(instanceID, pageID string) { + if statusRedis == nil { + return + } + ctx, cancel := spCtx() + defer cancel() + if err := statusRedis.Del(ctx, statusCacheKey(instanceID, pageID)).Err(); err != nil { + log.Printf("status cache invalidate %s/%s: %v", instanceID, pageID, err) + } +} + +func ListStatusPages(instanceID string) ([]models.StatusPage, error) { + ctx, cancel := spCtx() + defer cancel() + cur, err := db.Col("status_pages").Find(ctx, + bson.M{"instance_id": instanceID}, + options.Find().SetSort(bson.M{"created_at": 1})) + if err != nil { + return nil, err + } + pages := []models.StatusPage{} + if err := cur.All(ctx, &pages); err != nil { + return nil, err + } + return pages, nil +} + +func GetStatusPage(instanceID, pageID string) (*models.StatusPage, error) { + ctx, cancel := spCtx() + defer cancel() + var p models.StatusPage + err := db.Col("status_pages"). + FindOne(ctx, bson.M{"instance_id": instanceID, "page_id": pageID}). + Decode(&p) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, ErrPageNotFound + } + if err != nil { + return nil, err + } + return &p, nil +} + +func CreateStatusPage(instanceID string, p *models.StatusPage) (*models.StatusPage, error) { + if err := ValidatePageID(p.PageID); err != nil { + return nil, err + } + if p.Title == "" { + return nil, errors.New("title is required") + } + p.ID = bson.ObjectID{} + p.InstanceID = instanceID + p.CreatedAt = time.Now() + p.UpdatedAt = p.CreatedAt + if p.Sections == nil { + p.Sections = []models.StatusPageSection{} + } + + ctx, cancel := spCtx() + defer cancel() + res, err := db.Col("status_pages").InsertOne(ctx, p) + if mongo.IsDuplicateKeyError(err) { + // The unique index is what settles a race between two people reaching + // for one page id; a pre-check alone would not. + return nil, ErrPageIDTaken + } + if err != nil { + return nil, err + } + if oid, ok := res.InsertedID.(bson.ObjectID); ok { + p.ID = oid + } + return p, nil +} + +// UpdateStatusPage replaces the whole page document except its identity and +// creation time. Last-write-wins over one small document beats merge semantics +// between two people editing one page, the same call PUT /servers/:id/tags +// already makes. +// +// The page id itself is immutable: it is a URL that has been handed out. +func UpdateStatusPage(instanceID, pageID string, p *models.StatusPage) (*models.StatusPage, error) { + if p.Title == "" { + return nil, errors.New("title is required") + } + if p.Sections == nil { + p.Sections = []models.StatusPageSection{} + } + ctx, cancel := spCtx() + defer cancel() + res, err := db.Col("status_pages").UpdateOne(ctx, + bson.M{"instance_id": instanceID, "page_id": pageID}, + bson.M{"$set": bson.M{ + "title": p.Title, + "description": p.Description, + "logo_url": p.LogoURL, + "published": p.Published, + "banner": p.Banner, + "sections": p.Sections, + "updated_at": time.Now(), + }}) + if err != nil { + return nil, err + } + if res.MatchedCount == 0 { + return nil, ErrPageNotFound + } + InvalidateStatusCache(instanceID, pageID) + return GetStatusPage(instanceID, pageID) +} + +func DeleteStatusPage(instanceID, pageID string) error { + ctx, cancel := spCtx() + defer cancel() + res, err := db.Col("status_pages").DeleteOne(ctx, + bson.M{"instance_id": instanceID, "page_id": pageID}) + if err != nil { + return err + } + if res.DeletedCount == 0 { + return ErrPageNotFound + } + // Authored incidents keep their page_ids entry. A page deleted by mistake + // and recreated with the same id gets its incident history back, and an id + // that is never reused costs two bytes in an array. + InvalidateStatusCache(instanceID, pageID) + return nil +}