From 161835802de435051fea81b887f2a0efedaca9aa Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 24 Aug 2026 14:18:06 +0000 Subject: [PATCH] feat: authored status incidents and maintenance windows --- server/internal/services/statusincidents.go | 257 ++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 server/internal/services/statusincidents.go diff --git a/server/internal/services/statusincidents.go b/server/internal/services/statusincidents.go new file mode 100644 index 0000000..13a636c --- /dev/null +++ b/server/internal/services/statusincidents.go @@ -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 +}