feat: authored status incidents and maintenance windows

This commit is contained in:
2026-08-24 14:18:06 +00:00
parent 6f998ff506
commit 161835802d
+257
View File
@@ -0,0 +1,257 @@
package services
import (
"errors"
"fmt"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
var ErrIncidentNotFound = errors.New("status incident not found")
var incidentStatuses = map[string]bool{
models.IncidentInvestigating: true,
models.IncidentIdentified: true,
models.IncidentMonitoring: true,
models.IncidentResolved: true,
}
var maintenanceStatuses = map[string]bool{
models.MaintenanceScheduled: true,
models.MaintenanceInProgress: true,
models.MaintenanceCompleted: true,
}
var impacts = map[string]bool{
models.ImpactNone: true, models.ImpactMinor: true,
models.ImpactMajor: true, models.ImpactCritical: true,
}
func validateIncident(inc *models.StatusIncident) error {
if inc.Title == "" {
return errors.New("title is required")
}
if len(inc.PageIDs) == 0 {
return errors.New("at least one page is required")
}
switch inc.Kind {
case models.StatusKindIncident:
if !incidentStatuses[inc.Status] {
return fmt.Errorf("invalid incident status %q", inc.Status)
}
case models.StatusKindMaintenance:
if !maintenanceStatuses[inc.Status] {
return fmt.Errorf("invalid maintenance status %q", inc.Status)
}
if inc.ScheduledStart == nil || inc.ScheduledEnd == nil {
return errors.New("maintenance needs a scheduled start and end")
}
if !inc.ScheduledEnd.After(*inc.ScheduledStart) {
return errors.New("maintenance must end after it starts")
}
default:
return fmt.Errorf("invalid kind %q", inc.Kind)
}
if inc.Impact == "" {
inc.Impact = models.ImpactNone
}
if !impacts[inc.Impact] {
return fmt.Errorf("invalid impact %q", inc.Impact)
}
return nil
}
func CreateStatusIncident(instanceID string, inc *models.StatusIncident) (*models.StatusIncident, error) {
if err := validateIncident(inc); err != nil {
return nil, err
}
inc.ID = bson.ObjectID{}
inc.InstanceID = instanceID
inc.IncidentID = uuid.NewString()
now := time.Now()
inc.CreatedAt = now
inc.UpdatedAt = now
if inc.StartedAt.IsZero() {
if inc.Kind == models.StatusKindMaintenance && inc.ScheduledStart != nil {
inc.StartedAt = *inc.ScheduledStart
} else {
inc.StartedAt = now
}
}
if inc.Updates == nil {
inc.Updates = []models.StatusIncidentUpdate{}
}
ctx, cancel := spCtx()
defer cancel()
if _, err := db.Col("status_incidents").InsertOne(ctx, inc); err != nil {
return nil, err
}
invalidatePages(instanceID, inc.PageIDs)
return inc, nil
}
func invalidatePages(instanceID string, pageIDs []string) {
for _, p := range pageIDs {
InvalidateStatusCache(instanceID, p)
}
}
func getIncident(instanceID, incidentID string) (*models.StatusIncident, error) {
ctx, cancel := spCtx()
defer cancel()
var inc models.StatusIncident
err := db.Col("status_incidents").
FindOne(ctx, bson.M{"instance_id": instanceID, "incident_id": incidentID}).
Decode(&inc)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrIncidentNotFound
}
if err != nil {
return nil, err
}
return &inc, nil
}
func ListStatusIncidents(instanceID, pageID string) ([]models.StatusIncident, error) {
filter := bson.M{"instance_id": instanceID}
if pageID != "" {
filter["page_ids"] = pageID
}
ctx, cancel := spCtx()
defer cancel()
cur, err := db.Col("status_incidents").Find(ctx, filter,
options.Find().SetSort(bson.M{"started_at": -1}))
if err != nil {
return nil, err
}
out := []models.StatusIncident{}
if err := cur.All(ctx, &out); err != nil {
return nil, err
}
return out, nil
}
// ListStatusIncidentsForPage is the public read's query: one page, bounded by
// the history window, so a five-year-old instance does not assemble five years
// of incidents on every cache miss.
func ListStatusIncidentsForPage(instanceID, pageID string, since time.Time) ([]models.StatusIncident, error) {
ctx, cancel := spCtx()
defer cancel()
cur, err := db.Col("status_incidents").Find(ctx, bson.M{
"instance_id": instanceID,
"page_ids": pageID,
"$or": []bson.M{
{"started_at": bson.M{"$gte": since}},
{"resolved_at": nil},
{"status": models.MaintenanceScheduled},
},
}, options.Find().SetSort(bson.M{"started_at": -1}))
if err != nil {
return nil, err
}
out := []models.StatusIncident{}
if err := cur.All(ctx, &out); err != nil {
return nil, err
}
return out, nil
}
func UpdateStatusIncident(instanceID, incidentID string, inc *models.StatusIncident) (*models.StatusIncident, error) {
existing, err := getIncident(instanceID, incidentID)
if err != nil {
return nil, err
}
inc.Kind = existing.Kind // kind is fixed at creation
if err := validateIncident(inc); err != nil {
return nil, err
}
set := bson.M{
"page_ids": inc.PageIDs,
"title": inc.Title,
"impact": inc.Impact,
"affected_monitors": inc.AffectedMonitors,
"status": inc.Status,
"scheduled_start": inc.ScheduledStart,
"scheduled_end": inc.ScheduledEnd,
"updated_at": time.Now(),
}
if inc.Status == models.IncidentResolved || inc.Status == models.MaintenanceCompleted {
if existing.ResolvedAt == nil {
now := time.Now()
set["resolved_at"] = now
}
} else {
// Reopening clears it, so a mistakenly resolved incident does not keep
// a resolution time it no longer has.
set["resolved_at"] = nil
}
ctx, cancel := spCtx()
defer cancel()
if _, err := db.Col("status_incidents").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "incident_id": incidentID},
bson.M{"$set": set}); err != nil {
return nil, err
}
// Both old and new page sets, or a page the incident was just removed from
// keeps showing it for up to 30 seconds.
invalidatePages(instanceID, existing.PageIDs)
invalidatePages(instanceID, inc.PageIDs)
return getIncident(instanceID, incidentID)
}
func AppendStatusIncidentUpdate(instanceID, incidentID, status, body, author string) (*models.StatusIncident, error) {
existing, err := getIncident(instanceID, incidentID)
if err != nil {
return nil, err
}
if body == "" {
return nil, errors.New("update body is required")
}
valid := incidentStatuses
if existing.Kind == models.StatusKindMaintenance {
valid = maintenanceStatuses
}
if !valid[status] {
return nil, fmt.Errorf("invalid status %q for a %s", status, existing.Kind)
}
upd := models.StatusIncidentUpdate{At: time.Now(), Status: status, Body: body, Author: author}
set := bson.M{"status": status, "updated_at": upd.At}
if status == models.IncidentResolved || status == models.MaintenanceCompleted {
set["resolved_at"] = upd.At
}
ctx, cancel := spCtx()
defer cancel()
if _, err := db.Col("status_incidents").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "incident_id": incidentID},
bson.M{"$push": bson.M{"updates": upd}, "$set": set}); err != nil {
return nil, err
}
invalidatePages(instanceID, existing.PageIDs)
return getIncident(instanceID, incidentID)
}
func DeleteStatusIncident(instanceID, incidentID string) error {
existing, err := getIncident(instanceID, incidentID)
if err != nil {
return err
}
ctx, cancel := spCtx()
defer cancel()
if _, err := db.Col("status_incidents").DeleteOne(ctx,
bson.M{"instance_id": instanceID, "incident_id": incidentID}); err != nil {
return err
}
invalidatePages(instanceID, existing.PageIDs)
return nil
}