diff --git a/internal/api/v2/v2.go b/internal/api/v2/v2.go
index 68f205d..9ace5b4 100644
--- a/internal/api/v2/v2.go
+++ b/internal/api/v2/v2.go
@@ -330,8 +330,12 @@ func toAPIEvent(inc *db.Incident, isAuth bool) *Incident {
components[i] = int(comp.ID)
}
+ updates, latestDescription := mapEventUpdates(inc.Statuses, isAuth, inc.Status, inc.EndDate, inc.Type)
+
var description string
- if inc.Description != nil {
+ if latestDescription != "" {
+ description = latestDescription
+ } else if inc.Description != nil {
description = *inc.Description
}
@@ -343,8 +347,8 @@ func toAPIEvent(inc *db.Incident, isAuth bool) *Incident {
StartDate: *inc.StartDate,
EndDate: inc.EndDate,
System: &inc.System,
- Updates: mapEventUpdates(inc.Statuses, isAuth),
- Status: inc.Status,
+ Updates: updates,
+ Status: eventStatus(inc),
Type: inc.Type,
}
@@ -1848,23 +1852,103 @@ func PatchEventUpdateTextHandler(dbInst *db.DB, logger *zap.Logger) gin.HandlerF
}
}
-func mapEventUpdates(statuses []db.IncidentStatus, isAuth bool) []EventUpdateData {
+// mapEventUpdates maps stored status rows to the API updates array. Rows with
+// status "description" are not status entries: they are dropped from the array
+// and the latest one is returned as the event description.
+// statusDescription marks an update row that carries a description change rather
+// than a status change; the API folds it into the event description field.
+const statusDescription event.Status = "description"
+
+func mapEventUpdates(
+ statuses []db.IncidentStatus, isAuth bool, eventStatus event.Status,
+ endDate *time.Time, eventType string,
+) ([]EventUpdateData, string) {
updates := make([]EventUpdateData, 0, len(statuses))
idx := 0
+ var latestDescription string
+ // "changed" and "impact changed" are not status changes: they collapse to the
+ // previous normalized status. Seeding that with the event status keeps a
+ // leading such row from collapsing to an empty value.
+ lastStatus := normalizeStatus(eventStatus, endDate, eventType)
for _, s := range statuses {
if !isAuth && isInternalStatus(s.Status) {
continue
}
+ if s.Status == statusDescription {
+ latestDescription = s.Text
+ continue
+ }
+ status := normalizeStatus(s.Status, endDate, eventType)
+ if status == event.IncidentChanged || status == event.IncidentImpactChanged {
+ status = lastStatus
+ }
+ lastStatus = status
updates = append(updates, EventUpdateData{
ID: idx,
- Status: s.Status,
+ Status: status,
Text: s.Text,
Timestamp: s.Timestamp,
})
idx++
}
- return updates
+ return updates, latestDescription
+}
+
+// eventStatus normalizes the stored event status. "changed" and "impact changed"
+// annotate a change rather than denote a state, so they are collapsed to the
+// previous status from the update history, matching the updates sequence. It
+// walks the full history, so unlike updates[] the result does not depend on the
+// caller's visibility.
+func eventStatus(inc *db.Incident) event.Status {
+ status := normalizeStatus(inc.Status, inc.EndDate, inc.Type)
+ if status != event.IncidentChanged && status != event.IncidentImpactChanged {
+ return status
+ }
+
+ prev := status
+ for _, s := range inc.Statuses {
+ if s.Status == statusDescription {
+ continue
+ }
+ row := normalizeStatus(s.Status, inc.EndDate, inc.Type)
+ if row == event.IncidentChanged || row == event.IncidentImpactChanged {
+ row = prev
+ }
+ prev = row
+ }
+
+ return prev
+}
+
+// normalizeStatus maps non-canonical status values stored in the database to
+// their canonical form. "changed" and "impact changed" are not status changes;
+// they are collapsed to the previous status by eventStatus and mapEventUpdates.
+// OutDatedSystem without an end date is kept verbatim: it has no canonical
+// terminal status, and consumers map it to the previous status.
+func normalizeStatus(raw event.Status, endDate *time.Time, eventType string) event.Status {
+ switch raw {
+ case "analyzing":
+ return event.IncidentAnalysing
+ case "in progress":
+ return event.MaintenanceInProgress
+ case "scheduled":
+ return event.MaintenancePlanned
+ case event.OutDatedSystem:
+ if endDate == nil {
+ return raw
+ }
+ switch eventType {
+ case event.TypeIncident:
+ return event.IncidentResolved
+ case event.TypeInformation:
+ return event.InfoCompleted
+ default:
+ return event.MaintenanceCompleted
+ }
+ default:
+ return raw
+ }
}
// isInternalStatus returns true for statuses that should not be exposed to public (non-authenticated) users.
diff --git a/internal/api/v2/v2_visibility_test.go b/internal/api/v2/v2_visibility_test.go
index d029b88..67dd030 100644
--- a/internal/api/v2/v2_visibility_test.go
+++ b/internal/api/v2/v2_visibility_test.go
@@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stackmon/otc-status-dashboard/internal/db"
+ "github.com/stackmon/otc-status-dashboard/internal/event"
)
func TestMapEventUpdates_FiltersInternalStatuses(t *testing.T) {
@@ -20,27 +21,224 @@ func TestMapEventUpdates_FiltersInternalStatuses(t *testing.T) {
}
t.Run("authenticated sees all statuses", func(t *testing.T) {
- updates := mapEventUpdates(statuses, true)
+ updates, _ := mapEventUpdates(statuses, true, event.MaintenancePlanned, nil, event.TypeMaintenance)
assert.Len(t, updates, 4)
assert.Equal(t, "pending_review", string(updates[0].Status))
assert.Equal(t, "reviewed", string(updates[1].Status))
})
t.Run("unauthenticated sees only public statuses", func(t *testing.T) {
- updates := mapEventUpdates(statuses, false)
+ updates, _ := mapEventUpdates(statuses, false, event.MaintenancePlanned, nil, event.TypeMaintenance)
assert.Len(t, updates, 2)
assert.Equal(t, "planned", string(updates[0].Status))
assert.Equal(t, "in_progress", string(updates[1].Status))
})
t.Run("IDs are sequential after filtering", func(t *testing.T) {
- updates := mapEventUpdates(statuses, false)
+ updates, _ := mapEventUpdates(statuses, false, event.MaintenancePlanned, nil, event.TypeMaintenance)
for i, u := range updates {
assert.Equal(t, i, u.ID)
}
})
}
+func TestMapEventUpdates_DescriptionRows(t *testing.T) {
+ testTime := time.Now().UTC()
+
+ statuses := []db.IncidentStatus{
+ {ID: 1, Status: "planned", Text: "Planned", Timestamp: testTime},
+ {ID: 2, Status: "description", Text: "First description", Timestamp: testTime},
+ {ID: 3, Status: "in_progress", Text: "In progress", Timestamp: testTime},
+ {ID: 4, Status: "description", Text: "Latest description", Timestamp: testTime},
+ }
+
+ t.Run("description rows are removed and latest text is returned", func(t *testing.T) {
+ updates, description := mapEventUpdates(statuses, true, event.MaintenancePlanned, nil, event.TypeMaintenance)
+ assert.Len(t, updates, 2)
+ assert.Equal(t, "planned", string(updates[0].Status))
+ assert.Equal(t, "in_progress", string(updates[1].Status))
+ assert.Equal(t, "Latest description", description)
+ })
+
+ t.Run("IDs are sequential after description removal", func(t *testing.T) {
+ updates, _ := mapEventUpdates(statuses, false, event.MaintenancePlanned, nil, event.TypeMaintenance)
+ for i, u := range updates {
+ assert.Equal(t, i, u.ID)
+ }
+ })
+
+ t.Run("no description rows returns empty description", func(t *testing.T) {
+ _, description := mapEventUpdates(statuses[:1], true, event.MaintenancePlanned, nil, event.TypeMaintenance)
+ assert.Empty(t, description)
+ })
+}
+
+func TestMapEventUpdates_NormalizesStatuses(t *testing.T) {
+ testTime := time.Now().UTC()
+ endDate := testTime.Add(time.Hour)
+
+ statuses := []db.IncidentStatus{
+ {ID: 1, Status: "analyzing", Text: "Analysing", Timestamp: testTime},
+ {ID: 2, Status: "in progress", Text: "In progress", Timestamp: testTime},
+ {ID: 3, Status: "scheduled", Text: "Scheduled", Timestamp: testTime},
+ }
+
+ updates, _ := mapEventUpdates(statuses, true, event.MaintenancePlanned, &endDate, event.TypeMaintenance)
+ assert.Equal(t, "analysing", string(updates[0].Status))
+ assert.Equal(t, "in_progress", string(updates[1].Status))
+ assert.Equal(t, "planned", string(updates[2].Status))
+
+ systemStatuses := []db.IncidentStatus{
+ {ID: 1, Status: event.OutDatedSystem, Text: "Moved", Timestamp: testTime},
+ }
+ systemUpdates, _ := mapEventUpdates(systemStatuses, true, event.MaintenancePlanned, &endDate, event.TypeMaintenance)
+ assert.Equal(t, event.MaintenanceCompleted, systemUpdates[0].Status)
+}
+
+func TestEventStatus(t *testing.T) {
+ testTime := time.Now().UTC()
+ endDate := testTime.Add(time.Hour)
+ past := testTime.Add(-time.Hour)
+
+ t.Run("changed on a closed incident collapses to the previous status", func(t *testing.T) {
+ inc := &db.Incident{
+ Type: event.TypeIncident,
+ Status: event.IncidentChanged,
+ EndDate: &endDate,
+ Statuses: []db.IncidentStatus{
+ {Status: event.IncidentDetected, Timestamp: past},
+ {Status: event.IncidentResolved, Timestamp: past},
+ },
+ }
+ assert.Equal(t, event.IncidentResolved, eventStatus(inc))
+ })
+
+ t.Run("impact changed on an open incident collapses to the previous status", func(t *testing.T) {
+ inc := &db.Incident{
+ Type: event.TypeIncident,
+ Status: event.IncidentImpactChanged,
+ Statuses: []db.IncidentStatus{
+ {Status: event.IncidentDetected, Timestamp: past},
+ {Status: event.IncidentAnalysing, Timestamp: past},
+ },
+ }
+ assert.Equal(t, event.IncidentAnalysing, eventStatus(inc))
+ })
+
+ t.Run("a plain status is returned normalized", func(t *testing.T) {
+ inc := &db.Incident{
+ Type: event.TypeIncident,
+ Status: "analyzing",
+ Statuses: []db.IncidentStatus{{Status: "analyzing", Timestamp: past}},
+ }
+ assert.Equal(t, event.IncidentAnalysing, eventStatus(inc))
+ })
+
+ t.Run("SYSTEM without an end date is kept verbatim", func(t *testing.T) {
+ inc := &db.Incident{
+ Type: event.TypeIncident,
+ Status: event.OutDatedSystem,
+ Statuses: []db.IncidentStatus{{Status: event.OutDatedSystem, Timestamp: past}},
+ }
+ assert.Equal(t, event.OutDatedSystem, eventStatus(inc))
+ })
+}
+
+func TestEventStatus_MatchesLastUpdate(t *testing.T) {
+ testTime := time.Now().UTC()
+ endDate := testTime.Add(time.Hour)
+
+ statuses := []db.IncidentStatus{
+ {Status: event.IncidentDetected, Text: "detected", Timestamp: testTime},
+ {Status: event.IncidentResolved, Text: "resolved", Timestamp: testTime},
+ {Status: event.IncidentChanged, Text: "changed", Timestamp: testTime},
+ }
+ inc := &db.Incident{
+ Type: event.TypeIncident,
+ Status: event.IncidentChanged,
+ EndDate: &endDate,
+ Statuses: statuses,
+ }
+
+ updates, _ := mapEventUpdates(statuses, true, inc.Status, inc.EndDate, inc.Type)
+ assert.Equal(t, eventStatus(inc), updates[len(updates)-1].Status)
+}
+
+func TestEventStatus_DoesNotDependOnVisibility(t *testing.T) {
+ testTime := time.Now().UTC()
+
+ statuses := []db.IncidentStatus{
+ {Status: event.MaintenancePlanned, Text: "planned", Timestamp: testTime},
+ {Status: event.MaintenanceReviewed, Text: "reviewed", Timestamp: testTime},
+ }
+ inc := &db.Incident{Type: event.TypeMaintenance, Status: event.MaintenanceReviewed, Statuses: statuses}
+
+ assert.Equal(t, event.MaintenanceReviewed, eventStatus(inc))
+
+ // The unauthenticated view filters the trailing internal row, so the last
+ // visible update can differ from the view-independent event status.
+ public, _ := mapEventUpdates(statuses, false, inc.Status, inc.EndDate, inc.Type)
+ assert.Equal(t, event.MaintenancePlanned, public[len(public)-1].Status)
+}
+
+func TestMapEventUpdates_ChangedStatusesKeepPrevious(t *testing.T) {
+ testTime := time.Now().UTC()
+
+ statuses := []db.IncidentStatus{
+ {ID: 1, Status: "analysing", Text: "Analysing", Timestamp: testTime},
+ {ID: 2, Status: "impact changed", Text: "Impact changed", Timestamp: testTime},
+ {ID: 3, Status: "changed", Text: "Changed", Timestamp: testTime},
+ }
+
+ updates, _ := mapEventUpdates(statuses, true, event.IncidentAnalysing, nil, event.TypeIncident)
+ assert.Len(t, updates, 3)
+ assert.Equal(t, "analysing", string(updates[0].Status))
+ assert.Equal(t, "analysing", string(updates[1].Status))
+ assert.Equal(t, "analysing", string(updates[2].Status))
+}
+
+func TestMapEventUpdates_LeadingChangedUsesEventStatus(t *testing.T) {
+ testTime := time.Now().UTC()
+
+ statuses := []db.IncidentStatus{
+ {ID: 1, Status: "changed", Text: "Changed", Timestamp: testTime},
+ }
+
+ updates, _ := mapEventUpdates(statuses, true, event.IncidentResolved, nil, event.TypeIncident)
+ assert.Len(t, updates, 1)
+ assert.Equal(t, event.IncidentResolved, updates[0].Status)
+}
+
+func TestNormalizeStatus(t *testing.T) {
+ endDate := time.Now().UTC()
+
+ tests := []struct {
+ name string
+ raw event.Status
+ endDate *time.Time
+ eventType string
+ want event.Status
+ }{
+ {name: "analyzing to analysing", raw: "analyzing", want: event.IncidentAnalysing},
+ {name: "in progress to in_progress", raw: "in progress", want: event.MaintenanceInProgress},
+ {name: "scheduled to planned", raw: "scheduled", want: event.MaintenancePlanned},
+ {name: "SYSTEM incident with end date", raw: event.OutDatedSystem, endDate: &endDate, eventType: event.TypeIncident, want: event.IncidentResolved},
+ {name: "SYSTEM maintenance with end date", raw: event.OutDatedSystem, endDate: &endDate, eventType: event.TypeMaintenance, want: event.MaintenanceCompleted},
+ {name: "SYSTEM info with end date", raw: event.OutDatedSystem, endDate: &endDate, eventType: event.TypeInformation, want: event.InfoCompleted},
+ {name: "SYSTEM without end date passes through", raw: event.OutDatedSystem, want: event.OutDatedSystem},
+ {name: "changed passes through", raw: event.IncidentChanged, want: event.IncidentChanged},
+ {name: "impact changed passes through", raw: event.IncidentImpactChanged, want: event.IncidentImpactChanged},
+ {name: "canonical status passes through", raw: event.IncidentResolved, want: event.IncidentResolved},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := normalizeStatus(tt.raw, tt.endDate, tt.eventType)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
func TestIsCancelledWithoutPublicStatus(t *testing.T) {
testTime := time.Now().UTC()
diff --git a/tests/v2_events_test.go b/tests/v2_events_test.go
index 59c63cb..ed14954 100644
--- a/tests/v2_events_test.go
+++ b/tests/v2_events_test.go
@@ -223,7 +223,7 @@ func TestV2PostEventsHandler(t *testing.T) {
assert.Len(t, oldIncident.Updates, 3)
t.Logf("STATUS updates: %v", oldIncident.Updates)
assert.Equal(t, event.IncidentDetected, oldIncident.Updates[0].Status)
- assert.Equal(t, event.OutDatedSystem, oldIncident.Updates[1].Status)
+ assert.Equal(t, event.IncidentResolved, oldIncident.Updates[1].Status)
assert.Equal(t, event.IncidentResolved, oldIncident.Updates[2].Status)
assert.Equal(t, "The incident is detected.", oldIncident.Updates[0].Text)
assert.Equal(t, fmt.Sprintf("Cloud Container Engine (Container, EU-DE, cce) moved to Test incident creation for api V2 for components: 1, 2. Test should close previous and move components to the new.", result.Result[0].IncidentID), oldIncident.Updates[1].Text)
@@ -506,7 +506,7 @@ func TestV2PatchEventHandler(t *testing.T) {
inc = internalPatch(incID, &pData)
assert.Equal(t, startDate.Truncate(time.Microsecond), inc.StartDate)
- assert.Equal(t, event.IncidentChanged, inc.Status)
+ assert.Equal(t, event.IncidentResolved, inc.Status, "changing dates on a closed incident keeps the resolved status")
require.NotNil(t, inc.EndDate)
assert.Equal(t, endDate.Truncate(time.Microsecond), inc.EndDate.Truncate(time.Microsecond))
assert.Nil(t, inc.Version, "Version must not be exposed for non-maintenance events")
diff --git a/tests/v2_test.go b/tests/v2_test.go
index 14b4488..d94a3c6 100644
--- a/tests/v2_test.go
+++ b/tests/v2_test.go
@@ -379,7 +379,7 @@ func TestV2PostIncidentsHandler(t *testing.T) {
assert.Len(t, oldIncident.Updates, 3)
t.Logf("STATUS updates: %v", oldIncident.Updates)
assert.Equal(t, event.IncidentDetected, oldIncident.Updates[0].Status)
- assert.Equal(t, event.OutDatedSystem, oldIncident.Updates[1].Status)
+ assert.Equal(t, event.IncidentResolved, oldIncident.Updates[1].Status)
assert.Equal(t, event.IncidentResolved, oldIncident.Updates[2].Status)
assert.Equal(t, "The incident is detected.", oldIncident.Updates[0].Text)
assert.Equal(t, fmt.Sprintf("Cloud Container Engine (Container, EU-DE, cce) moved to Test incident creation for api V2 for components: 1, 2. Test should close previous and move components to the new.", result.Result[0].IncidentID), oldIncident.Updates[1].Text)
@@ -664,7 +664,7 @@ func TestV2PatchIncidentHandler(t *testing.T) {
inc = internalPatch(incID, &pData)
assert.Equal(t, startDate.Truncate(time.Microsecond), inc.StartDate)
- assert.Equal(t, event.IncidentChanged, inc.Status)
+ assert.Equal(t, event.IncidentResolved, inc.Status, "changing dates on a closed incident keeps the resolved status")
require.NotNil(t, inc.EndDate)
assert.Equal(t, endDate.Truncate(time.Microsecond), inc.EndDate.Truncate(time.Microsecond))