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
@@ -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`
@@ -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`,
@@ -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
@@ -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",
+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()
}