feat: cached public status snapshot with licence gate
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user