mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Add complete mock alert timelines
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user