feat: status page CRUD and cache invalidation
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user