feat: status page CRUD and cache invalidation

This commit is contained in:
2026-08-24 14:15:25 +00:00
parent d192589790
commit 6f998ff506
2 changed files with 149 additions and 0 deletions
+2
View File
@@ -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 {
+147
View File
@@ -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
}