diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 4aa66de1f..b3cdbcb06 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -1726,6 +1726,11 @@ Assistant are now owned by the Alerts incident handoff model and carry only sanitized incident facts plus event summaries into both the visible drawer briefing and the backend model-only handoff context; raw command and output details stay in the incident timeline or approval surface. +The shared hook may expose Timeline for a displayed mock alert because mock +mode now guarantees an occurrence-qualified incident through the same typed +API. A mock fixture missing that incident is a broken alert read-model contract, +not an empty-state case for the frontend to disguise; genuine unknown real +occurrences may still render the existing no-timeline state. Resource incident panel cards, summary rows, and toggle-button presentation now also route through `frontend-modern/src/utils/alertIncidentPresentation.ts` diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 18bb8a6f5..d936254e7 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -4488,6 +4488,15 @@ not manufacture escalation, notification, analysis, command, or runbook events. Richer canonical events replace equivalent snapshot fallbacks when available. A genuinely unknown identifier/occurrence may still return `null`; an Alerts-page row with its canonical identifier and start time must not. +When mock mode is enabled, that same transport contract resolves displayed +mock alert occurrences from the canonical mock fixture graph before consulting +the production incident store. Every mock history row therefore returns a +non-empty lifecycle timeline, `resource_id` lists the same incidents newest +first with the requested positive limit, and incident notes persist on that +fixture graph for its lifetime. Mock reads and writes must keep the production +JSON shape and validation rules; they must not mix persisted real incidents +into a mock resource timeline or require a production incident store to make a +fixture-owned workflow function. Per-alert snooze is a monitoring-write mutation over canonical alert identity. `POST /api/alerts/snooze` accepts `alertIdentifier` and an RFC 3339 `until`, diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 1136a132d..817ba3033 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -515,6 +515,7 @@ cleanup so readers cannot retain orphaned runtime or alert projections. 35b. `internal/mock/availability_fixtures.go` 35c. `internal/mock/recovery_points.go` 35d. `internal/mock/integration.go` +35e. `internal/mock/alert_incidents.go` 36. `internal/dockeragent/docker_client.go` 37. `pkg/agents/docker/report.go` 38. `internal/models/models.go` @@ -1931,6 +1932,15 @@ enumerated backup artifact must never mint protected posture. The `TestCuratedDemoProtectionStoriesProduceRepresentativePostures` proof in `internal/mock/demo_scenarios_test.go` evaluates those fixtures through the production posture engine. +Mock alert history and incident timelines belong to the same fixture authority. +`internal/mock/alert_incidents.go` enriches every displayed mock alert row with +one occurrence-qualified incident whose resource identity, acknowledgement +state, lifecycle status, and ordered events agree with that row. Historical +fixtures close with a resolution event, active fixtures remain open, and +representative investigation and runbook events exercise the richer timeline +surface. Alert-level reads, resource-level incident lists, and graph-lifetime +notes must all query or mutate that same fixture instance; a mock history row +must never expose a Timeline control backed by a missing incident. Mock fixture defaults in `internal/mock/generator.go` (the `DefaultConfig` constant) are also part of that mock-runtime contract. The Proxmox default is an intentionally large public-demo estate so platform-first pages exercise diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 4e1f938e6..9e5584c25 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -5554,6 +5554,7 @@ "internal/dockeragent/swarm.go", "internal/kubernetesagent/agent.go", "internal/mock/action_fixtures.go", + "internal/mock/alert_incidents.go", "internal/mock/availability_fixtures.go", "internal/mock/demo_scenarios.go", "internal/mock/fixture_graph.go", @@ -5941,6 +5942,7 @@ "match_prefixes": [], "match_files": [ "internal/mock/action_fixtures.go", + "internal/mock/alert_incidents.go", "internal/mock/availability_fixtures.go", "internal/mock/demo_scenarios.go", "internal/mock/fixture_graph.go", @@ -5953,6 +5955,7 @@ "test_prefixes": [], "exact_files": [ "internal/mock/action_fixtures_test.go", + "internal/mock/alert_incidents_test.go", "internal/mock/canonical_api_guardrails_test.go", "internal/mock/demo_scenarios_test.go", "internal/mock/generator_test.go", diff --git a/internal/api/alerting/alerts.go b/internal/api/alerting/alerts.go index e6d1cb79e..f94b2fb42 100644 --- a/internal/api/alerting/alerts.go +++ b/internal/api/alerting/alerts.go @@ -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) diff --git a/internal/api/alerting/alerts_test.go b/internal/api/alerting/alerts_test.go index 3054bbee6..5d3c6d96f 100644 --- a/internal/api/alerting/alerts_test.go +++ b/internal/api/alerting/alerts_test.go @@ -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) }) diff --git a/internal/mock/alert_incidents.go b/internal/mock/alert_incidents.go new file mode 100644 index 000000000..693584c8c --- /dev/null +++ b/internal/mock/alert_incidents.go @@ -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) +} diff --git a/internal/mock/alert_incidents_test.go b/internal/mock/alert_incidents_test.go new file mode 100644 index 000000000..2c1e7bbac --- /dev/null +++ b/internal/mock/alert_incidents_test.go @@ -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") + } +} diff --git a/internal/mock/fixture_graph.go b/internal/mock/fixture_graph.go index 71965f5b9..bf3a8f84e 100644 --- a/internal/mock/fixture_graph.go +++ b/internal/mock/fixture_graph.go @@ -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), diff --git a/internal/mock/integration.go b/internal/mock/integration.go index e26d4a567..2103ec3f4 100644 --- a/internal/mock/integration.go +++ b/internal/mock/integration.go @@ -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() }