Add complete mock alert timelines

This commit is contained in:
rcourtman
2026-08-27 22:04:21 +01:00
parent 386041e46a
commit 8ade613dfd
10 changed files with 570 additions and 12 deletions
+36 -12
View File
@@ -793,12 +793,6 @@ func (h *AlertHandlers) GetAlertIncidentTimeline(w http.ResponseWriter, r *http.
return
}
store := h.getMonitor(r.Context()).GetIncidentStore()
if store == nil {
http.Error(w, "Incident store unavailable", http.StatusServiceUnavailable)
return
}
query := r.URL.Query()
alertID := strings.TrimSpace(query.Get("alertIdentifier"))
resourceID := strings.TrimSpace(query.Get("resource_id"))
@@ -827,6 +821,18 @@ func (h *AlertHandlers) GetAlertIncidentTimeline(w http.ResponseWriter, r *http.
}
startedAt = parsed
}
if mockIncident := mock.GetMockAlertIncidentTimeline(alertID, startedAt); mockIncident != nil {
if err := utils.WriteJSONResponse(w, exportIncident(mockIncident)); err != nil {
log.Error().Err(err).Msg("Failed to write mock incident timeline response")
}
return
}
store := h.getMonitor(r.Context()).GetIncidentStore()
if store == nil {
http.Error(w, "Incident store unavailable", http.StatusServiceUnavailable)
return
}
var incident *memory.Incident
if !startedAt.IsZero() {
@@ -850,6 +856,18 @@ func (h *AlertHandlers) GetAlertIncidentTimeline(w http.ResponseWriter, r *http.
http.Error(w, "Invalid resource ID", http.StatusBadRequest)
return
}
if mock.IsMockEnabled() {
incidents := mock.GetMockAlertIncidentsForResource(resourceID, limit)
if err := utils.WriteJSONResponse(w, exportIncidents(incidents)); err != nil {
log.Error().Err(err).Msg("Failed to write mock incident list response")
}
return
}
store := h.getMonitor(r.Context()).GetIncidentStore()
if store == nil {
http.Error(w, "Incident store unavailable", http.StatusServiceUnavailable)
return
}
incidents := store.ListIncidentsByResource(resourceID, limit)
if err := utils.WriteJSONResponse(w, exportIncidents(incidents)); err != nil {
log.Error().Err(err).Msg("Failed to write incident list response")
@@ -912,12 +930,6 @@ func (h *AlertHandlers) SaveAlertIncidentNote(w http.ResponseWriter, r *http.Req
return
}
store := h.getMonitor(r.Context()).GetIncidentStore()
if store == nil {
http.Error(w, "Incident store unavailable", http.StatusServiceUnavailable)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 8*1024)
var req struct {
AlertIdentifier string `json:"alertIdentifier"`
@@ -949,6 +961,18 @@ func (h *AlertHandlers) SaveAlertIncidentNote(w http.ResponseWriter, r *http.Req
http.Error(w, "note is required", http.StatusBadRequest)
return
}
if mock.AddMockAlertIncidentNote(alertIdentifier, req.IncidentID, req.Note, req.User) {
if err := utils.WriteJSONResponse(w, map[string]interface{}{"success": true}); err != nil {
log.Error().Err(err).Msg("Failed to write mock incident note response")
}
return
}
store := h.getMonitor(r.Context()).GetIncidentStore()
if store == nil {
http.Error(w, "Incident store unavailable", http.StatusServiceUnavailable)
return
}
if ok := store.RecordNote(alertIdentifier, req.IncidentID, req.Note, req.User); !ok {
http.Error(w, "Failed to save note", http.StatusBadRequest)
+68
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
@@ -16,6 +17,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/ai/memory"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts/eventlog"
pulsemock "github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
@@ -764,6 +766,62 @@ func TestGetAlertIncidentTimeline_ListExportsCanonicalAlertIdentifier(t *testing
assert.Equal(t, "canonical:a1", incidents[0]["alertIdentifier"])
}
func TestMockAlertIncidentTimelineSupportsAlertResourceAndNoteWorkflows(t *testing.T) {
previous := pulsemock.IsMockEnabled()
if err := pulsemock.SetEnabled(true); err != nil {
t.Fatalf("enable mock fixtures: %v", err)
}
t.Cleanup(func() {
if err := pulsemock.SetEnabled(previous); err != nil {
t.Errorf("restore mock fixtures: %v", err)
}
})
history := pulsemock.GetMockAlertHistory(1)
if len(history) != 1 {
t.Fatalf("mock alert history returned %d rows, want 1", len(history))
}
alert := history[0]
mockMonitor := new(MockAlertMonitor)
h := NewAlertHandlers(nil, mockMonitor, nil)
alertURL := "/api/alerts/incidents?alertIdentifier=" + url.QueryEscape(alert.ID) + "&started_at=" + url.QueryEscape(alert.StartTime.Format(time.RFC3339))
w := httptest.NewRecorder()
h.GetAlertIncidentTimeline(w, httptest.NewRequest(http.MethodGet, alertURL, nil))
if w.Code != http.StatusOK {
t.Fatalf("mock alert timeline status = %d, body = %q", w.Code, w.Body.String())
}
var timeline incidentView
assert.NoError(t, json.NewDecoder(w.Body).Decode(&timeline))
assert.Equal(t, alert.ID, timeline.AlertIdentifier)
assert.NotEmpty(t, timeline.Events)
resourceURL := "/api/alerts/incidents?resource_id=" + url.QueryEscape(alert.ResourceID) + "&limit=5"
w = httptest.NewRecorder()
h.GetAlertIncidentTimeline(w, httptest.NewRequest(http.MethodGet, resourceURL, nil))
assert.Equal(t, http.StatusOK, w.Code)
var resourceTimeline []incidentView
assert.NoError(t, json.NewDecoder(w.Body).Decode(&resourceTimeline))
assert.NotEmpty(t, resourceTimeline)
noteBody := fmt.Sprintf(`{"alertIdentifier":%q,"note":"Checking the demo timeline","user":"operator"}`, alert.ID)
w = httptest.NewRecorder()
h.SaveAlertIncidentNote(w, httptest.NewRequest(http.MethodPost, "/api/alerts/incidents/note", strings.NewReader(noteBody)))
assert.Equal(t, http.StatusOK, w.Code)
w = httptest.NewRecorder()
h.GetAlertIncidentTimeline(w, httptest.NewRequest(http.MethodGet, alertURL, nil))
if w.Code != http.StatusOK {
t.Fatalf("updated mock alert timeline status = %d, body = %q", w.Code, w.Body.String())
}
assert.NoError(t, json.NewDecoder(w.Body).Decode(&timeline))
if len(timeline.Events) == 0 {
t.Fatal("updated mock alert timeline has no events")
}
assert.Equal(t, memory.IncidentEventNote, timeline.Events[len(timeline.Events)-1].Type)
assert.Equal(t, "Checking the demo timeline", timeline.Events[len(timeline.Events)-1].Details["note"])
}
func TestGetAlertIncidentTimeline_ProjectsCanonicalLifecycleAndRemediation(t *testing.T) {
mockMonitor := new(MockAlertMonitor)
incidentStore := memory.NewIncidentStore(memory.IncidentStoreConfig{})
@@ -1249,6 +1307,16 @@ func TestAlertHandlers_ErrorCases(t *testing.T) {
req := httptest.NewRequest("POST", "/api/alerts/note", strings.NewReader(`{}`))
w := httptest.NewRecorder()
h2.SaveAlertIncidentNote(w, req)
assert.Equal(t, 400, w.Code)
})
t.Run("SaveAlertIncidentNote_ValidBodyNoStore", func(t *testing.T) {
mockMonitor2 := new(MockAlertMonitor)
mockMonitor2.On("GetIncidentStore").Return(nil)
h2 := NewAlertHandlers(nil, mockMonitor2, nil)
req := httptest.NewRequest("POST", "/api/alerts/note", strings.NewReader(`{"alertIdentifier":"a1","note":"investigating"}`))
w := httptest.NewRecorder()
h2.SaveAlertIncidentNote(w, req)
assert.Equal(t, 503, w.Code)
})
+220
View File
@@ -0,0 +1,220 @@
package mock
import (
"fmt"
"hash/fnv"
"sort"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/memory"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
)
const mockIncidentStartTolerance = time.Second
// buildAlertIncidentFixtures gives the alert demo one coherent occurrence
// model. History rows and incident timelines are enriched together so mock
// mode exercises acknowledgement, investigation, remediation, and resolution
// rather than presenting timeline controls backed by no data.
func buildAlertIncidentFixtures(history []models.Alert, now time.Time) ([]models.Alert, []*memory.Incident) {
enriched := append([]models.Alert(nil), history...)
incidents := make([]*memory.Incident, 0, len(enriched))
for index := range enriched {
alert := &enriched[index]
if strings.TrimSpace(alert.ID) == "" {
continue
}
seed := stableMockIncidentSeed(alert.ID)
active := strings.HasPrefix(alert.ID, "active-")
duration := time.Duration(5+seed%236) * time.Minute
resolvedAt := alert.StartTime.Add(duration)
if !now.IsZero() && resolvedAt.After(now) {
resolvedAt = now
}
if resolvedAt.Before(alert.StartTime) {
resolvedAt = alert.StartTime
}
acknowledged := seed%4 == 0
ackAt := alert.StartTime.Add(time.Duration(1+seed%4) * time.Minute)
eventCeiling := resolvedAt
if active {
eventCeiling = now
}
ackAt = clampMockIncidentEventTime(ackAt, alert.StartTime, eventCeiling)
if acknowledged {
alert.Acknowledged = true
alert.AckTime = cloneMockTime(ackAt)
alert.AckUser = "demo-operator"
}
incident := &memory.Incident{
ID: "mock-incident-" + alert.ID,
AlertIdentifier: alert.ID,
AlertType: alert.Type,
Level: alert.Level,
ResourceID: alert.ResourceID,
ResourceName: alert.ResourceName,
ResourceType: mockAlertResourceType(*alert),
Node: alert.Node,
Instance: alert.Instance,
Message: alert.Message,
Status: memory.IncidentStatusOpen,
OpenedAt: alert.StartTime,
Acknowledged: acknowledged,
AckUser: alert.AckUser,
AckTime: cloneMockTimePtr(alert.AckTime),
Events: []memory.IncidentEvent{{
ID: "mock-event-fired-" + alert.ID,
Type: memory.IncidentEventAlertFired,
Timestamp: alert.StartTime,
Summary: mockAlertFiredSummary(*alert),
Details: map[string]interface{}{
"type": alert.Type,
"level": alert.Level,
"value": alert.Value,
"threshold": alert.Threshold,
},
}},
}
if acknowledged {
incident.Events = append(incident.Events, memory.IncidentEvent{
ID: "mock-event-acknowledged-" + alert.ID,
Type: memory.IncidentEventAlertAcknowledged,
Timestamp: ackAt,
Summary: "Alert acknowledged",
Details: map[string]interface{}{"user": alert.AckUser},
})
}
analysisCandidate := alert.StartTime.Add(2 * time.Minute)
if active || analysisCandidate.Before(resolvedAt) {
analysisAt := clampMockIncidentEventTime(analysisCandidate, alert.StartTime, eventCeiling)
incident.Events = append(incident.Events, memory.IncidentEvent{
ID: "mock-event-analysis-" + alert.ID,
Type: memory.IncidentEventAnalysis,
Timestamp: analysisAt,
Summary: "Pulse Patrol analysis completed",
Details: map[string]interface{}{
"finding": "Telemetry confirms the alert condition and identifies the affected resource.",
"source": "mock-fixture",
},
})
}
if seed%3 == 0 {
runbookCandidate := alert.StartTime.Add(3 * time.Minute)
if active || runbookCandidate.Before(resolvedAt) {
runbookAt := clampMockIncidentEventTime(runbookCandidate, alert.StartTime, eventCeiling)
incident.Events = append(incident.Events, memory.IncidentEvent{
ID: "mock-event-runbook-" + alert.ID,
Type: memory.IncidentEventRunbook,
Timestamp: runbookAt,
Summary: "Runbook Verify resource health (succeeded)",
Details: map[string]interface{}{
"runbook_id": "mock-verify-health",
"outcome": "succeeded",
"automatic": false,
},
})
}
}
if !active {
incident.Status = memory.IncidentStatusResolved
incident.ClosedAt = cloneMockTime(resolvedAt)
incident.Events = append(incident.Events, memory.IncidentEvent{
ID: "mock-event-resolved-" + alert.ID,
Type: memory.IncidentEventAlertResolved,
Timestamp: resolvedAt,
Summary: "Alert resolved",
Details: map[string]interface{}{"resolved_at": resolvedAt.Format(time.RFC3339)},
})
}
sort.SliceStable(incident.Events, func(i, j int) bool {
return incident.Events[i].Timestamp.Before(incident.Events[j].Timestamp)
})
incidents = append(incidents, incident)
}
return enriched, incidents
}
func clampMockIncidentEventTime(candidate, openedAt, ceiling time.Time) time.Time {
if !ceiling.IsZero() && candidate.After(ceiling) {
candidate = ceiling
}
if candidate.Before(openedAt) {
return openedAt
}
return candidate
}
func stableMockIncidentSeed(value string) uint64 {
hash := fnv.New64a()
_, _ = hash.Write([]byte(value))
return hash.Sum64()
}
func mockAlertFiredSummary(alert models.Alert) string {
if alert.Value != 0 || alert.Threshold != 0 {
return fmt.Sprintf("Alert triggered: %s (%s %.1f >= %.1f)", alert.Type, alert.Level, alert.Value, alert.Threshold)
}
return fmt.Sprintf("Alert triggered: %s (%s)", alert.Type, alert.Level)
}
func mockAlertResourceType(alert models.Alert) string {
if alert.Metadata == nil {
return ""
}
value, _ := alert.Metadata["resourceType"].(string)
return strings.TrimSpace(value)
}
func cloneMockIncident(incident *memory.Incident) *memory.Incident {
if incident == nil {
return nil
}
clone := *incident
clone.ClosedAt = cloneMockTimePtr(incident.ClosedAt)
clone.AckTime = cloneMockTimePtr(incident.AckTime)
clone.Events = make([]memory.IncidentEvent, len(incident.Events))
for index, event := range incident.Events {
clone.Events[index] = event
if event.Details != nil {
clone.Events[index].Details = make(map[string]interface{}, len(event.Details))
for key, value := range event.Details {
clone.Events[index].Details[key] = value
}
}
}
return &clone
}
func cloneMockIncidents(incidents []*memory.Incident) []*memory.Incident {
cloned := make([]*memory.Incident, 0, len(incidents))
for _, incident := range incidents {
if clone := cloneMockIncident(incident); clone != nil {
cloned = append(cloned, clone)
}
}
return cloned
}
func cloneMockTime(value time.Time) *time.Time {
if value.IsZero() {
return nil
}
cloned := value
return &cloned
}
func cloneMockTimePtr(value *time.Time) *time.Time {
if value == nil {
return nil
}
return cloneMockTime(*value)
}
+114
View File
@@ -0,0 +1,114 @@
package mock
import (
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/memory"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
)
func TestBuildAlertIncidentFixturesCreatesCompleteLifecycleData(t *testing.T) {
now := time.Date(2026, time.August, 27, 12, 0, 0, 0, time.UTC)
history := []models.Alert{
{
ID: "hist-complete-lifecycle",
Type: "threshold",
Level: "critical",
ResourceID: "vm-101",
ResourceName: "database",
StartTime: now.Add(-30 * time.Minute),
Value: 94,
Threshold: 80,
Metadata: map[string]interface{}{"resourceType": "vm"},
},
{
ID: "active-complete-lifecycle",
Type: "connectivity",
Level: "error",
ResourceID: "node-1",
ResourceName: "pve-1",
StartTime: now.Add(-90 * time.Second),
Metadata: map[string]interface{}{"resourceType": "node"},
},
}
enriched, incidents := buildAlertIncidentFixtures(history, now)
if len(enriched) != 2 || len(incidents) != 2 {
t.Fatalf("got %d enriched alerts and %d incidents, want 2 of each", len(enriched), len(incidents))
}
resolved := incidents[0]
if resolved.Status != memory.IncidentStatusResolved || resolved.ClosedAt == nil {
t.Fatalf("historical incident status = %q, closedAt = %v; want resolved lifecycle", resolved.Status, resolved.ClosedAt)
}
if len(resolved.Events) < 3 {
t.Fatalf("historical incident has %d events, want fired, analysis, and resolved", len(resolved.Events))
}
if resolved.Events[0].Type != memory.IncidentEventAlertFired || resolved.Events[len(resolved.Events)-1].Type != memory.IncidentEventAlertResolved {
t.Fatalf("historical lifecycle endpoints = %q ... %q", resolved.Events[0].Type, resolved.Events[len(resolved.Events)-1].Type)
}
if resolved.ResourceType != "vm" {
t.Fatalf("resource type = %q, want vm", resolved.ResourceType)
}
active := incidents[1]
if active.Status != memory.IncidentStatusOpen || active.ClosedAt != nil {
t.Fatalf("active incident status = %q, closedAt = %v; want open lifecycle", active.Status, active.ClosedAt)
}
for _, event := range active.Events {
if event.Timestamp.After(now) {
t.Fatalf("active incident event %q is in the future: %s", event.Type, event.Timestamp)
}
}
for index, incident := range incidents {
if enriched[index].Acknowledged != incident.Acknowledged || enriched[index].AckUser != incident.AckUser {
t.Fatalf("alert and incident acknowledgement state diverged for %s", incident.AlertIdentifier)
}
}
}
func TestMockAlertIncidentQueriesAndNotesShareCanonicalFixture(t *testing.T) {
resetMockIntegrationState(t)
now := time.Date(2026, time.August, 27, 12, 0, 0, 0, time.UTC)
history := []models.Alert{
{ID: "hist-resource-new", Type: "threshold", Level: "warning", ResourceID: "vm-101", StartTime: now.Add(-10 * time.Minute)},
{ID: "hist-resource-old", Type: "backup", Level: "error", ResourceID: "vm-101", StartTime: now.Add(-2 * time.Hour)},
}
enriched, incidents := buildAlertIncidentFixtures(history, now)
dataMu.Lock()
mockGraph.AlertHistory = enriched
mockGraph.AlertIncidents = incidents
dataMu.Unlock()
enabled.Store(true)
timeline := GetMockAlertIncidentTimeline(enriched[0].ID, enriched[0].StartTime)
if timeline == nil || timeline.AlertIdentifier != enriched[0].ID {
t.Fatalf("exact occurrence lookup returned %#v", timeline)
}
timeline.Events[0].Summary = "mutated by caller"
canonical := GetMockAlertIncidentTimeline(enriched[0].ID, enriched[0].StartTime)
if canonical.Events[0].Summary == "mutated by caller" {
t.Fatal("timeline lookup exposed the canonical fixture to caller mutation")
}
resourceIncidents := GetMockAlertIncidentsForResource("vm-101", 1)
if len(resourceIncidents) != 1 || resourceIncidents[0].AlertIdentifier != "hist-resource-new" {
t.Fatalf("resource timeline = %#v, want newest matching incident", resourceIncidents)
}
beforeVersion := fixtureDataVersion.Load()
if !AddMockAlertIncidentNote(enriched[0].ID, "", "Investigating storage latency", "operator") {
t.Fatal("AddMockAlertIncidentNote returned false for canonical mock incident")
}
updated := GetMockAlertIncidentTimeline(enriched[0].ID, enriched[0].StartTime)
last := updated.Events[len(updated.Events)-1]
if last.Type != memory.IncidentEventNote || last.Details["note"] != "Investigating storage latency" {
t.Fatalf("last event = %#v, want persisted note", last)
}
if fixtureDataVersion.Load() != beforeVersion+1 {
t.Fatalf("fixture data version did not advance after note mutation")
}
}
+4
View File
@@ -3,6 +3,7 @@ package mock
import (
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/memory"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/truenas"
@@ -15,6 +16,7 @@ import (
type FixtureGraph struct {
State models.StateSnapshot
AlertHistory []models.Alert
AlertIncidents []*memory.Incident
PlatformFixtures PlatformFixtures
AvailabilityFixtures []AvailabilityFixture
DiscoveryFixtures []*DiscoveryFixture
@@ -38,6 +40,7 @@ func buildFixtureGraph(cfg MockConfig, now time.Time) FixtureGraph {
syncMetricFixtureRegistriesFromGraph(graph)
graph.UpdateMetrics(cfg, now)
graph.AlertHistory = buildAlertHistory(graph.State.Nodes, graph.State.VMs, graph.State.Containers)
graph.AlertHistory, graph.AlertIncidents = buildAlertIncidentFixtures(graph.AlertHistory, now)
resources, _ := graph.UnifiedResourceSnapshot()
graph.ActionFixtures = buildActionFixtures(resources, now)
syncMetricFixtureRegistriesFromGraph(graph)
@@ -48,6 +51,7 @@ func cloneFixtureGraph(in FixtureGraph) FixtureGraph {
return FixtureGraph{
State: cloneState(in.State),
AlertHistory: append([]models.Alert(nil), in.AlertHistory...),
AlertIncidents: cloneMockIncidents(in.AlertIncidents),
PlatformFixtures: clonePlatformFixtures(in.PlatformFixtures),
AvailabilityFixtures: cloneAvailabilityFixtures(in.AvailabilityFixtures),
DiscoveryFixtures: cloneDiscoveryFixtures(in.DiscoveryFixtures),
+101
View File
@@ -1,14 +1,17 @@
package mock
import (
"fmt"
"math"
"os"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/memory"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/mockruntime"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
@@ -560,6 +563,104 @@ func GetMockAlertHistory(limit int) []models.Alert {
return append([]models.Alert(nil), mockGraph.AlertHistory...)
}
// GetMockAlertIncidentTimeline returns the occurrence-qualified incident
// fixture for a mock alert. Callers receive a defensive copy so UI filtering
// and note rendering cannot mutate the canonical fixture graph.
func GetMockAlertIncidentTimeline(alertIdentifier string, startedAt time.Time) *memory.Incident {
if !IsMockEnabled() || strings.TrimSpace(alertIdentifier) == "" {
return nil
}
dataMu.RLock()
defer dataMu.RUnlock()
for _, incident := range mockGraph.AlertIncidents {
if incident == nil || incident.AlertIdentifier != alertIdentifier {
continue
}
if !startedAt.IsZero() {
delta := incident.OpenedAt.Sub(startedAt)
if delta < 0 {
delta = -delta
}
if delta > mockIncidentStartTolerance {
continue
}
}
return cloneMockIncident(incident)
}
return nil
}
// GetMockAlertIncidentsForResource returns newest-first incident fixtures for
// the resource-level timeline panel.
func GetMockAlertIncidentsForResource(resourceID string, limit int) []*memory.Incident {
if !IsMockEnabled() || strings.TrimSpace(resourceID) == "" {
return []*memory.Incident{}
}
dataMu.RLock()
matches := make([]*memory.Incident, 0)
for _, incident := range mockGraph.AlertIncidents {
if incident != nil && incident.ResourceID == resourceID {
matches = append(matches, cloneMockIncident(incident))
}
}
dataMu.RUnlock()
sort.Slice(matches, func(i, j int) bool {
return matches[i].OpenedAt.After(matches[j].OpenedAt)
})
if limit > 0 && len(matches) > limit {
matches = matches[:limit]
}
return matches
}
// AddMockAlertIncidentNote persists a note for the lifetime of the current
// mock fixture graph so the demo note workflow behaves like the real timeline.
func AddMockAlertIncidentNote(alertIdentifier, incidentID, note, user string) bool {
if !IsMockEnabled() || strings.TrimSpace(note) == "" {
return false
}
dataMu.Lock()
defer dataMu.Unlock()
for _, incident := range mockGraph.AlertIncidents {
if incident == nil {
continue
}
if incidentID != "" && incident.ID != incidentID {
continue
}
if incidentID == "" && incident.AlertIdentifier != alertIdentifier {
continue
}
noteAt := time.Now().UTC()
if noteAt.Before(incident.OpenedAt) {
noteAt = incident.OpenedAt
}
summary := "Note added"
if strings.TrimSpace(user) != "" {
summary = "Note added by " + strings.TrimSpace(user)
}
incident.Events = append(incident.Events, memory.IncidentEvent{
ID: fmt.Sprintf("mock-event-note-%s-%d", incident.AlertIdentifier, len(incident.Events)+1),
Type: memory.IncidentEventNote,
Timestamp: noteAt,
Summary: summary,
Details: map[string]interface{}{
"note": strings.TrimSpace(note),
"user": strings.TrimSpace(user),
},
})
sort.SliceStable(incident.Events, func(i, j int) bool {
return incident.Events[i].Timestamp.Before(incident.Events[j].Timestamp)
})
fixtureDataVersion.Add(1)
return true
}
return false
}
func cloneState(state models.StateSnapshot) models.StateSnapshot {
return state.Clone()
}