From e087eff00e2a4fdb05bd232d126c5fdb2dbffa9f Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 10 May 2026 17:13:07 +0100 Subject: [PATCH] Stop polluting Patrol finding lifecycle with no-op heartbeats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Patrol scan that re-detected an already-active finding was appending a "detected (same_state -> same_state)" lifecycle event with the message "Detected by Pulse Patrol". A finding active for 6 scans rendered as 6 stacked rows reading "Detected Detected by Pulse Patrol (detected -> detected)". Backend: drop the unconditional re-detection lifecycle append in findings.go. The lifecycle should record state transitions, not heartbeats — TimesRaised and LastSeenAt already track recurrence, and the genuine transition events ("regressed", "reminded", "suppression_lifted") are emitted from their own branches upstream. Frontend: defensively hide (from -> to) spans where from === to and strip the type-label prefix from the message so already- persisted polluted lifecycle entries also render cleanly until they age out of the per-finding event cap. Adds a test that locks in the new backend behavior. --- .../src/components/AI/FindingsPanel.tsx | 52 ++++++++++++------- internal/ai/findings.go | 7 ++- internal/ai/findings_lifecycle_test.go | 47 +++++++++++++++++ 3 files changed, 86 insertions(+), 20 deletions(-) diff --git a/frontend-modern/src/components/AI/FindingsPanel.tsx b/frontend-modern/src/components/AI/FindingsPanel.tsx index f74d0acbc..68d1ad977 100644 --- a/frontend-modern/src/components/AI/FindingsPanel.tsx +++ b/frontend-modern/src/components/AI/FindingsPanel.tsx @@ -1036,26 +1036,40 @@ export const FindingsPanel: Component = (props) => {
Lifecycle
- {(event) => ( -
- - - {formatFindingLifecycleType(event.type)} + {(event) => { + const typeLabel = formatFindingLifecycleType(event.type); + // Some historical events have a message that just restates + // the type label ("Detected" / "Detected by Pulse Patrol"). + // Drop the message in that case so the row reads cleanly. + const showMessage = () => { + const msg = event.message?.trim(); + if (!msg) return false; + return !msg.toLowerCase().startsWith(typeLabel.toLowerCase()); + }; + // A from->to span where from === to is a no-op transition + // (a heartbeat that pre-dates the lifecycle dedupe fix). + // Hide it; only render real transitions. + const showTransition = () => + Boolean(event.from) && Boolean(event.to) && event.from !== event.to; + return ( +
+ + {typeLabel} + + {' '} + {event.message} + + + {' '} + + ({event.from} {'->'} {event.to}) + + - - {' '} - {event.message} - - - {' '} - - ({event.from} {'->'} {event.to}) - - - - {formatRelativeTime(event.at)} -
- )} + {formatRelativeTime(event.at)} +
+ ); + }}
diff --git a/internal/ai/findings.go b/internal/ai/findings.go index 069a75462..4e95e2417 100644 --- a/internal/ai/findings.go +++ b/internal/ai/findings.go @@ -1263,7 +1263,12 @@ func (s *FindingsStore) Add(f *Finding) bool { } } s.syncLoopStateLocked(existing) - s.appendLifecycleLocked(existing, "detected", "Detected by Pulse Patrol", existing.LoopState, existing.LoopState, nil) + // Re-detections of an existing finding are heartbeats, not transitions. + // TimesRaised and LastSeenAt already track recurrence. The actual + // transition events ("regressed", "reminded", "suppression_lifted", + // etc.) are emitted from their own branches above; appending an + // additional "detected (same -> same)" event on every Patrol scan + // pollutes the lifecycle with no-op rows. severity := existing.Severity s.mu.Unlock() s.scheduleSave() diff --git a/internal/ai/findings_lifecycle_test.go b/internal/ai/findings_lifecycle_test.go index d8bc13dfc..27936dcf9 100644 --- a/internal/ai/findings_lifecycle_test.go +++ b/internal/ai/findings_lifecycle_test.go @@ -34,6 +34,53 @@ func TestFindingsStore_AddRecordsDetectedLifecycleEvent(t *testing.T) { } } +func TestFindingsStore_RedetectionDoesNotAppendHeartbeatLifecycleEvent(t *testing.T) { + store := NewFindingsStore() + f := &Finding{ + ID: "lf-heartbeat", + ResourceID: "host-runtime-error", + ResourceName: "host-runtime-error", + Severity: FindingSeverityWarning, + Category: FindingCategoryReliability, + Title: "Provider analysis error", + Description: "Pulse Patrol reached the configured provider, but the provider did not complete the request.", + } + if !store.Add(f) { + t.Fatal("expected first add to create finding") + } + initialLen := len(store.Get(f.ID).Lifecycle) + if initialLen == 0 { + t.Fatal("expected first add to record at least one lifecycle event") + } + + // Simulate three additional Patrol scans re-detecting the same active + // finding. None of these are state transitions — TimesRaised and + // LastSeenAt should still update, but no new lifecycle events should + // be appended (the lifecycle records transitions, not heartbeats). + for i := 0; i < 3; i++ { + if !store.Add(&Finding{ + ID: f.ID, + ResourceID: "host-runtime-error", + ResourceName: "host-runtime-error", + Severity: FindingSeverityWarning, + Category: FindingCategoryReliability, + Title: "Provider analysis error", + Description: "Pulse Patrol reached the configured provider, but the provider did not complete the request.", + }) { + t.Fatalf("expected re-detection %d to update existing finding", i+1) + } + } + + got := store.Get(f.ID) + if got.TimesRaised != 1+3 { + t.Fatalf("expected timesRaised=4 after three re-detections, got %d", got.TimesRaised) + } + if len(got.Lifecycle) != initialLen { + t.Fatalf("expected lifecycle length to remain %d after heartbeat re-detections, got %d (events: %+v)", + initialLen, len(got.Lifecycle), got.Lifecycle) + } +} + func TestFindingsStore_RegressionIncrementsAndRecordsLifecycleEvent(t *testing.T) { store := NewFindingsStore() f := &Finding{