Stop polluting Patrol finding lifecycle with no-op heartbeats

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.
This commit is contained in:
rcourtman
2026-05-10 17:13:07 +01:00
parent 3da835c5bc
commit e087eff00e
3 changed files with 86 additions and 20 deletions
@@ -1036,26 +1036,40 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
<div class="text-xs font-medium text-base-content mb-2">Lifecycle</div>
<div class="space-y-1">
<For each={[...(finding.lifecycle || [])].slice(-6).reverse()}>
{(event) => (
<div class="text-xs text-muted flex items-start justify-between gap-2">
<span class="truncate">
<span class="font-medium text-base-content">
{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 (
<div class="text-xs text-muted flex items-start justify-between gap-2">
<span class="truncate">
<span class="font-medium text-base-content">{typeLabel}</span>
<Show when={showMessage()}>
{' '}
<span>{event.message}</span>
</Show>
<Show when={showTransition()}>
{' '}
<span class="text-muted">
({event.from} {'->'} {event.to})
</span>
</Show>
</span>
<Show when={event.message}>
{' '}
<span>{event.message}</span>
</Show>
<Show when={event.from && event.to}>
{' '}
<span class="text-muted">
({event.from} {'->'} {event.to})
</span>
</Show>
</span>
<span class="shrink-0">{formatRelativeTime(event.at)}</span>
</div>
)}
<span class="shrink-0">{formatRelativeTime(event.at)}</span>
</div>
);
}}
</For>
</div>
</div>
+6 -1
View File
@@ -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()
+47
View File
@@ -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{