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{