mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 22:12:23 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user