Merge pull request #1848 from rcourtman/claude/patrol-findings-hygiene-v3

Fold alert-mirroring findings, collapse flapping, surface lasting decisions
This commit is contained in:
rcourtman
2026-09-02 07:40:04 +01:00
committed by GitHub
29 changed files with 2128 additions and 118 deletions
+49 -1
View File
@@ -9975,6 +9975,40 @@
"kind": "file"
}
]
},
{
"id": "patrol-findings-hygiene-attention-noise",
"summary": "Two users in a row (discussions #1623 and #1699) could not find Patrol's durable outcomes because Remember as expected, Dismiss: Not an issue, Dismiss: Later, and Create rule sat two levels below the Needs attention detail, which offered only Acknowledge and Suppress. The same screenshot showed an alert with eleven open/resolved transitions in a day rendered as eleven timeline rows, and Patrol findings that restate an active alert (same resource, same condition) listed as separate items. Telemetry on 2026-09-01 showed 4,709 findings across 736 installs but only 255 investigations across 27, so most findings are seen and ignored. Attention needs: alert-mirroring findings folded under the alert, flapping collapsed to one labelled item with a count, and the durable decisions on the detail with one-line explanations.",
"owner": "project-owner",
"status": "triaged",
"recorded_at": "2026-09-02",
"lane_ids": [
"L6"
],
"subsystem_ids": [
"ai-runtime",
"api-contracts",
"patrol-intelligence"
],
"proposed_resolution": "lane-expansion",
"coverage_impact": 3,
"evidence": [
{
"repo": "pulse",
"path": "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/findings_alert_mirror.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/findings_storm_throttler.go",
"kind": "file"
}
]
}
],
"candidate_lanes": [
@@ -10042,7 +10076,21 @@
]
}
],
"work_claims": [],
"work_claims": [
{
"id": "claude-patrol-findings-hygiene-coverage-gap-patrol-findings-hygiene-attention-noise",
"agent_id": "claude-patrol-findings-hygiene",
"summary": "Fold alert-mirroring findings under the alert, collapse flapping, surface lasting Patrol decisions on the attention detail",
"target_id": "v6-product-lane-expansion",
"claimed_at": "2026-09-02T03:29:56Z",
"heartbeat_at": "2026-09-02T03:29:56Z",
"expires_at": "2026-09-02T09:29:56Z",
"work_item": {
"kind": "coverage-gap",
"id": "patrol-findings-hygiene-attention-noise"
}
}
],
"open_decisions": [],
"source_of_truth_file": "docs/release-control/v6/internal/SOURCE_OF_TRUTH.md",
"resolved_decisions": [
@@ -4247,7 +4247,14 @@ titles: provider alarm messages yield their bounded alarm subject, generic
resource/storage incident types become plain issue language, and known
acronyms such as CPU, ZFS, and I/O retain their canonical casing. Clients must
not receive machine-title-cased placeholders such as `Resource Incident` and
repair them independently. `internal/ai/patrol_metrics.go` exports only
repair them independently. The projection also collapses open/resolved churn:
when a record's timeline holds at least four open-to-resolved or
resolved-to-open transitions inside the last 24 hours, the item carries a
`flapping` summary (transition count, window, first and latest transition) and
clients present one item with that label; the full transition list stays in the
detail timeline as forensic detail. The window and threshold are the shared
constants in `internal/ai/findings_storm_throttler.go`, so alerts and Patrol
findings agree on what flapping means. `internal/ai/patrol_metrics.go` exports only
low-cardinality lifecycle-state counts, queue age, acknowledgement time, and
calm-evaluation age; raw resource IDs are forbidden as metric labels.
@@ -4371,6 +4371,23 @@ auto-register mutation boundary.
## Current State
### Attention flapping summary and finding alert-mirror fields
`GET /api/ai/patrol/attention` items and `GET /api/ai/patrol/attention/{id}`
details may carry `flapping` (`transitionCount`, `windowHours`,
`firstTransitionAt`, `lastTransitionAt`) when the record's timeline holds at
least four open/resolved transitions inside the last 24 hours; the timeline
itself is unchanged. `GET /api/ai/patrol/findings` findings may carry
`flapping` (`transition_count`, `window_hours`, `first_transition_at`,
`last_transition_at`) under the same rule, plus `mirrors_alert_id` and
`mirrors_alert_type` naming the active alert the finding restates (same
canonical resource, same condition class). All four fields are omitted when
not applicable and are derived read-model annotations, not lifecycle state;
clients must not treat their absence as evidence of stability.
`frontend-modern/src/api/patrolAttention.ts` (`AttentionFlapping`) and
`frontend-modern/src/api/patrol.ts` (`FindingFlapping`, `mirrors_alert_*`)
are the typed client boundary.
### Resource-list facets preserve scoped navigation evidence
`GET /api/resources` returns a compact `facets` object beside the paged row
@@ -3110,6 +3110,12 @@ viewport ordering, and reduced-motion-safe behavior. Detail deep links use
The temporary-suppression reason and duration are labelled native controls
owned by `FormTextarea` and `FormSelect`; Patrol must not recreate their label,
focus, responsive touch-target, or controlled-value shells locally.
The Lasting decisions section reuses the same ownership: its four decision
triggers are shared `Button` primitives in a labelled list, the inline
confirmation note or rule reason is a `FormTextarea`, and the alert-only
guidance link is a `ButtonLink`. `PatrolIntelligenceSurface.tsx` passes the
Patrol findings accessor into the workbench; the workbench does not fetch or
poll findings itself.
The objective brief and optional-context fields in `PatrolObjectivesPanel`
share the same `FormTextarea` ownership contract.
@@ -2498,3 +2498,42 @@ writes through `@/api/resourceOperatorState`; no alert-local mute document is
created. `ResourceMonitoringPolicyAction.test.tsx` and the resource operator
state section tests pin provider copy, expected-offline persistence, retirement,
and compatibility preservation.
### Lasting decisions live on the attention detail
Two users in a row (discussions #1623 and #1699) could not find the durable
Patrol outcomes because they sat two levels below the Needs attention detail.
The selected detail in `PatrolAttentionWorkbench.tsx` now owns a `Lasting
decisions` section beside the alert lifecycle controls. When a Patrol finding
mirrors the selected alert (same resource, backend-stamped `mirrorsAlertType`
equal to the item kind, joined by `getLinkedPatrolFindings` in
`patrolHomePresentation.ts` over the surface-supplied `findings` accessor),
the section lists Remember as expected, Dismiss: Not an issue, Dismiss: Later,
and Create rule, each with one line stating what it does and how long it lasts
(`PATROL_LASTING_DECISIONS`). Choosing one opens an inline confirmation with an
optional note, or a required reason for a rule, and writes through the existing
finding actions (`aiIntelligenceStore.dismissFinding`,
`createSuppressionRuleFromFinding`); the alert lifecycle above is untouched.
A finding Patrol already remembers shows the remembered decision and a Reopen
path instead of re-offering the choice; to make that possible
`usePatrolIntelligenceState.ts` loads Patrol findings with dismissed and
resolved history included, because a dismissed finding is absent from the
active-only default set and the detail would otherwise claim Patrol has
nothing to remember. An alert with no mirrored finding says
plainly that Patrol has nothing to remember and points at alert thresholds,
because the only durable control for a bare alert is the alert configuration.
The temporary controls state in one visible line that acknowledgement and
suppression are short-term; the former `More ways to manage this issue`
disclosure is gone.
Flapping is one item, not many. Queue rows and the detail show the backend
`flapping` label with the transition count; the detail timeline renders a
one-line flapping summary and keeps every transition under `Show all N
transitions`. `FindingsPanel.tsx` shows the same label on findings and demotes
active findings that mirror an active alert (`isAlertMirroredFinding` in
`frontend-modern/src/utils/findingAlertIdentity.ts`) into a collapsed `N
findings mirror an active alert` group below the list, where their finding
options remain available; dismissed and resolved views keep them in place.
`PatrolAttentionWorkbench.test.tsx` pins the four decisions and their copy, the
rule reason requirement, the remembered-decision path, the alert-only
guidance, and the flapping collapse.
+36 -24
View File
@@ -1,45 +1,57 @@
{
"version": 1,
"base_sha": "9fba43ffed507f092f876acaf23bef013f0f6fab",
"verified_at": "2026-09-02T03:39:02Z",
"base_sha": "d7a4dcf8e7c59fc35348236d8aeb3f25dc6062fe",
"verified_at": "2026-09-02T06:02:51Z",
"result": "passed",
"changed_paths": ["frontend-modern/src/components/shared/Dialog.tsx"],
"changed_paths": [
"frontend-modern/src/api/patrol.ts",
"frontend-modern/src/api/patrolAttention.ts",
"frontend-modern/src/components/AI/FindingsPanel.tsx",
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx",
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx",
"frontend-modern/src/features/patrol/patrolHomePresentation.ts",
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts",
"frontend-modern/src/stores/aiIntelligence.ts",
"frontend-modern/src/utils/findingAlertIdentity.ts"
],
"content_sha256": {
"frontend-modern/src/components/shared/Dialog.tsx": "185f09e185a9e5ccddf73906a81552ea0cc8b07390fb5ab587fbe1b952697735"
"frontend-modern/src/api/patrol.ts": "962ddd9eccb31fcfa419cde956c90f319333de3c15f98118d6580b542b1f4825",
"frontend-modern/src/api/patrolAttention.ts": "6729696d1966d62a38ebcc52bcc1d0a62479c6e025c1a7d4f23f02ed77c4428f",
"frontend-modern/src/components/AI/FindingsPanel.tsx": "8a6f65c113e507e4d4016861559f5ca01c962b4173387a42a2140499ae1d3e30",
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx": "ababb1fd31897a9dd7ca518d574d30b21b8b4abeae597f6475937ad93f3ebf59",
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "f7fbdc4d43c42e12f08671a78a4b903bb1ed7b2d25d1551c395691c4991f1a40",
"frontend-modern/src/features/patrol/patrolHomePresentation.ts": "78aa0072e9b6c8bed4bc5fa3a86010bd61c9aa0c2ed98b08accca2c42e5e68de",
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "b9632efe1d21f9c125b52795fa950b5f40167c4e11ccff18652f32ac1b3faf49",
"frontend-modern/src/stores/aiIntelligence.ts": "177859f415e92bcd282844ececf6ef8edfd5d54bda612ae18f100aacc1b40921",
"frontend-modern/src/utils/findingAlertIdentity.ts": "7988efd6dbf80c60e4d66312abb5ff95bcae9fc98445af0f57940a7ed3ea358b"
},
"routes": [
"/settings/infrastructure",
"/actions",
"/alerts/overview",
"/settings/system-general",
"/patrol",
"/"
"/patrol?attention=mock-cluster-2-pve10-local-zfs%3A%3Ametric-threshold%3Ausage"
],
"viewports": [
{
"width": 1280,
"height": 720
"height": 1400
},
{
"width": 390,
"height": 844
},
{
"width": 393,
"height": 851
"height": 1600
}
],
"states": [
"Add infrastructure dialog open at desktop and narrow widths with reduced motion, a visible labelled heading, accessible description, focused close control, contained panel geometry, and no horizontal document overflow",
"Add infrastructure dialog dismissed at desktop and narrow widths with the underlying Infrastructure surface restored",
"authenticated Actions, Alerts, Infrastructure, General Settings, and Patrol surfaces with reduced motion and no automatically detectable WCAG A/AA violations",
"logged-out welcome surface with reduced motion and no automatically detectable WCAG A/AA violations"
"Patrol inbox queue with 47-50 mock decisions and the alert-only detail (no mirrored Patrol finding: Lasting decisions explains there is nothing for Patrol to remember and links alert thresholds)",
"Attention detail for east-d-service-pool storage usage with a mirrored Patrol finding: Lifecycle short-term note, Lasting decisions listing Remember as expected, Dismiss: Not an issue, Dismiss: Later, Create rule with one-line explanations",
"Inline confirmation form for Remember as expected with optional note",
"Remembered-decision state (Remembered as expected, note, Reopen finding) after the dismissal, and the four options again after Reopen",
"Activity tab findings list with the collapsed \"1 finding mirrors an active alert\" group, expanded to show the demoted finding with Finding options",
"Narrow 390px detail layout with stacked lasting decisions and no horizontal overflow"
],
"interactions": [
"opened Add infrastructure from its named trigger at desktop and narrow widths and verified the dialog accessible name and description",
"inspected final desktop and 390x844 screenshots for placement, clipping, stacking, scrolling, focus treatment, and responsive layout",
"verified the dialog bounds stay inside both viewports and the document has no horizontal overflow",
"dismissed the dialog with Escape at desktop and narrow widths and verified focus returned to Add infrastructure",
"scanned representative authenticated and logged-out surfaces for WCAG A/AA violations and unexpected reduced-motion effects"
"Opened the deep-linked attention detail and read the Lifecycle and Lasting decisions sections",
"Clicked Remember as expected, typed a note, confirmed; verified via /api/ai/patrol/findings?include_resolved=1 that the mirrored finding carried dismissed_reason=expected_behavior and the note",
"Reloaded the detail and confirmed the Remembered as expected state, then clicked Reopen finding and confirmed the finding returned to active with the four decisions offered again",
"Switched to the Activity tab, opened Finding options and history, expanded the mirrored-findings group",
"Triggered POST /api/ai/patrol/run and confirmed mirrors_alert_id/mirrors_alert_type stamped on the storage finding"
]
}
@@ -26,6 +26,22 @@ describe('Patrol attention API', () => {
fetchMock.mockResolvedValue({});
});
it('returns the backend flapping summary on attention items unchanged', async () => {
const flapping = {
transitionCount: 11,
windowHours: 24,
firstTransitionAt: '2026-08-09T15:00:00Z',
lastTransitionAt: '2026-08-10T14:30:00Z',
};
fetchMock.mockResolvedValue({
data: [{ id: 'record-flap', flapping }],
summary: {},
meta: { page: 1, limit: 50, total: 1, totalPages: 1 },
});
const response = await getPatrolAttention();
expect(response.data[0].flapping).toEqual(flapping);
});
it('uses one bounded typed list query', async () => {
await getPatrolAttention('stale_unknown', 2, 40);
expect(fetchMock).toHaveBeenCalledWith(
+21
View File
@@ -69,6 +69,27 @@ export interface Finding {
// urgency signal. Present only for capacity-relevant findings whose resource
// has enough utilization history to compute a trend.
capacity_forecast?: CapacityForecast;
// flapping is present while the finding's open/resolved state has changed
// at least four times inside the last 24 hours. The store collapses those
// transitions into one lifecycle row; this summary carries the count.
flapping?: FindingFlapping;
// mirrors_alert_id / mirrors_alert_type name the active alert this finding
// restates (same resource, same condition). Patrol stamps them after every
// run so the UI can fold the finding under the alert instead of listing the
// same problem twice.
mirrors_alert_id?: string;
mirrors_alert_type?: string;
}
/**
* FindingFlapping summarises open/resolved churn on one finding. Mirrors
* internal/ai FindingFlapping on the wire.
*/
export interface FindingFlapping {
transition_count: number;
window_hours: number;
first_transition_at: string;
last_transition_at: string;
}
/**
@@ -38,6 +38,19 @@ export interface AttentionResource {
resourceId: string;
}
/**
* AttentionFlapping summarises an item whose lifecycle keeps switching between
* open and resolved. Present only once the shared flapping threshold (four
* transitions inside 24 hours) is crossed; the full transition list stays in
* the detail timeline.
*/
export interface AttentionFlapping {
transitionCount: number;
windowHours: number;
firstTransitionAt: string;
lastTransitionAt: string;
}
export interface AttentionItem {
id: string;
operationalRecordId: string;
@@ -59,6 +72,7 @@ export interface AttentionItem {
recommendedNextStep?: string;
availableActions: AttentionActionOffer[];
verificationState: AttentionVerificationState;
flapping?: AttentionFlapping;
}
export interface AttentionItemDetail {
@@ -31,12 +31,17 @@ import {
type PatrolAssistantApprovalBriefingInput,
type PatrolAssistantProposedFixBriefingInput,
} from '@/features/patrol/patrolInvestigationContextModel';
import { getFlappingPresentation } from '@/features/patrol/patrolHomePresentation';
import { InvestigationSection, ApprovalSection } from '@/components/patrol';
import { AIAPI, type RemediationPlan } from '@/api/ai';
import { createSuppressionRuleFromFinding, reinvestigateFinding } from '@/api/patrol';
import type { PatrolRunRecord, PatrolRuntimeState, PatrolAutonomyLevel } from '@/api/patrol';
import { formatRelativeTime } from '@/utils/format';
import { getFindingAlertIdentifier, hasTriggeringAlert } from '@/utils/findingAlertIdentity';
import {
getFindingAlertIdentifier,
hasTriggeringAlert,
isAlertMirroredFinding,
} from '@/utils/findingAlertIdentity';
import {
FilterSegmentedControl,
type FilterSegmentOption,
@@ -457,9 +462,16 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
return Number.isFinite(due) && due <= nowMs;
}).length;
});
// Active findings that restate an active alert (backend-stamped mirror).
// They are demoted into a collapsed group below the list rather than shown
// as separate items: the alert already owns that condition on the Patrol
// inbox and the Alerts page.
const alertMirroredFindings = createMemo(() =>
isPatrolFindingsSource() ? filteredFindings().filter(isAlertMirroredFinding) : [],
);
const patrolFindings = createMemo(() => {
if (isPatrolFindingsSource()) {
return filteredFindings();
return filteredFindings().filter((finding) => !isAlertMirroredFinding(finding));
}
return filteredFindings().filter(
(f) => f.source !== 'threshold' && !f.isThreshold && !hasTriggeringAlert(f),
@@ -1064,6 +1076,26 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
>
{severityPresentation.label}
</MetadataBadge>
{/* Flapping: the store collapsed repeated open/resolved
transitions into one row; say so instead of letting the
regression counter climb silently. */}
<Show when={finding.flapping}>
{(flapping) => (
<MetadataBadge
{...FINDING_ROW_BADGE_PROPS}
tone="warning"
title={
getFlappingPresentation(flapping().transition_count, flapping().window_hours)
.detail
}
>
{
getFlappingPresentation(flapping().transition_count, flapping().window_hours)
.label
}
</MetadataBadge>
)}
</Show>
{/* Patrol actionable state plain-language translation of
investigation state that the contract allows on the
collapsed Patrol row (approval required, investigating,
@@ -2122,7 +2154,11 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
<Card padding="none" class="overflow-hidden">
{/* Content */}
<div class="divide-y divide-border-subtle">
<Show when={patrolFindingDisplayGroups().length === 0}>
<Show
when={
patrolFindingDisplayGroups().length === 0 && alertMirroredFindings().length === 0
}
>
<div class="p-6 text-sm text-muted text-center">
<Show when={shouldShowRichEmptyState()}>
<div class="flex flex-col items-center gap-3">
@@ -2201,6 +2237,25 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
</Show>
)}
</For>
<Show when={alertMirroredFindings().length > 0}>
<details class="bg-surface-alt/40">
<summary class="flex min-h-11 cursor-pointer flex-wrap items-center justify-between gap-2 px-3 py-2 text-xs focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500">
<span class="font-semibold text-base-content">
{alertMirroredFindings().length}{' '}
{alertMirroredFindings().length === 1 ? 'finding mirrors' : 'findings mirror'}{' '}
an active alert
</span>
<span class="text-muted">
Already shown as alerts. Expand to manage the Patrol findings.
</span>
</summary>
<div class="divide-y divide-border-subtle border-t border-border-subtle">
<For each={alertMirroredFindings()}>
{(finding) => renderFindingItem(finding, false)}
</For>
</div>
</details>
</Show>
</div>
</Card>
@@ -1,4 +1,4 @@
import { A, useLocation } from '@solidjs/router';
import { useLocation } from '@solidjs/router';
import {
type Accessor,
createEffect,
@@ -20,6 +20,7 @@ import ClockIcon from 'lucide-solid/icons/clock';
import ExternalLinkIcon from 'lucide-solid/icons/external-link';
import RefreshIcon from 'lucide-solid/icons/refresh-cw';
import RotateCwIcon from 'lucide-solid/icons/rotate-cw';
import RepeatIcon from 'lucide-solid/icons/repeat';
import ShieldOffIcon from 'lucide-solid/icons/shield-off';
import SparklesIcon from 'lucide-solid/icons/sparkles';
import XIcon from 'lucide-solid/icons/x';
@@ -35,6 +36,7 @@ import {
unacknowledgePatrolAttention,
unsuppressPatrolAttention,
} from '@/api/patrolAttention';
import { createSuppressionRuleFromFinding } from '@/api/patrol';
import { ResourceActionsAPI } from '@/api/resourceActions';
import { Button, ButtonLink, CopyValueButton } from '@/components/shared/Button';
import { FormSelect } from '@/components/shared/FormSelect';
@@ -48,6 +50,7 @@ import {
getPatrolProtectionProviderLabels,
} from '@/features/patrol/patrolControlPresentation';
import { aiChatStore } from '@/stores/aiChat';
import { aiIntelligenceStore, type UnifiedFinding } from '@/stores/aiIntelligence';
import { patrolAttentionStore } from '@/stores/patrolAttention';
import {
ALERT_THRESHOLDS_PATH,
@@ -63,9 +66,14 @@ import { formatRelativeTime } from '@/utils/format';
import { copyToClipboard } from '@/utils/clipboard';
import type { PatrolAutonomyLevel } from '@/api/patrol';
import {
getAttentionFlappingPresentation,
getLinkedPatrolFindings,
getPatrolRememberedDecisionPresentation,
partitionPatrolAttention,
PATROL_AUTONOMY_EXPERIENCE,
PATROL_LASTING_DECISIONS,
type PatrolAttentionDecision,
type PatrolLastingDecision,
} from './patrolHomePresentation';
const PRIMARY_EVIDENCE_LIMIT = 3;
@@ -138,6 +146,11 @@ export function PatrolAttentionWorkbench(
autonomyLocked?: boolean;
pendingActionCount?: number;
onOpenFindings?: (item: AttentionItem) => void;
/**
* Current Patrol findings, so the selected detail can show the finding
* that mirrors this alert and offer Patrol's lasting decisions on it.
*/
findings?: () => UnifiedFinding[];
} = {},
) {
const location = useLocation();
@@ -557,6 +570,7 @@ export function PatrolAttentionWorkbench(
})
}
onOpenFindings={props.onOpenFindings}
findings={props.findings}
/>
</div>
</Show>
@@ -676,6 +690,18 @@ function AttentionList(props: {
<MetadataBadge tone={severityTone(item)} size="xs" shape="rounded">
{formatLabel(item.severity)}
</MetadataBadge>
<Show when={getAttentionFlappingPresentation(item.flapping)}>
{(flapping) => (
<MetadataBadge
tone="warning"
size="xs"
shape="rounded"
title={flapping().detail}
>
{flapping().label}
</MetadataBadge>
)}
</Show>
<Show when={props.view === 'handled'}>
<MetadataBadge tone="neutral" size="xs" shape="rounded">
{item.state === 'acknowledged' ? 'Reviewed' : 'Suppressed'}
@@ -809,10 +835,17 @@ function AttentionDetail(props: {
onSuppress: (itemId: string, reason: string, expiresAt: string) => Promise<void>;
onUnsuppress: (itemId: string) => Promise<void>;
onOpenFindings?: (item: AttentionItem) => void;
findings?: () => UnifiedFinding[];
}) {
const [copiedResourceId, setCopiedResourceId] = createSignal('');
const detail = () => props.detail;
const item = () => detail()?.item;
const linkedFindings = createMemo(() => {
const current = detail();
const findings = props.findings?.();
if (!current || !findings) return [];
return getLinkedPatrolFindings(current.item, findings);
});
const orderedEvidence = createMemo(() =>
[...(detail()?.evidence ?? [])].sort(
(left, right) => new Date(right.observedAt).getTime() - new Date(left.observedAt).getTime(),
@@ -1002,6 +1035,14 @@ function AttentionDetail(props: {
<p class="mt-3 text-sm leading-6 text-base-content">
{loaded().item.plainLanguageSummary}
</p>
<Show when={getAttentionFlappingPresentation(loaded().item.flapping)}>
{(flapping) => (
<p class="mt-2 flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50/70 px-3 py-2 text-xs leading-5 text-amber-900 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
<RepeatIcon class="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<span>{flapping().detail}</span>
</p>
)}
</Show>
<Show when={aiChatStore.enabled === true}>
<Button
variant="ghost"
@@ -1117,6 +1158,11 @@ function AttentionDetail(props: {
onUnacknowledge={props.onUnacknowledge}
onSuppress={props.onSuppress}
onUnsuppress={props.onUnsuppress}
/>
<AttentionLastingDecisions
item={loaded().item}
linkedFindings={linkedFindings()}
onOpenFindings={props.onOpenFindings}
/>
@@ -1201,26 +1247,33 @@ function AttentionDetail(props: {
</p>
}
>
<ol class="space-y-3">
<For each={loaded().timeline}>
{(transition) => (
<li class="border-l-2 border-border pl-3">
<p class="text-xs font-medium text-base-content">
{formatLabel(transition.from)} to {formatLabel(transition.to)}
</p>
<p class="mt-0.5 text-[11px] text-muted">
{formatRelativeTime(transition.at, { compact: true })} ·{' '}
{formatLabel(transition.cause)}
</p>
<Show when={transition.reason}>
{(reason) => (
<p class="mt-1 text-xs leading-5 text-muted">{reason()}</p>
)}
</Show>
</li>
)}
</For>
</ol>
<Show
when={loaded().item.flapping}
fallback={<AttentionTimelineList timeline={loaded().timeline} />}
>
{(flapping) => (
<>
<p class="text-xs leading-5 text-base-content">
<span class="font-semibold">Flapping.</span>{' '}
{flapping().transitionCount} open/resolved changes in the last{' '}
{flapping().windowHours} hours, first{' '}
{formatRelativeTime(flapping().firstTransitionAt, { compact: true })}{' '}
and latest{' '}
{formatRelativeTime(flapping().lastTransitionAt, { compact: true })}.
The transitions are collapsed here so one noisy issue reads as one
issue.
</p>
<details class="mt-2 rounded-md border border-border-subtle bg-surface-alt/30">
<summary class="flex min-h-11 cursor-pointer items-center px-3 py-2 text-xs font-medium text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 sm:min-h-0">
Show all {loaded().timeline.length} transitions
</summary>
<div class="border-t border-border-subtle p-3">
<AttentionTimelineList timeline={loaded().timeline} />
</div>
</details>
</>
)}
</Show>
</Show>
</DetailSection>
</div>
@@ -1246,7 +1299,6 @@ function AttentionLifecycleControls(props: {
onUnacknowledge: (itemId: string) => Promise<void>;
onSuppress: (itemId: string, reason: string, expiresAt: string) => Promise<void>;
onUnsuppress: (itemId: string) => Promise<void>;
onOpenFindings?: (item: AttentionItem) => void;
}) {
const [showSuppression, setShowSuppression] = createSignal(false);
const [reason, setReason] = createSignal('');
@@ -1343,22 +1395,11 @@ function AttentionLifecycleControls(props: {
</Button>
</Show>
</div>
<Show when={props.onOpenFindings}>
<div class="mt-3 flex flex-col gap-3 rounded-md border border-border-subtle bg-surface px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-xs leading-5 text-muted">
Need a lasting outcome for a Patrol finding on this resource? Resolve it, dismiss it
as not an issue, remember expected behavior, or create a suppression rule.
</p>
<Button
variant="secondary"
size="sm"
class="min-h-11 shrink-0 sm:min-h-0"
onClick={() => props.onOpenFindings?.(props.detail.item)}
>
Find lasting options for this resource
</Button>
</div>
</Show>
<p class="mt-3 text-xs leading-5 text-muted">
Both of these are short-term. Mark reviewed hides this occurrence until it resolves or you
return it to the inbox. Suppression hides it only until the time you pick. Lasting
decisions are below.
</p>
<Show when={showSuppression()}>
<form
class="mt-3 space-y-3 border-t border-border-subtle pt-3"
@@ -1419,33 +1460,297 @@ function AttentionLifecycleControls(props: {
{props.error}
</p>
</Show>
<details class="mt-3 border-t border-border-subtle pt-2 text-xs leading-5 text-muted">
<summary class="min-h-11 cursor-pointer py-2 font-medium text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 sm:min-h-0">
More ways to manage this issue
</summary>
<div class="pb-1">
<p>
Mark reviewed removes this occurrence from the decision inbox until it resolves or you
return it to open. Suppression hides it only until the selected return time. Both
remain available under Reviewed and suppressed.
</p>
<p class="mt-1">
For a permanent change,{' '}
<A
href={ALERT_THRESHOLDS_PATH}
class="font-medium text-blue-700 hover:underline dark:text-blue-300"
>
adjust alert thresholds
</A>{' '}
to change or turn off this alert for the affected resource.
</p>
</div>
</details>
</div>
</DetailSection>
);
}
function AttentionTimelineList(props: { timeline: AttentionItemDetail['timeline'] }) {
return (
<ol class="space-y-3">
<For each={props.timeline}>
{(transition) => (
<li class="border-l-2 border-border pl-3">
<p class="text-xs font-medium text-base-content">
{formatLabel(transition.from)} to {formatLabel(transition.to)}
</p>
<p class="mt-0.5 text-[11px] text-muted">
{formatRelativeTime(transition.at, { compact: true })} ·{' '}
{formatLabel(transition.cause)}
</p>
<Show when={transition.reason}>
{(reason) => <p class="mt-1 text-xs leading-5 text-muted">{reason()}</p>}
</Show>
</li>
)}
</For>
</ol>
);
}
/**
* Patrol's durable decisions, offered where the operator already is. They act
* on the Patrol finding that mirrors this alert: the alert lifecycle above is
* reversible bookkeeping, while these teach Patrol what is normal here. An
* alert with no Patrol finding attached has nothing for Patrol to remember,
* so the section says that and points at alert thresholds instead of
* offering buttons that would do nothing.
*/
function AttentionLastingDecisions(props: {
item: AttentionItem;
linkedFindings: UnifiedFinding[];
onOpenFindings?: (item: AttentionItem) => void;
}) {
const [pending, setPending] = createSignal<PatrolLastingDecision | null>(null);
const [note, setNote] = createSignal('');
const [busy, setBusy] = createSignal(false);
const [error, setError] = createSignal('');
const [notice, setNotice] = createSignal('');
const activeFinding = createMemo(() =>
props.linkedFindings.find((finding) => finding.status === 'active' && !finding.dismissedReason),
);
const rememberedFinding = createMemo(() =>
props.linkedFindings.find((finding) => Boolean(finding.dismissedReason)),
);
const cancel = () => {
setPending(null);
setNote('');
setError('');
};
const choose = (decision: PatrolLastingDecision) => {
setPending(decision);
setNote('');
setError('');
setNotice('');
};
const confirm = async (event: SubmitEvent) => {
event.preventDefault();
const decision = pending();
const finding = activeFinding();
if (!decision || !finding || busy()) return;
const reason = note().trim();
if (decision.requiresReason && !reason) return;
setBusy(true);
setError('');
try {
if (decision.kind === 'create_rule') {
await createSuppressionRuleFromFinding({
resourceId: finding.resourceId,
resourceName: finding.resourceName,
category: finding.category,
description: reason,
});
await aiIntelligenceStore.loadPatrolFindings();
setNotice(
`Rule created. Future ${finding.category} findings on ${finding.resourceName} are dismissed automatically.`,
);
} else {
const saved = await aiIntelligenceStore.dismissFinding(
finding.id,
decision.kind,
reason || undefined,
);
if (!saved) throw new Error('Patrol could not save this decision.');
setNotice(`${decision.label} saved. ${decision.explanation}`);
}
cancel();
} catch (cause) {
setError(cause instanceof Error ? cause.message : 'The decision could not be saved.');
} finally {
setBusy(false);
}
};
const reopen = async (finding: UnifiedFinding) => {
if (busy()) return;
setBusy(true);
setError('');
try {
const reopened = await aiIntelligenceStore.reopenFinding(finding.id);
if (!reopened) throw new Error('Patrol could not reopen this finding.');
setNotice('Finding reopened. Patrol will raise it again.');
} catch (cause) {
setError(cause instanceof Error ? cause.message : 'The finding could not be reopened.');
} finally {
setBusy(false);
}
};
return (
<DetailSection title="Lasting decisions">
<div class="rounded-md border border-border-subtle bg-surface-alt/40 p-3">
<Show when={notice()}>
{(message) => (
<p
role="status"
class="mb-3 flex items-start gap-2 text-xs leading-5 text-emerald-800 dark:text-emerald-200"
>
<CheckCircleIcon class="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<span>{message()}</span>
</p>
)}
</Show>
<Show
when={activeFinding()}
fallback={
<Show
when={rememberedFinding()}
fallback={
<AttentionAlertOnlyGuidance
item={props.item}
onOpenFindings={props.onOpenFindings}
/>
}
>
{(finding) => {
const remembered = () => getPatrolRememberedDecisionPresentation(finding());
return (
<div>
<p class="text-xs leading-5 text-base-content">
<span class="font-semibold">{remembered()?.headline}.</span>{' '}
{remembered()?.detail}
</p>
<p class="mt-1 text-xs leading-5 text-muted">
Patrol finding: {finding().title}
<Show when={finding().userNote}>{(value) => <> · Note: {value()}</>}</Show>
</p>
<Button
variant="secondary"
size="sm"
class="mt-2 min-h-11 sm:min-h-0"
isLoading={busy()}
onClick={() => void reopen(finding())}
>
Reopen finding
</Button>
</div>
);
}}
</Show>
}
>
{(finding) => (
<>
<p class="text-xs leading-5 text-muted">
Patrol also raised{' '}
<span class="font-medium text-base-content">{finding().title}</span> for this
resource. Decide once here and Patrol remembers it. The alert above keeps its own
lifecycle.
</p>
<ul class="mt-2 divide-y divide-border-subtle" aria-label="Lasting decisions">
<For each={PATROL_LASTING_DECISIONS}>
{(decision) => (
<li class="flex flex-col gap-1 py-2 sm:flex-row sm:items-start sm:gap-3">
<Button
variant={decision.kind === 'expected_behavior' ? 'primary' : 'secondary'}
size="sm"
class="min-h-11 shrink-0 justify-start sm:min-h-0 sm:w-48"
disabled={busy()}
aria-pressed={pending()?.kind === decision.kind}
onClick={() => choose(decision)}
>
{decision.label}
</Button>
<p class="text-xs leading-5 text-muted sm:pt-1">{decision.explanation}</p>
</li>
)}
</For>
</ul>
<Show when={pending()}>
{(decision) => (
<form
class="mt-3 space-y-3 border-t border-border-subtle pt-3"
onSubmit={confirm}
aria-label={`Confirm ${decision().label}`}
>
<FormTextarea
id={`attention-lasting-note-${props.item.id}`}
label={
decision().requiresReason
? 'Why this rule? Patrol shows the reason wherever the rule is listed.'
: 'Note for Patrol (optional). Patrol reads it on future checks.'
}
density="compact"
fieldBaseClass="block"
labelClass="text-xs"
textareaClass="mt-1 min-h-20"
value={note()}
maxlength={240}
required={decision().requiresReason}
disabled={busy()}
onInput={(event) => setNote(event.currentTarget.value)}
/>
<div class="flex flex-wrap gap-2">
<Button
variant="primary"
size="sm"
class="min-h-11 sm:min-h-0"
type="submit"
isLoading={busy()}
disabled={decision().requiresReason && !note().trim()}
>
Confirm: {decision().label}
</Button>
<Button
variant="ghost"
size="sm"
class="min-h-11 sm:min-h-0"
disabled={busy()}
onClick={cancel}
>
Cancel
</Button>
</div>
</form>
)}
</Show>
</>
)}
</Show>
<Show when={error()}>
<p role="alert" class="mt-3 text-xs leading-5 text-red-700 dark:text-red-300">
{error()}
</p>
</Show>
</div>
</DetailSection>
);
}
function AttentionAlertOnlyGuidance(props: {
item: AttentionItem;
onOpenFindings?: (item: AttentionItem) => void;
}) {
return (
<div>
<p class="text-xs leading-5 text-muted">
This is an alert and Patrol has not raised a finding about it, so there is nothing for
Patrol to remember here. To change it for good, adjust when this alert fires or turn it off
for {props.item.subjectResourceName} under alert thresholds.
</p>
<div class="mt-2 flex flex-wrap gap-2">
<ButtonLink
href={ALERT_THRESHOLDS_PATH}
variant="secondary"
size="sm"
class="min-h-11 sm:min-h-0"
>
Adjust alert thresholds
</ButtonLink>
<Show when={props.onOpenFindings}>
<Button
variant="ghost"
size="sm"
class="min-h-11 sm:min-h-0"
onClick={() => props.onOpenFindings?.(props.item)}
>
Browse Patrol findings for this resource
</Button>
</Show>
</div>
</div>
);
}
function ActionVerificationMessage(props: { state: AttentionItem['verificationState'] }) {
const message = () => {
switch (props.state) {
@@ -5,6 +5,7 @@ import HistoryIcon from 'lucide-solid/icons/history';
import { ButtonLink } from '@/components/shared/Button';
import { MetadataBadge } from '@/components/shared/MetadataBadge';
import { actionInboxStore } from '@/stores/actionInbox';
import { aiIntelligenceStore } from '@/stores/aiIntelligence';
import { usePatrolIntelligenceState } from './usePatrolIntelligenceState';
import { PatrolIntelligenceHeader } from './PatrolIntelligenceHeader';
import { PatrolIntelligenceBanners } from './PatrolIntelligenceBanners';
@@ -111,6 +112,7 @@ export function PatrolIntelligenceSurface() {
autonomyLocked={state.autoFixLocked()}
pendingActionCount={actionInboxStore.pendingActionCount}
onOpenFindings={openFindings}
findings={() => aiIntelligenceStore.patrolFindings}
/>
</Show>
</div>
@@ -18,8 +18,31 @@ const apiMocks = vi.hoisted(() => ({
unsuppress: vi.fn(),
planAction: vi.fn(),
getAction: vi.fn(),
dismissFinding: vi.fn(),
reopenFinding: vi.fn(),
loadPatrolFindings: vi.fn(),
createRule: vi.fn(),
}));
vi.mock('@/stores/aiIntelligence', () => ({
aiIntelligenceStore: {
dismissFinding: (...args: unknown[]) => apiMocks.dismissFinding(...args),
reopenFinding: (...args: unknown[]) => apiMocks.reopenFinding(...args),
loadPatrolFindings: (...args: unknown[]) => apiMocks.loadPatrolFindings(...args),
get patrolFindings() {
return [];
},
},
}));
vi.mock('@/api/patrol', async (importOriginal) => {
const original = await importOriginal<typeof import('@/api/patrol')>();
return {
...original,
createSuppressionRuleFromFinding: (...args: unknown[]) => apiMocks.createRule(...args),
};
});
vi.mock('@/api/patrolAttention', async (importOriginal) => {
const original = await importOriginal<typeof import('@/api/patrolAttention')>();
return {
@@ -73,6 +96,7 @@ import {
sortPatrolAttentionDecisions,
} from '../PatrolAttentionWorkbench';
import { patrolAttentionStore } from '@/stores/patrolAttention';
import type { UnifiedFinding } from '@/stores/aiIntelligence';
const evaluatedAt = '2026-07-19T08:00:00Z';
@@ -124,6 +148,23 @@ const item = (overrides: Partial<AttentionItem> = {}): AttentionItem => ({
...overrides,
});
const mirroredFinding = (overrides: Partial<UnifiedFinding> = {}): UnifiedFinding => ({
id: 'finding-1',
source: 'ai-patrol',
resourceId: 'pve:vm:101',
resourceName: 'Database VM',
resourceType: 'vm',
category: 'capacity',
severity: 'warning',
title: 'Disk on Database VM is 95% full',
description: 'The root volume has been filling for a week.',
detectedAt: '2026-07-19T07:00:00Z',
status: 'active',
mirrorsAlertId: 'alert-record-1',
mirrorsAlertType: 'disk',
...overrides,
});
const listResponse = (
items: AttentionItem[],
responseSummary: AttentionSummary,
@@ -189,6 +230,10 @@ describe('PatrolAttentionWorkbench', () => {
apiMocks.unsuppress.mockReset();
apiMocks.planAction.mockReset();
apiMocks.getAction.mockReset();
apiMocks.dismissFinding.mockReset();
apiMocks.reopenFinding.mockReset();
apiMocks.loadPatrolFindings.mockReset();
apiMocks.createRule.mockReset();
});
afterEach(() => {
@@ -686,18 +731,18 @@ describe('PatrolAttentionWorkbench', () => {
).toBeInTheDocument();
});
it('keeps durable Patrol finding outcomes visible beside alert lifecycle controls', async () => {
it('offers every lasting Patrol decision on the finding that mirrors the alert', async () => {
const active = item();
apiMocks.getList.mockResolvedValue(
listResponse([active], summary({ activeCount: 1, openCount: 1, calm: false })),
);
apiMocks.getDetail.mockResolvedValue(detail(active));
const onOpenFindings = vi.fn();
apiMocks.dismissFinding.mockResolvedValue(true);
render(() => (
<Router>
<Route
path="/patrol"
component={() => <PatrolAttentionWorkbench onOpenFindings={onOpenFindings} />}
component={() => <PatrolAttentionWorkbench findings={() => [mirroredFinding()]} />}
/>
</Router>
));
@@ -708,43 +753,211 @@ describe('PatrolAttentionWorkbench', () => {
}),
);
expect(await screen.findByText('Lasting decisions')).toBeInTheDocument();
expect(screen.getByText(/Patrol also raised/i)).toHaveTextContent(
'Disk on Database VM is 95% full',
);
const decisions = within(screen.getByRole('list', { name: 'Lasting decisions' }));
for (const label of [
'Remember as expected',
'Dismiss: Not an issue',
'Dismiss: Later',
'Create rule',
]) {
expect(decisions.getByRole('button', { name: label })).toBeInTheDocument();
}
expect(decisions.getByText(/stops raising it unless it gets worse/i)).toHaveTextContent(
/Lasts until you reopen the finding/i,
);
expect(decisions.getByText(/Hides this for 7 days/i)).toBeInTheDocument();
expect(
await screen.findByText(/Need a lasting outcome for a Patrol finding on this resource/i),
).toHaveTextContent(/remember expected behavior/i);
fireEvent.click(screen.getByRole('button', { name: 'Find lasting options for this resource' }));
expect(onOpenFindings).toHaveBeenCalledWith(active);
decisions.getByText(/Permanent rule for this resource and category/i),
).toBeInTheDocument();
// The temporary controls say so in one line instead of hiding it behind a toggle.
expect(screen.getByText(/Both of these are short-term/i)).toBeInTheDocument();
expect(screen.queryByText('More ways to manage this issue')).not.toBeInTheDocument();
fireEvent.click(screen.getByText('More ways to manage this issue'));
expect(screen.getByText(/Mark reviewed removes this occurrence/i)).toHaveTextContent(
/until it resolves or you return it to open/i,
fireEvent.click(decisions.getByRole('button', { name: 'Remember as expected' }));
const form = screen.getByRole('form', { name: 'Confirm Remember as expected' });
fireEvent.input(within(form).getByLabelText(/Note for Patrol/i), {
target: { value: 'This VM is a cold standby.' },
});
fireEvent.click(within(form).getByRole('button', { name: 'Confirm: Remember as expected' }));
await waitFor(() =>
expect(apiMocks.dismissFinding).toHaveBeenCalledWith(
'finding-1',
'expected_behavior',
'This VM is a cold standby.',
),
);
expect(screen.getByRole('link', { name: 'adjust alert thresholds' })).toHaveAttribute(
'href',
'/alerts/thresholds',
);
expect(
screen.queryByRole('button', { name: 'review Patrol findings' }),
).not.toBeInTheDocument();
expect(await screen.findByRole('status')).toHaveTextContent(/Remember as expected saved/i);
});
it('omits the findings pointer when no findings surface is wired', async () => {
it('requires a written reason before creating a permanent rule from the detail', async () => {
const active = item();
apiMocks.getList.mockResolvedValue(
listResponse([active], summary({ activeCount: 1, openCount: 1, calm: false })),
);
apiMocks.getDetail.mockResolvedValue(detail(active));
renderWorkbench();
apiMocks.createRule.mockResolvedValue({ success: true, message: 'ok', rule: { id: 'r1' } });
apiMocks.loadPatrolFindings.mockResolvedValue(undefined);
render(() => (
<Router>
<Route
path="/patrol"
component={() => <PatrolAttentionWorkbench findings={() => [mirroredFinding()]} />}
/>
</Router>
));
fireEvent.click(
await screen.findByRole('button', {
name: 'Open Database VM · Disk pressure',
}),
);
const decisions = within(await screen.findByRole('list', { name: 'Lasting decisions' }));
fireEvent.click(decisions.getByRole('button', { name: 'Create rule' }));
const form = screen.getByRole('form', { name: 'Confirm Create rule' });
const confirm = within(form).getByRole('button', { name: 'Confirm: Create rule' });
expect(confirm).toBeDisabled();
fireEvent.input(within(form).getByLabelText(/Why this rule/i), {
target: { value: 'Archive volume is meant to run near full.' },
});
expect(confirm).not.toBeDisabled();
fireEvent.click(confirm);
await waitFor(() =>
expect(apiMocks.createRule).toHaveBeenCalledWith({
resourceId: 'pve:vm:101',
resourceName: 'Database VM',
category: 'capacity',
description: 'Archive volume is meant to run near full.',
}),
);
expect(apiMocks.loadPatrolFindings).toHaveBeenCalled();
expect(await screen.findByRole('status')).toHaveTextContent(/Rule created/i);
});
it('shows the remembered decision and a reopen path instead of re-offering it', async () => {
const active = item();
apiMocks.getList.mockResolvedValue(
listResponse([active], summary({ activeCount: 1, openCount: 1, calm: false })),
);
apiMocks.getDetail.mockResolvedValue(detail(active));
apiMocks.reopenFinding.mockResolvedValue(true);
render(() => (
<Router>
<Route
path="/patrol"
component={() => (
<PatrolAttentionWorkbench
findings={() => [
mirroredFinding({
status: 'dismissed',
dismissedReason: 'expected_behavior',
userNote: 'Cold standby.',
}),
]}
/>
)}
/>
</Router>
));
fireEvent.click(
await screen.findByRole('button', {
name: 'Open Database VM · Disk pressure',
}),
);
expect(await screen.findByText(/Mark reviewed removes this occurrence/i)).toBeInTheDocument();
expect(await screen.findByText(/Remembered as expected/i)).toBeInTheDocument();
expect(screen.getByText(/Note: Cold standby/i)).toBeInTheDocument();
expect(screen.queryByRole('list', { name: 'Lasting decisions' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Reopen finding' }));
await waitFor(() => expect(apiMocks.reopenFinding).toHaveBeenCalledWith('finding-1'));
});
it('explains that an alert with no Patrol finding has nothing for Patrol to remember', async () => {
const active = item();
apiMocks.getList.mockResolvedValue(
listResponse([active], summary({ activeCount: 1, openCount: 1, calm: false })),
);
apiMocks.getDetail.mockResolvedValue(detail(active));
const onOpenFindings = vi.fn();
render(() => (
<Router>
<Route
path="/patrol"
component={() => (
<PatrolAttentionWorkbench findings={() => []} onOpenFindings={onOpenFindings} />
)}
/>
</Router>
));
fireEvent.click(
await screen.findByRole('button', {
name: 'Open Database VM · Disk pressure',
}),
);
expect(await screen.findByText(/nothing for Patrol to remember here/i)).toHaveTextContent(
/turn it off for Database VM under alert thresholds/i,
);
expect(screen.queryByRole('list', { name: 'Lasting decisions' })).not.toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Adjust alert thresholds' })).toHaveAttribute(
'href',
'/alerts/thresholds',
);
fireEvent.click(
screen.getByRole('button', { name: 'Browse Patrol findings for this resource' }),
);
expect(onOpenFindings).toHaveBeenCalledWith(active);
});
it('collapses a flapping item into one label and a summarised timeline', async () => {
const flapping = item({
id: 'record-flap',
operationalRecordId: 'record-flap',
kind: 'docker-container-state',
title: 'Docker container state on shlink-web-clientX',
subjectResourceName: 'shlink-web-clientX',
severity: 'warning',
flapping: {
transitionCount: 11,
windowHours: 24,
firstTransitionAt: '2026-07-18T09:00:00Z',
lastTransitionAt: '2026-07-19T07:30:00Z',
},
});
const flappingDetail = detail(flapping);
flappingDetail.timeline = Array.from({ length: 11 }, (_, index) => ({
id: `flap-${index}`,
operationalRecordId: 'record-flap',
from: index % 2 === 0 ? 'open' : 'resolved',
to: index % 2 === 0 ? 'resolved' : 'open',
at: `2026-07-18T${String(9 + index).padStart(2, '0')}:00:00Z`,
cause: index % 2 === 0 ? 'recovery_evidence' : 'detector_decision',
causeKey: 'runtime-state',
evidenceIds: [],
}));
apiMocks.getList.mockResolvedValue(
listResponse([flapping], summary({ activeCount: 1, openCount: 1, calm: false })),
);
apiMocks.getDetail.mockResolvedValue(flappingDetail);
renderWorkbench();
const row = await screen.findByRole('button', {
name: 'Open shlink-web-clientX · Docker container state',
});
expect(within(row).getByText('Flapping · 11 changes in 24h')).toBeInTheDocument();
fireEvent.click(row);
expect(
screen.queryByRole('button', { name: 'review Patrol findings' }),
).not.toBeInTheDocument();
await screen.findByText(/switched between open and resolved 11 times in the last day/i),
).toBeInTheDocument();
fireEvent.click(screen.getByText('Evidence and history'));
expect(screen.getByText(/11 open\/resolved changes in the last 24 hours/i)).toBeInTheDocument();
expect(screen.getByText('Show all 11 transitions')).toBeInTheDocument();
});
it('opens the canonical governed action review from an eligible attention item', async () => {
@@ -117,7 +117,9 @@ describe('usePatrolIntelligenceState', () => {
expect(patrolIntelligenceStateSource).toContain('async function loadVisiblePatrolData()');
expect(patrolIntelligenceStateSource).toContain('refetchPatrolStatus()');
expect(patrolIntelligenceStateSource).toContain('aiIntelligenceStore.loadPatrolFindings()');
expect(patrolIntelligenceStateSource).toContain(
'aiIntelligenceStore.loadPatrolFindings({ includeResolved: true })',
);
expect(patrolIntelligenceStateSource).toContain('aiIntelligenceStore.loadPendingApprovals()');
expect(patrolIntelligenceStateSource).toContain(
'patrolRunHistory.refetch({ background: true })',
@@ -1,5 +1,6 @@
import type { PatrolAutonomyLevel, PatrolObjective } from '@/api/patrol';
import type { AttentionItem } from '@/api/patrolAttention';
import type { AttentionFlapping, AttentionItem } from '@/api/patrolAttention';
import type { UnifiedFinding } from '@/stores/aiIntelligence';
export interface PatrolAutonomyExperience {
label: string;
@@ -174,3 +175,155 @@ export function getPatrolObjectiveProtectionSummary(
tone: 'warning',
};
}
export interface FlappingPresentation {
label: string;
detail: string;
}
const formatFlappingWindow = (windowHours: number): string =>
windowHours === 24 ? 'the last day' : `the last ${windowHours} hours`;
/**
* One label for flapping across alerts and Patrol findings. The backend
* decides *whether* an item is flapping (four or more open/resolved
* transitions in 24 hours); this only phrases the count it reports.
*/
export function getFlappingPresentation(
transitionCount: number,
windowHours: number,
): FlappingPresentation {
const changes = transitionCount === 1 ? 'change' : 'changes';
return {
label: `Flapping · ${transitionCount} ${changes} in ${windowHours}h`,
detail: `This issue has switched between open and resolved ${transitionCount} times in ${formatFlappingWindow(windowHours)}. It is shown once here. Every transition is listed under the timeline.`,
};
}
export function getAttentionFlappingPresentation(
flapping: AttentionFlapping | undefined,
): FlappingPresentation | null {
if (!flapping || flapping.transitionCount <= 0) return null;
return getFlappingPresentation(flapping.transitionCount, flapping.windowHours);
}
const normalizeResourceKey = (value: string | undefined): string =>
(value ?? '').trim().toLowerCase();
/**
* Patrol findings that restate this attention item's alert: same resource and
* the backend-stamped mirror type equals the alert kind. Resolved findings are
* history and are not linked.
*/
export function getLinkedPatrolFindings(
item: AttentionItem,
findings: readonly UnifiedFinding[],
): UnifiedFinding[] {
const resource = normalizeResourceKey(item.subjectResourceId);
const kind = normalizeResourceKey(item.kind);
if (!resource || !kind) return [];
return findings.filter(
(finding) =>
finding.status !== 'resolved' &&
Boolean(finding.mirrorsAlertId) &&
normalizeResourceKey(finding.resourceId) === resource &&
normalizeResourceKey(finding.mirrorsAlertType) === kind,
);
}
export type PatrolLastingDecisionKind =
'expected_behavior' | 'not_an_issue' | 'will_fix_later' | 'create_rule';
export interface PatrolLastingDecision {
kind: PatrolLastingDecisionKind;
label: string;
/** One line: what it does and how long it lasts. */
explanation: string;
/** Whether the inline confirmation requires a written reason. */
requiresReason: boolean;
}
/**
* The durable Patrol outcomes, in the order a first-time user should read
* them: the most common "this is normal here" first, the permanent rule last.
* Durations mirror internal/ai/findings.go: expected_behavior and not_an_issue
* hold until the finding is reopened (severity escalation still reactivates
* expected_behavior); will_fix_later reminds after seven days; a rule is
* permanent until deleted under Patrol suppression rules.
*/
export const PATROL_LASTING_DECISIONS: readonly PatrolLastingDecision[] = [
{
kind: 'expected_behavior',
label: 'Remember as expected',
explanation:
'Patrol treats this as normal for this resource and stops raising it unless it gets worse. Lasts until you reopen the finding.',
requiresReason: false,
},
{
kind: 'not_an_issue',
label: 'Dismiss: Not an issue',
explanation:
'Marks the detection wrong and silences similar findings on this resource. Lasts until you reopen the finding.',
requiresReason: false,
},
{
kind: 'will_fix_later',
label: 'Dismiss: Later',
explanation: 'Hides this for 7 days. If it is still happening after that, Patrol reminds you.',
requiresReason: false,
},
{
kind: 'create_rule',
label: 'Create rule',
explanation:
'Permanent rule for this resource and category: matching findings are dismissed automatically until you delete the rule under Patrol suppression rules.',
requiresReason: true,
},
];
export interface PatrolRememberedDecisionPresentation {
headline: string;
detail: string;
}
/**
* Describes a finding Patrol already remembers a decision for, so the
* attention detail can say what was decided instead of offering it again.
*/
export function getPatrolRememberedDecisionPresentation(
finding: UnifiedFinding,
now: Date = new Date(),
): PatrolRememberedDecisionPresentation | null {
if (finding.status !== 'dismissed' && !finding.dismissedReason) return null;
switch (finding.dismissedReason) {
case 'expected_behavior':
return {
headline: 'Remembered as expected',
detail:
'Patrol treats this as normal for this resource and will only raise it again if it gets worse.',
};
case 'not_an_issue':
return {
headline: 'Dismissed as not an issue',
detail: 'Similar findings on this resource stay silent until you reopen this one.',
};
case 'will_fix_later': {
const remindAt = finding.remindAt ? new Date(finding.remindAt) : undefined;
const overdue =
remindAt !== undefined && !Number.isNaN(remindAt.getTime()) && remindAt <= now;
return {
headline: overdue ? 'Reminder due' : 'Dismissed until later',
detail: overdue
? 'You said you would fix this later and the reminder date has passed. It is still happening.'
: remindAt && !Number.isNaN(remindAt.getTime())
? `Patrol stays quiet and reminds you on ${remindAt.toLocaleDateString()} if it is still happening.`
: 'Patrol stays quiet and reminds you if it is still happening after the reminder date.',
};
}
default:
return {
headline: 'Dismissed',
detail: 'Patrol remembers this decision until you reopen the finding.',
};
}
}
@@ -807,6 +807,13 @@ export function usePatrolIntelligenceState() {
try {
await Promise.all([
// The Patrol surface needs dismissed findings too: the attention
// detail shows the decision Patrol already remembers for a mirrored
// finding, and the store's default load is active-only. Listed first
// so it flips the store's sticky include-resolved preference before
// loadDashboardData issues its own findings read; otherwise the two
// responses race and the active-only one can win.
aiIntelligenceStore.loadPatrolFindings({ includeResolved: true }),
aiIntelligenceStore.loadDashboardData(),
refetchPatrolStatus(),
loadAutonomySettings(),
@@ -828,7 +835,10 @@ export function usePatrolIntelligenceState() {
try {
await Promise.all([
refetchPatrolStatus(),
aiIntelligenceStore.loadPatrolFindings(),
// Include dismissed and resolved findings: the attention detail shows
// the decision Patrol already remembers for a mirrored finding, and a
// dismissed finding is not in the active-only default set.
aiIntelligenceStore.loadPatrolFindings({ includeResolved: true }),
aiIntelligenceStore.loadPendingApprovals(),
patrolRunHistory.refetch({ background: true }),
]);
@@ -20,6 +20,7 @@ import {
getPatrolFindings,
type Finding as PatrolFinding,
type CapacityForecast,
type FindingFlapping,
} from '@/api/patrol';
import type { ResourceCriticality } from '@/api/resourceOperatorState';
import type {
@@ -215,6 +216,15 @@ export interface UnifiedFinding {
// independently of the model prose. Surfaced as a first-class urgency
// signal on capacity-relevant findings.
capacityForecast?: CapacityForecast;
// flapping is set while the finding keeps switching between open and
// resolved (four or more transitions in 24 hours). The lifecycle log holds
// one collapsed "flapping" row instead of one row per transition.
flapping?: FindingFlapping;
// mirrorsAlertId / mirrorsAlertType identify the active alert this finding
// restates. Surfaces fold such findings under the alert rather than listing
// the same problem twice.
mirrorsAlertId?: string;
mirrorsAlertType?: string;
}
const [unifiedFindings, setUnifiedFindings] = createSignal<UnifiedFinding[]>([]);
@@ -323,6 +333,9 @@ function normalizePatrolFindingRecord(item: PatrolFinding, now: number): Unified
regressionCount: item.regression_count || 0,
lastRegressionAt: item.last_regression_at || undefined,
capacityForecast: item.capacity_forecast,
flapping: item.flapping,
mirrorsAlertId: item.mirrors_alert_id || undefined,
mirrorsAlertType: item.mirrors_alert_type || undefined,
};
}
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest';
import { getFindingAlertIdentifier, hasTriggeringAlert } from '@/utils/findingAlertIdentity';
import {
getFindingAlertIdentifier,
hasTriggeringAlert,
isAlertMirroredFinding,
} from '@/utils/findingAlertIdentity';
describe('findingAlertIdentity', () => {
it('prefers canonical alertIdentifier when present', () => {
@@ -18,4 +22,19 @@ describe('findingAlertIdentity', () => {
it('detects when a finding was triggered by an alert', () => {
expect(hasTriggeringAlert({ alertIdentifier: 'instance:node:100::metric/cpu' })).toBe(true);
});
it('demotes only active findings that Patrol stamped as mirroring an alert', () => {
expect(isAlertMirroredFinding({ mirrorsAlertId: 'usage-storage-1', status: 'active' })).toBe(
true,
);
expect(isAlertMirroredFinding({ mirrorsAlertId: ' ', status: 'active' })).toBe(false);
expect(isAlertMirroredFinding({ status: 'active' })).toBe(false);
// A remembered or resolved mirrored finding is Patrol history, not a duplicate item.
expect(isAlertMirroredFinding({ mirrorsAlertId: 'usage-storage-1', status: 'dismissed' })).toBe(
false,
);
expect(isAlertMirroredFinding({ mirrorsAlertId: 'usage-storage-1', status: 'resolved' })).toBe(
false,
);
});
});
@@ -10,3 +10,20 @@ export const getFindingAlertIdentifier = (finding: FindingAlertIdentity): string
export const hasTriggeringAlert = (finding: FindingAlertIdentity): boolean =>
getFindingAlertIdentifier(finding) !== undefined;
type FindingAlertMirror = {
mirrorsAlertId?: string;
status?: string;
};
/**
* True when Patrol stamped this finding as restating an active alert (same
* resource, same condition) and the finding is still active. Such findings are
* demoted under the alert instead of listed as separate items; dismissed and
* resolved findings keep their normal place because those views are about
* Patrol's own history.
*/
export const isAlertMirroredFinding = (finding: FindingAlertMirror): boolean =>
typeof finding.mirrorsAlertId === 'string' &&
finding.mirrorsAlertId.trim() !== '' &&
finding.status === 'active';
+52
View File
@@ -75,6 +75,18 @@ type AttentionResource struct {
ResourceID string `json:"resourceId"`
}
// AttentionFlapping summarises an item whose lifecycle keeps moving between
// open and resolved. It uses the same window and threshold as finding
// flapping (findings_storm_throttler.go) so alerts and Patrol findings agree
// on the label. The full transition list stays in the detail timeline; this
// is the collapsed operator-facing summary.
type AttentionFlapping struct {
TransitionCount int `json:"transitionCount"`
WindowHours int `json:"windowHours"`
FirstTransitionAt time.Time `json:"firstTransitionAt"`
LastTransitionAt time.Time `json:"lastTransitionAt"`
}
type AttentionItem struct {
ID string `json:"id"`
OperationalRecordID string `json:"operationalRecordId"`
@@ -96,6 +108,7 @@ type AttentionItem struct {
RecommendedNextStep string `json:"recommendedNextStep,omitempty"`
AvailableActions []AttentionActionOffer `json:"availableActions"`
VerificationState AttentionVerificationState `json:"verificationState"`
Flapping *AttentionFlapping `json:"flapping,omitempty"`
}
type AttentionItemDetail struct {
@@ -244,6 +257,7 @@ func projectAttentionAlert(
RecommendedNextStep: record.RecommendedNextStep,
AvailableActions: []AttentionActionOffer{},
VerificationState: AttentionVerificationNotAvailable,
Flapping: attentionFlapping(timeline, now),
}
return AttentionItemDetail{
Item: item,
@@ -253,6 +267,44 @@ func projectAttentionAlert(
}, true
}
// attentionFlapping collapses open/resolved churn in the lifecycle timeline
// into one summary when it crosses the shared flapping threshold. Only
// transitions that open or resolve the record count; acknowledgement,
// suppression, and evidence refreshes are operator or detector bookkeeping,
// not the condition coming and going.
func attentionFlapping(
timeline []operationaltrust.LifecycleTransition,
now time.Time,
) *AttentionFlapping {
transitions := make([]time.Time, 0, len(timeline))
for _, transition := range timeline {
if !attentionTransitionIsFlap(transition) {
continue
}
transitions = append(transitions, transition.At)
}
summary := summarizeFlapTransitions(transitions, now)
if summary == nil {
return nil
}
return &AttentionFlapping{
TransitionCount: summary.TransitionCount,
WindowHours: summary.WindowHours,
FirstTransitionAt: summary.FirstTransitionAt,
LastTransitionAt: summary.LastTransitionAt,
}
}
func attentionTransitionIsFlap(transition operationaltrust.LifecycleTransition) bool {
if transition.To == operationaltrust.OperationalResolved {
return transition.From != operationaltrust.OperationalResolved
}
if transition.To == operationaltrust.OperationalOpen {
return transition.From == operationaltrust.OperationalResolved
}
return false
}
func summarizeAttentionEvidence(
state operationaltrust.OperationalState,
evidence []operationaltrust.EvidenceEnvelope,
+72
View File
@@ -378,3 +378,75 @@ func attentionTestPosture(
EvaluatedAt: evaluatedAt,
}
}
func TestProjectAttentionItemsCollapsesOpenResolvedChurnIntoFlapping(t *testing.T) {
now := time.Date(2026, 8, 10, 15, 0, 0, 0, time.UTC)
alert := attentionTestAlert("flapper", operationaltrust.OperationalOpen, operationaltrust.SeverityWarning, now.Add(-2*24*time.Hour), now)
from := operationaltrust.OperationalOpen
to := operationaltrust.OperationalResolved
// Eleven open/resolved transitions in the last day, plus older churn that
// must not count, plus acknowledgement bookkeeping that never counts.
for i := 0; i < 11; i++ {
alert.Transitions = append(alert.Transitions, operationaltrust.LifecycleTransition{
ID: "t-" + string(rune('a'+i)),
OperationalRecordID: "flapper",
From: from,
To: to,
At: now.Add(-time.Duration(11-i) * time.Hour),
Cause: operationaltrust.TransitionRecoveryEvidence,
})
from, to = to, from
}
alert.Transitions = append(alert.Transitions,
operationaltrust.LifecycleTransition{
ID: "old", OperationalRecordID: "flapper",
From: operationaltrust.OperationalOpen, To: operationaltrust.OperationalResolved,
At: now.Add(-30 * time.Hour), Cause: operationaltrust.TransitionRecoveryEvidence,
},
operationaltrust.LifecycleTransition{
ID: "ack", OperationalRecordID: "flapper",
From: operationaltrust.OperationalOpen, To: operationaltrust.OperationalAcknowledged,
At: now.Add(-20 * time.Minute), Cause: operationaltrust.TransitionAcknowledgement,
},
)
projected := ProjectAttentionItems([]alerts.Alert{alert}, nil, nil, now)
if len(projected.Details) != 1 {
t.Fatalf("details = %d, want 1", len(projected.Details))
}
item := projected.Details[0].Item
if item.Flapping == nil {
t.Fatal("eleven open/resolved transitions in a day were not labelled flapping")
}
if item.Flapping.TransitionCount != 11 {
t.Fatalf("TransitionCount = %d, want 11 (older and acknowledgement transitions excluded)", item.Flapping.TransitionCount)
}
if item.Flapping.WindowHours != 24 {
t.Fatalf("WindowHours = %d, want 24", item.Flapping.WindowHours)
}
if len(projected.Details[0].Timeline) != 13 {
t.Fatalf("timeline = %d, want the full forensic list of 13 transitions retained", len(projected.Details[0].Timeline))
}
}
func TestProjectAttentionItemsLeavesSingleRecurrenceUnlabelled(t *testing.T) {
now := time.Date(2026, 8, 10, 15, 0, 0, 0, time.UTC)
alert := attentionTestAlert("recurred", operationaltrust.OperationalOpen, operationaltrust.SeverityWarning, now.Add(-24*time.Hour), now)
alert.Transitions = append(alert.Transitions,
operationaltrust.LifecycleTransition{
ID: "r1", OperationalRecordID: "recurred",
From: operationaltrust.OperationalOpen, To: operationaltrust.OperationalResolved,
At: now.Add(-3 * time.Hour), Cause: operationaltrust.TransitionRecoveryEvidence,
},
operationaltrust.LifecycleTransition{
ID: "r2", OperationalRecordID: "recurred",
From: operationaltrust.OperationalResolved, To: operationaltrust.OperationalOpen,
At: now.Add(-time.Hour), Cause: operationaltrust.TransitionDetectorDecision,
},
)
projected := ProjectAttentionItems([]alerts.Alert{alert}, nil, nil, now)
if projected.Details[0].Item.Flapping != nil {
t.Fatalf("one recurrence was labelled flapping: %+v", projected.Details[0].Item.Flapping)
}
}
+4
View File
@@ -169,6 +169,10 @@ func (p *PatrolService) runDemoPatrolCycleWithStart(trigger TriggerReason, accep
}
}
// Demo findings restate mock alerts on purpose (a storage pool near full
// beside its usage alert), so fold them the same way a real run does.
p.reconcileAlertMirrors()
// A simulated pass stands in for a healthy provider-backed run: clear any
// stale "Provider analysis error" finding left by real runs that failed
// before mock mode enabled.
+114 -3
View File
@@ -4,6 +4,7 @@ package ai
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"sync"
"time"
@@ -247,6 +248,21 @@ type Finding struct {
// local evidence caused this finding to be assessed. It is durable context
// for investigation, not action or approval authority.
ObjectiveContext *aicontracts.PatrolObjectiveContext `json:"objective_context,omitempty"`
// Flapping is set while the finding's open/resolved state has changed
// at least findingFlapThreshold times inside findingFlapWindow. The
// store collapses the individual regressed/resolved lifecycle rows into
// one flapping row while it is set. Derived: rebuilt from the lifecycle
// on the first transition after a restart rather than persisted.
Flapping *FindingFlapping `json:"flapping,omitempty"`
// MirrorsAlertID names the active alert whose resource and condition
// this finding restates (for example a Proxmox guest stopped finding
// beside a powered-off alert). Stamped by Patrol after every run and
// cleared when the alert resolves; the attention surface folds such
// findings under the alert instead of listing them twice.
MirrorsAlertID string `json:"mirrors_alert_id,omitempty"`
// MirrorsAlertType is the alert type of MirrorsAlertID, so a client
// holding the alert projection can join on resource plus kind.
MirrorsAlertType string `json:"mirrors_alert_type,omitempty"`
}
// findingJSON is the marshal mirror of Finding: identical fields, but
@@ -299,6 +315,9 @@ type findingJSON struct {
LastRegressionAt *time.Time `json:"last_regression_at,omitempty"`
CapacityForecast *CapacityForecast `json:"capacity_forecast,omitempty"`
ObjectiveContext *aicontracts.PatrolObjectiveContext `json:"objective_context,omitempty"`
Flapping *FindingFlapping `json:"flapping,omitempty"`
MirrorsAlertID string `json:"mirrors_alert_id,omitempty"`
MirrorsAlertType string `json:"mirrors_alert_type,omitempty"`
}
func (f Finding) MarshalJSON() ([]byte, error) {
@@ -347,6 +366,9 @@ func (f Finding) MarshalJSON() ([]byte, error) {
LastRegressionAt: f.LastRegressionAt,
CapacityForecast: f.CapacityForecast,
ObjectiveContext: clonePatrolObjectiveContext(f.ObjectiveContext),
Flapping: cloneFindingFlapping(f.Flapping),
MirrorsAlertID: f.MirrorsAlertID,
MirrorsAlertType: f.MirrorsAlertType,
})
}
@@ -400,6 +422,9 @@ func (f *Finding) UnmarshalJSON(data []byte) error {
LastRegressionAt: payload.LastRegressionAt,
CapacityForecast: payload.CapacityForecast,
ObjectiveContext: clonePatrolObjectiveContext(payload.ObjectiveContext),
Flapping: cloneFindingFlapping(payload.Flapping),
MirrorsAlertID: payload.MirrorsAlertID,
MirrorsAlertType: payload.MirrorsAlertType,
}
return nil
}
@@ -1337,6 +1362,92 @@ func (s *FindingsStore) appendLifecycleLocked(f *Finding, typ, msg, from, to str
}
}
// recordTransitionLifecycleLocked records an open/resolved transition for f.
// Below the flapping threshold it appends the ordinary lifecycle row. Once
// the storm throttler reports the finding is flapping, it stamps f.Flapping
// and folds the churn into one "flapping" row that carries the transition
// count, updating that row in place on every further transition instead of
// appending another regressed/resolved entry. The individual rows recorded
// before the label applied stay in the log as forensic detail.
func (s *FindingsStore) recordTransitionLifecycleLocked(f *Finding, typ, msg, from, to string, meta map[string]string) {
if f == nil {
return
}
var flapping *FindingFlapping
if t := s.stormThrottler; t != nil {
flapping = t.observeFlapLocked(f, time.Now())
}
f.Flapping = flapping
if flapping == nil {
s.appendLifecycleLocked(f, typ, msg, from, to, meta)
return
}
collapsed := map[string]string{
"transition_count": strconv.Itoa(flapping.TransitionCount),
"window_hours": strconv.Itoa(flapping.WindowHours),
"first_transition_at": flapping.FirstTransitionAt.UTC().Format(time.RFC3339),
"last_transition": typ,
}
message := fmt.Sprintf(
"State changed %d times in the last %d hours; individual transitions are collapsed while the finding is flapping",
flapping.TransitionCount,
flapping.WindowHours,
)
if n := len(f.Lifecycle); n > 0 && f.Lifecycle[n-1].Type == FindingLifecycleFlapping {
last := &f.Lifecycle[n-1]
last.At = time.Now()
last.Message = message
last.From = from
last.To = to
last.Metadata = collapsed
return
}
s.appendLifecycleLocked(f, FindingLifecycleFlapping, message, from, to, collapsed)
}
func cloneFindingFlapping(value *FindingFlapping) *FindingFlapping {
if value == nil {
return nil
}
clone := *value
return &clone
}
// findingAlertMirror names the active alert a finding restates.
type findingAlertMirror struct {
AlertID string
AlertType string
}
// StampAlertMirrors records, for every unresolved finding, which active
// alert it mirrors (same resource, same condition). Findings absent from
// the map lose any previous stamp, so a resolved alert releases its
// finding back to the normal list on the next reconcile. The stamp is a
// derived read-model annotation and does not schedule a save.
func (s *FindingsStore) StampAlertMirrors(mirrors map[string]findingAlertMirror) int {
s.mu.Lock()
defer s.mu.Unlock()
changed := 0
for id, f := range s.findings {
mirror, ok := mirrors[id]
if !ok || f.ResolvedAt != nil {
if f.MirrorsAlertID != "" || f.MirrorsAlertType != "" {
f.MirrorsAlertID = ""
f.MirrorsAlertType = ""
changed++
}
continue
}
if f.MirrorsAlertID != mirror.AlertID || f.MirrorsAlertType != mirror.AlertType {
f.MirrorsAlertID = mirror.AlertID
f.MirrorsAlertType = mirror.AlertType
changed++
}
}
return changed
}
func isKnownLoopState(v string) bool {
switch FindingLoopState(v) {
case FindingLoopStateDetected, FindingLoopStateInvestigating, FindingLoopStateRemediationPlanned, FindingLoopStateRemediating, FindingLoopStateRemediationFailed, FindingLoopStateNeedsAttention, FindingLoopStateTimedOut, FindingLoopStateResolved, FindingLoopStateDismissed, FindingLoopStateSnoozed, FindingLoopStateSuppressed:
@@ -1954,7 +2065,7 @@ func (s *FindingsStore) Add(f *Finding) bool {
if hadAcknowledgement {
meta["previous_acknowledged"] = "true"
}
s.appendLifecycleLocked(existing, "regressed", "Finding re-detected after resolution", string(FindingLoopStateResolved), string(FindingLoopStateDetected), meta)
s.recordTransitionLifecycleLocked(existing, "regressed", "Finding re-detected after resolution", string(FindingLoopStateResolved), string(FindingLoopStateDetected), meta)
if existing.IsActive() {
s.activeCounts[existing.Severity]++
}
@@ -2099,7 +2210,7 @@ func (s *FindingsStore) Resolve(id string, auto bool) bool {
if auto {
etype = "auto_resolved"
}
s.appendLifecycleLocked(f, etype, f.ResolveReason, f.LoopState, string(FindingLoopStateResolved), nil)
s.recordTransitionLifecycleLocked(f, etype, f.ResolveReason, f.LoopState, string(FindingLoopStateResolved), nil)
s.syncLoopStateLocked(f)
s.activeCounts[f.Severity]--
s.mu.Unlock()
@@ -2123,7 +2234,7 @@ func (s *FindingsStore) ResolveWithReason(id string, reason string) bool {
f.ResolvedAt = &now
f.AutoResolved = true
f.ResolveReason = reason
s.appendLifecycleLocked(f, "auto_resolved", reason, f.LoopState, string(FindingLoopStateResolved), nil)
s.recordTransitionLifecycleLocked(f, "auto_resolved", reason, f.LoopState, string(FindingLoopStateResolved), nil)
s.syncLoopStateLocked(f)
s.activeCounts[f.Severity]--
s.mu.Unlock()
+192
View File
@@ -0,0 +1,192 @@
// findings_alert_mirror.go decides when a Patrol finding restates an active
// alert. Real-time alerts own down, threshold, and age conditions; Patrol's
// deterministic watchers (Proxmox guest lifecycle, PDM bridge, signal
// detection) and the model can still emit a finding for the same resource
// and condition. Rather than dropping those findings, Patrol stamps them
// with the alert they mirror after every run so the attention surface can
// fold them under the alert and the findings list can demote them into an
// expansion. The matcher is deliberately conservative: it requires the same
// canonical resource and a recognised condition class on both sides, or an
// explicit AlertIdentifier link.
package ai
import (
"strings"
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
// alertMirrorCandidate is the minimal alert shape the matcher needs.
type alertMirrorCandidate struct {
ID string
ResourceID string
Type string
}
// Condition classes shared by alerts and findings. An empty class never
// matches, so unknown alert types and free-form findings are left alone.
const (
mirrorConditionDown = "down"
mirrorConditionRestartLoop = "restart-loop"
mirrorConditionContainerHealth = "container-health"
mirrorConditionDiskCapacity = "disk-capacity"
mirrorConditionMemory = "memory"
mirrorConditionCPU = "cpu"
mirrorConditionBackupAge = "backup-age"
mirrorConditionSnapshotAge = "snapshot-age"
mirrorConditionTemperature = "temperature"
)
// alertConditionClass maps an alert type onto the shared condition class.
func alertConditionClass(alertType string) string {
normalized := strings.NewReplacer("_", "-", " ", "-").Replace(strings.ToLower(strings.TrimSpace(alertType)))
switch normalized {
case "offline", "connectivity", "powered-off", "docker-container-state", "docker-host-offline", "node-offline":
return mirrorConditionDown
case "docker-container-restart-loop":
return mirrorConditionRestartLoop
case "docker-container-health", "docker-service-health":
return mirrorConditionContainerHealth
case "disk", "usage", "storage-usage":
return mirrorConditionDiskCapacity
case "memory", "docker-container-memory-limit", "docker-container-oom-kill":
return mirrorConditionMemory
case "cpu":
return mirrorConditionCPU
case "backup-age":
return mirrorConditionBackupAge
case "snapshot-age":
return mirrorConditionSnapshotAge
case "temperature", "disk-temperature":
return mirrorConditionTemperature
}
return ""
}
// findingConditionClass maps a finding onto the shared condition class using
// its stable key or dedup prefix first and its category plus title words
// second. Findings that do not describe an alert-owned condition return "".
func findingConditionClass(f *Finding) string {
if f == nil {
return ""
}
id := strings.ToLower(strings.TrimSpace(f.ID))
key := strings.ToLower(strings.TrimSpace(f.Key))
switch {
case strings.HasPrefix(id, proxmoxGuestStoppedFindingIDPrefix), key == proxmoxGuestStoppedFindingKey:
return mirrorConditionDown
case strings.HasPrefix(id, strings.ToLower(PDMAlertFindingPrefix)):
return mirrorConditionDown
}
text := strings.ToLower(f.Title)
contains := func(words ...string) bool {
for _, word := range words {
if strings.Contains(text, word) {
return true
}
}
return false
}
switch f.Category {
case FindingCategoryCapacity:
switch {
case contains("memory", "swap"):
return mirrorConditionMemory
case contains("disk", "storage", "pool", "datastore", "volume", "full", "%"):
return mirrorConditionDiskCapacity
}
case FindingCategoryPerformance:
switch {
case contains("memory", "swap", "oom"):
return mirrorConditionMemory
case contains("cpu", "load"):
return mirrorConditionCPU
case contains("temperature", "thermal"):
return mirrorConditionTemperature
}
case FindingCategoryReliability:
switch {
case contains("restarting repeatedly", "restart loop", "crash loop", "crashlooping"):
return mirrorConditionRestartLoop
case contains("unhealthy", "health check"):
return mirrorConditionContainerHealth
case contains("stopped", "exited", "offline", "powered off", "is down", "unreachable", "not running"):
return mirrorConditionDown
}
case FindingCategoryBackup:
if contains("snapshot") {
return mirrorConditionSnapshotAge
}
return mirrorConditionBackupAge
}
return ""
}
// findingMirrorsAlert reports whether f restates alert: an explicit
// AlertIdentifier link, or the same canonical resource with the same
// condition class.
func findingMirrorsAlert(f *Finding, alert alertMirrorCandidate) bool {
if f == nil {
return false
}
alertID := strings.TrimSpace(alert.ID)
if alertID != "" && strings.TrimSpace(f.AlertIdentifier) == alertID {
return true
}
findingResource := unified.CanonicalResourceID(f.ResourceID)
alertResource := unified.CanonicalResourceID(alert.ResourceID)
if findingResource == "" || alertResource == "" || findingResource != alertResource {
return false
}
class := findingConditionClass(f)
return class != "" && class == alertConditionClass(alert.Type)
}
// matchFindingsToAlerts returns, for every unresolved finding that mirrors
// an active alert, the alert it mirrors. Storm meta-findings are never
// mirrors; they summarise several findings rather than one condition. When
// several alerts match, the first in the caller's order wins so the result
// is deterministic for a stable alert list.
func matchFindingsToAlerts(findings []*Finding, alerts []alertMirrorCandidate) map[string]findingAlertMirror {
mirrors := make(map[string]findingAlertMirror)
if len(findings) == 0 || len(alerts) == 0 {
return mirrors
}
for _, f := range findings {
if f == nil || f.ResolvedAt != nil || f.Source == stormFindingSource {
continue
}
for _, alert := range alerts {
if findingMirrorsAlert(f, alert) {
mirrors[f.ID] = findingAlertMirror{
AlertID: strings.TrimSpace(alert.ID),
AlertType: strings.TrimSpace(alert.Type),
}
break
}
}
}
return mirrors
}
// reconcileAlertMirrors re-stamps every unresolved finding against the
// current unscoped active-alert set. It runs at the end of every patrol
// cycle so a finding that mirrors an alert is folded while the alert is
// active and released once the alert resolves.
func (p *PatrolService) reconcileAlertMirrors() int {
if p == nil || p.findings == nil || p.stateProvider == nil {
return 0
}
snapshot := p.stateProvider.ReadSnapshot()
candidates := make([]alertMirrorCandidate, 0, len(snapshot.ActiveAlerts))
for _, alert := range snapshot.ActiveAlerts {
candidates = append(candidates, alertMirrorCandidate{
ID: alert.ID,
ResourceID: alert.ResourceID,
Type: alert.Type,
})
}
findings := p.findings.GetAll(nil)
return p.findings.StampAlertMirrors(matchFindingsToAlerts(findings, candidates))
}
+154
View File
@@ -0,0 +1,154 @@
package ai
import (
"testing"
"time"
)
func TestFindingMirrorsAlert_SameResourceSameCondition(t *testing.T) {
stopped := &Finding{
ID: proxmoxGuestStoppedFindingIDPrefix + "pve1/qemu/101",
Key: proxmoxGuestStoppedFindingKey,
Category: FindingCategoryReliability,
ResourceID: "pve:vm:101",
Title: "Proxmox VM database stopped",
}
poweredOff := alertMirrorCandidate{ID: "powered-off-pve:vm:101", ResourceID: "pve:vm:101", Type: "powered-off"}
if !findingMirrorsAlert(stopped, poweredOff) {
t.Fatal("guest stopped finding did not mirror the powered-off alert on the same resource")
}
otherResource := alertMirrorCandidate{ID: "powered-off-pve:vm:102", ResourceID: "pve:vm:102", Type: "powered-off"}
if findingMirrorsAlert(stopped, otherResource) {
t.Fatal("matched an alert on a different resource")
}
memoryAlert := alertMirrorCandidate{ID: "memory-pve:vm:101", ResourceID: "pve:vm:101", Type: "memory"}
if findingMirrorsAlert(stopped, memoryAlert) {
t.Fatal("matched an alert with a different condition on the same resource")
}
}
func TestFindingMirrorsAlert_ExplicitAlertIdentifierWins(t *testing.T) {
f := &Finding{
ID: "llm-finding",
Category: FindingCategoryGeneral,
ResourceID: "docker:host/web",
Title: "Something Patrol noticed",
AlertIdentifier: "docker-container-state-docker:host/web",
}
alert := alertMirrorCandidate{ID: "docker-container-state-docker:host/web", ResourceID: "docker:other", Type: "docker-container-state"}
if !findingMirrorsAlert(f, alert) {
t.Fatal("explicit AlertIdentifier link was not honoured")
}
}
func TestFindingConditionClass_CoversAlertOwnedConditions(t *testing.T) {
cases := []struct {
name string
f *Finding
want string
}{
{"container exited", &Finding{Category: FindingCategoryReliability, Title: `Container "vaultwarden" exited unexpectedly`}, mirrorConditionDown},
{"restart loop", &Finding{Category: FindingCategoryReliability, Title: `Container "sftp-ingest" is restarting repeatedly`}, mirrorConditionRestartLoop},
{"unhealthy container", &Finding{Category: FindingCategoryReliability, Title: `Container "jellyfin" is unhealthy`}, mirrorConditionContainerHealth},
{"storage full", &Finding{Category: FindingCategoryCapacity, Title: `Storage "core-b-service-pool" is 88% full`}, mirrorConditionDiskCapacity},
{"memory capacity", &Finding{Category: FindingCategoryCapacity, Title: `Memory headroom on node pve1 is nearly exhausted`}, mirrorConditionMemory},
{"guest memory", &Finding{Category: FindingCategoryPerformance, Title: `Container "artifact-cache" memory usage at 92%`}, mirrorConditionMemory},
{"cpu", &Finding{Category: FindingCategoryPerformance, Title: `Sustained CPU saturation on pve3`}, mirrorConditionCPU},
{"snapshot age", &Finding{Category: FindingCategoryBackup, Title: `Snapshot "base" is 349 days old`}, mirrorConditionSnapshotAge},
{"backup age", &Finding{Category: FindingCategoryBackup, Title: `No recent backup for VM 104`}, mirrorConditionBackupAge},
{"pdm offline", &Finding{ID: PDMAlertFindingPrefix + ":dc/node/pve1", Category: FindingCategoryReliability, Title: `PDM: node "pve1" is offline`}, mirrorConditionDown},
{"mail gateway degraded is Patrol-owned", &Finding{Category: FindingCategoryReliability, Title: `Mail gateway "mail-gateway-us" is degraded`}, ""},
{"security finding never mirrors", &Finding{Category: FindingCategorySecurity, Title: `Container runs as root and is offline`}, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := findingConditionClass(tc.f); got != tc.want {
t.Fatalf("findingConditionClass() = %q, want %q", got, tc.want)
}
})
}
}
func TestAlertConditionClass_MapsAlertTypes(t *testing.T) {
cases := map[string]string{
"docker-container-state": mirrorConditionDown,
"powered-off": mirrorConditionDown,
"offline": mirrorConditionDown,
"connectivity": mirrorConditionDown,
"docker-container-restart-loop": mirrorConditionRestartLoop,
"docker-container-health": mirrorConditionContainerHealth,
"usage": mirrorConditionDiskCapacity,
"disk": mirrorConditionDiskCapacity,
"memory": mirrorConditionMemory,
"cpu": mirrorConditionCPU,
"snapshot-age": mirrorConditionSnapshotAge,
"backup-age": mirrorConditionBackupAge,
"temperature": mirrorConditionTemperature,
"zfs-pool-state": "",
"": "",
}
for alertType, want := range cases {
if got := alertConditionClass(alertType); got != want {
t.Errorf("alertConditionClass(%q) = %q, want %q", alertType, got, want)
}
}
}
func TestMatchFindingsToAlerts_SkipsResolvedAndStormFindings(t *testing.T) {
resolvedAt := time.Now()
alerts := []alertMirrorCandidate{
{ID: "docker-container-state-docker:h/a", ResourceID: "docker:h/a", Type: "docker-container-state"},
{ID: "docker-container-state-docker:h/b", ResourceID: "docker:h/b", Type: "docker-container-state"},
}
findings := []*Finding{
{ID: "live", Category: FindingCategoryReliability, ResourceID: "docker:h/a", Title: `Container "a" exited`},
{ID: "gone", Category: FindingCategoryReliability, ResourceID: "docker:h/b", Title: `Container "b" exited`, ResolvedAt: &resolvedAt},
{ID: stormFindingIDPrefix + "docker:h/a", Source: stormFindingSource, Category: FindingCategoryReliability, ResourceID: "docker:h/a", Title: "Multiple findings emitted"},
nil,
}
got := matchFindingsToAlerts(findings, alerts)
if len(got) != 1 {
t.Fatalf("mirrors = %d, want 1: %+v", len(got), got)
}
mirror, ok := got["live"]
if !ok {
t.Fatalf("live finding was not matched: %+v", got)
}
if mirror.AlertID != "docker-container-state-docker:h/a" || mirror.AlertType != "docker-container-state" {
t.Fatalf("mirror = %+v", mirror)
}
}
func TestStampAlertMirrors_SetsAndClears(t *testing.T) {
store := NewFindingsStore()
store.Add(&Finding{ID: "f1", Severity: FindingSeverityWarning, Category: FindingCategoryReliability, ResourceID: "docker:h/a", Title: `Container "a" exited`})
store.Add(&Finding{ID: "f2", Severity: FindingSeverityWarning, Category: FindingCategoryReliability, ResourceID: "docker:h/b", Title: `Container "b" exited`})
changed := store.StampAlertMirrors(map[string]findingAlertMirror{
"f1": {AlertID: "alert-a", AlertType: "docker-container-state"},
})
if changed != 1 {
t.Fatalf("changed = %d, want 1", changed)
}
if f := store.Get("f1"); f.MirrorsAlertID != "alert-a" || f.MirrorsAlertType != "docker-container-state" {
t.Fatalf("f1 was not stamped: %+v", f)
}
if f := store.Get("f2"); f.MirrorsAlertID != "" {
t.Fatalf("f2 was stamped without a match: %+v", f)
}
// Same stamp again is a no-op.
if changed := store.StampAlertMirrors(map[string]findingAlertMirror{"f1": {AlertID: "alert-a", AlertType: "docker-container-state"}}); changed != 0 {
t.Fatalf("idempotent restamp changed = %d, want 0", changed)
}
// Alert resolved: the finding is released.
if changed := store.StampAlertMirrors(map[string]findingAlertMirror{}); changed != 1 {
t.Fatalf("clear changed = %d, want 1", changed)
}
if f := store.Get("f1"); f.MirrorsAlertID != "" || f.MirrorsAlertType != "" {
t.Fatalf("f1 stamp was not cleared: %+v", f)
}
}
+162
View File
@@ -0,0 +1,162 @@
package ai
import (
"testing"
"time"
)
// flapFinding builds a fresh warning finding for the flap tests.
func flapFinding() *Finding {
return &Finding{
ID: "docker:h/web:exited",
Key: "container-exited",
Severity: FindingSeverityWarning,
Category: FindingCategoryReliability,
ResourceID: "docker:h/web",
ResourceName: "web",
Title: `Container "web" exited`,
}
}
func lifecycleTypes(f *Finding) []string {
types := make([]string, 0, len(f.Lifecycle))
for _, event := range f.Lifecycle {
types = append(types, event.Type)
}
return types
}
func TestFindingsStore_CollapsesFlappingIntoOneLifecycleRow(t *testing.T) {
store := NewFindingsStore()
store.SetStormThrottler(newFindingStormThrottler())
store.Add(flapFinding())
// Resolve and re-detect repeatedly. Each resolve and each regression is
// one open/resolved transition.
for i := 0; i < 6; i++ {
if !store.ResolveWithReason("docker:h/web:exited", "container running again") {
t.Fatalf("resolve %d failed", i)
}
if store.Add(flapFinding()) {
t.Fatalf("re-detection %d was treated as a new finding", i)
}
}
f := store.Get("docker:h/web:exited")
if f == nil {
t.Fatal("finding disappeared")
}
if f.Flapping == nil {
t.Fatalf("twelve transitions in a minute were not labelled flapping: %v", lifecycleTypes(f))
}
if f.Flapping.TransitionCount != 12 {
t.Fatalf("TransitionCount = %d, want 12", f.Flapping.TransitionCount)
}
if f.RegressionCount != 6 {
t.Fatalf("RegressionCount = %d, want 6 (regressions still counted while collapsed)", f.RegressionCount)
}
// Transitions 1-3 were recorded individually; from the fourth onward the
// store maintains a single flapping row instead of appending nine more.
types := lifecycleTypes(f)
flappingRows := 0
individualTransitions := 0
for _, typ := range types {
switch typ {
case FindingLifecycleFlapping:
flappingRows++
case "regressed", "auto_resolved", "resolved":
individualTransitions++
}
}
if flappingRows != 1 {
t.Fatalf("flapping rows = %d, want exactly one collapsed row: %v", flappingRows, types)
}
if individualTransitions != findingFlapThreshold-1 {
t.Fatalf("individual transition rows = %d, want %d pre-label rows: %v", individualTransitions, findingFlapThreshold-1, types)
}
last := f.Lifecycle[len(f.Lifecycle)-1]
if last.Type != FindingLifecycleFlapping {
t.Fatalf("last lifecycle row = %q, want the collapsed flapping row", last.Type)
}
if last.Metadata["transition_count"] != "12" {
t.Fatalf("collapsed row transition_count = %q, want 12", last.Metadata["transition_count"])
}
if last.Metadata["last_transition"] != "regressed" {
t.Fatalf("collapsed row last_transition = %q, want regressed", last.Metadata["last_transition"])
}
}
func TestFindingsStore_WithoutThrottlerKeepsIndividualRows(t *testing.T) {
store := NewFindingsStore()
store.Add(flapFinding())
for i := 0; i < 3; i++ {
store.ResolveWithReason("docker:h/web:exited", "running again")
store.Add(flapFinding())
}
f := store.Get("docker:h/web:exited")
if f.Flapping != nil {
t.Fatal("no throttler installed but the finding was labelled flapping")
}
for _, typ := range lifecycleTypes(f) {
if typ == FindingLifecycleFlapping {
t.Fatalf("collapsed row appeared without a throttler: %v", lifecycleTypes(f))
}
}
}
func TestFindingsStore_FlappingLabelClearsWhenChurnStops(t *testing.T) {
store := NewFindingsStore()
throttler := newFindingStormThrottler()
store.SetStormThrottler(throttler)
store.Add(flapFinding())
for i := 0; i < 3; i++ {
store.ResolveWithReason("docker:h/web:exited", "running again")
store.Add(flapFinding())
}
if store.Get("docker:h/web:exited").Flapping == nil {
t.Fatal("expected flapping after six transitions")
}
// Age the tracked window out, then one more transition: no longer flapping.
throttler.mu.Lock()
tracker := throttler.flaps["docker:h/web:exited"]
for i := range tracker.transitions {
tracker.transitions[i] = tracker.transitions[i].Add(-findingFlapWindow - time.Hour)
}
throttler.mu.Unlock()
store.ResolveWithReason("docker:h/web:exited", "running again")
f := store.Get("docker:h/web:exited")
if f.Flapping != nil {
t.Fatalf("label persisted after the window emptied: %+v", f.Flapping)
}
if last := f.Lifecycle[len(f.Lifecycle)-1]; last.Type != "auto_resolved" {
t.Fatalf("post-flap transition row = %q, want an ordinary auto_resolved row", last.Type)
}
}
func TestFindingJSONRoundTripsFlappingAndMirror(t *testing.T) {
now := time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC)
f := flapFinding()
f.DetectedAt = now
f.LastSeenAt = now
f.Flapping = &FindingFlapping{TransitionCount: 7, WindowHours: 24, FirstTransitionAt: now.Add(-20 * time.Hour), LastTransitionAt: now}
f.MirrorsAlertID = "docker-container-state-docker:h/web"
f.MirrorsAlertType = "docker-container-state"
data, err := f.MarshalJSON()
if err != nil {
t.Fatal(err)
}
var decoded Finding
if err := decoded.UnmarshalJSON(data); err != nil {
t.Fatal(err)
}
if decoded.Flapping == nil || decoded.Flapping.TransitionCount != 7 || decoded.Flapping.WindowHours != 24 {
t.Fatalf("flapping did not round-trip: %+v", decoded.Flapping)
}
if decoded.MirrorsAlertID != f.MirrorsAlertID || decoded.MirrorsAlertType != f.MirrorsAlertType {
t.Fatalf("mirror fields did not round-trip: %q %q", decoded.MirrorsAlertID, decoded.MirrorsAlertType)
}
}
+185
View File
@@ -10,12 +10,21 @@
// store mutex held; it owns its own internal mutex so the throttler does
// not couple to the store's lock-ordering invariants. The observer must
// NOT call back into FindingsStore.
//
// The same throttler owns flap detection (observeFlapLocked): a finding
// whose open/resolved state changes at least findingFlapThreshold times
// inside findingFlapWindow is "flapping". The store then collapses those
// transitions into one lifecycle entry carrying the count instead of
// appending one regressed/resolved row per change, and the attention
// projection reads the same constants so alert and finding surfaces agree
// on what "flapping" means.
package ai
import (
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -28,8 +37,48 @@ const (
stormFindingIDPrefix = "finding-storm:"
stormClusterCap = 1024
stormResolveReason = "finding-storm:rate_dropped_below_threshold"
// findingFlapWindow is the sliding window over which open/resolved
// transitions count toward flapping. Twenty-four hours matches the
// operator-visible complaint (eleven transitions in a day) rather than
// the alert engine's five-minute notification-suppression window.
findingFlapWindow = 24 * time.Hour
// findingFlapThreshold is the number of open/resolved transitions inside
// findingFlapWindow at which an item is labelled flapping. Four means
// the condition came back at least twice; a single recurrence is a
// regression, not a flap.
findingFlapThreshold = 4
// findingFlapTrackerCap bounds per-finding transition tracking.
findingFlapTrackerCap = 4096
// FindingLifecycleFlapping is the collapsed lifecycle event type that
// stands in for the individual regressed/resolved rows while a finding
// is flapping. Its metadata carries transition_count and window_hours.
FindingLifecycleFlapping = "flapping"
)
// FindingFlapping summarises a finding whose open/resolved state keeps
// changing. It is stamped by the store while the transition count inside
// the window is at or above findingFlapThreshold and cleared otherwise.
type FindingFlapping struct {
// TransitionCount is the number of open/resolved transitions observed
// inside the window ending at LastTransitionAt.
TransitionCount int `json:"transition_count"`
// WindowHours is the length of the sliding window in hours.
WindowHours int `json:"window_hours"`
// FirstTransitionAt is the oldest transition still inside the window.
FirstTransitionAt time.Time `json:"first_transition_at"`
// LastTransitionAt is the most recent transition.
LastTransitionAt time.Time `json:"last_transition_at"`
}
// findingFlapTracker holds the sliding window of open/resolved transition
// timestamps for one finding.
type findingFlapTracker struct {
transitions []time.Time
hydrated bool
lastTouchedAt time.Time
}
// findingStormCluster carries per-clusterKey state: the sliding window of
// emission timestamps, identity of the storm finding currently emitted for
// the cluster (if any), and the freshest contributor metadata so the storm
@@ -55,12 +104,17 @@ type findingStormThrottler struct {
mu sync.Mutex
clusters map[string]*findingStormCluster
cap int
flaps map[string]*findingFlapTracker
flapCap int
}
func newFindingStormThrottler() *findingStormThrottler {
return &findingStormThrottler{
clusters: make(map[string]*findingStormCluster),
cap: stormClusterCap,
flaps: make(map[string]*findingFlapTracker),
flapCap: findingFlapTrackerCap,
}
}
@@ -240,3 +294,134 @@ func (t *findingStormThrottler) buildStormFindingLocked(cluster *findingStormClu
LastSeenAt: now,
}
}
// observeFlapLocked records one open/resolved transition for f at now and
// returns the resulting FindingFlapping summary, or nil while the finding
// is below the flapping threshold. Like observeLocked it is called with
// the store mutex held and uses the throttler's own mutex; it never calls
// back into the store.
//
// The first observation of a finding hydrates the window from the
// finding's persisted lifecycle so a restart does not forget that an item
// was flapping until the next transition lands.
func (t *findingStormThrottler) observeFlapLocked(f *Finding, now time.Time) *FindingFlapping {
if t == nil || f == nil {
return nil
}
key := strings.TrimSpace(f.ID)
if key == "" {
return nil
}
t.mu.Lock()
defer t.mu.Unlock()
if t.flaps == nil {
t.flaps = make(map[string]*findingFlapTracker)
}
tracker, ok := t.flaps[key]
if !ok {
tracker = &findingFlapTracker{}
t.flaps[key] = tracker
}
if !tracker.hydrated {
tracker.transitions = flapTransitionsFromLifecycle(f.Lifecycle, now)
tracker.hydrated = true
}
tracker.lastTouchedAt = now
tracker.transitions = append(trimFlapWindow(tracker.transitions, now), now)
t.pruneFlapLRULocked(key)
return summarizeFlapTransitions(tracker.transitions, now)
}
// pruneFlapLRULocked evicts the least-recently-touched trackers (excluding
// the one just touched) until the map is at or under flapCap.
func (t *findingStormThrottler) pruneFlapLRULocked(protectKey string) {
if t.flapCap <= 0 || len(t.flaps) <= t.flapCap {
return
}
type entry struct {
key string
at time.Time
}
candidates := make([]entry, 0, len(t.flaps)-1)
for k, tracker := range t.flaps {
if k == protectKey {
continue
}
candidates = append(candidates, entry{key: k, at: tracker.lastTouchedAt})
}
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].at.Before(candidates[j].at)
})
excess := len(t.flaps) - t.flapCap
for i := 0; i < excess && i < len(candidates); i++ {
delete(t.flaps, candidates[i].key)
}
}
// trimFlapWindow drops transitions older than findingFlapWindow before now.
func trimFlapWindow(transitions []time.Time, now time.Time) []time.Time {
cutoff := now.Add(-findingFlapWindow)
keep := transitions[:0]
for _, ts := range transitions {
if !ts.Before(cutoff) {
keep = append(keep, ts)
}
}
return keep
}
// summarizeFlapTransitions returns the flapping summary for a window of
// transition timestamps, or nil below the threshold. Shared by the finding
// store and the attention projection so both surfaces agree.
func summarizeFlapTransitions(transitions []time.Time, now time.Time) *FindingFlapping {
inWindow := make([]time.Time, 0, len(transitions))
cutoff := now.Add(-findingFlapWindow)
for _, ts := range transitions {
if !ts.Before(cutoff) {
inWindow = append(inWindow, ts)
}
}
if len(inWindow) < findingFlapThreshold {
return nil
}
sort.Slice(inWindow, func(i, j int) bool { return inWindow[i].Before(inWindow[j]) })
return &FindingFlapping{
TransitionCount: len(inWindow),
WindowHours: int(findingFlapWindow / time.Hour),
FirstTransitionAt: inWindow[0],
LastTransitionAt: inWindow[len(inWindow)-1],
}
}
// flapTransitionsFromLifecycle rebuilds the in-window transition
// timestamps from a persisted lifecycle log. Individual regressed and
// resolved rows count once each; a collapsed flapping row contributes its
// recorded transition_count, spread as repeats of its timestamp so the
// count survives without inventing intermediate times.
func flapTransitionsFromLifecycle(lifecycle []FindingLifecycleEvent, now time.Time) []time.Time {
cutoff := now.Add(-findingFlapWindow)
var transitions []time.Time
for _, event := range lifecycle {
if event.At.Before(cutoff) || event.At.After(now) {
continue
}
switch event.Type {
case "regressed", "resolved", "auto_resolved":
transitions = append(transitions, event.At)
case FindingLifecycleFlapping:
count := 1
if raw, ok := event.Metadata["transition_count"]; ok {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
count = parsed
}
}
for i := 0; i < count; i++ {
transitions = append(transitions, event.At)
}
}
}
return transitions
}
@@ -232,3 +232,104 @@ func clusterKeys(tr *findingStormThrottler) []string {
}
return keys
}
func TestStormThrottler_FlapBelowThresholdReturnsNil(t *testing.T) {
tr := newFindingStormThrottler()
base := time.Now()
f := newStormFinding("f1", "vm/100", "container exited")
for i := 0; i < findingFlapThreshold-1; i++ {
if got := tr.observeFlapLocked(f, base.Add(time.Duration(i)*time.Hour)); got != nil {
t.Fatalf("transition %d below threshold: want nil, got %+v", i+1, got)
}
}
}
func TestStormThrottler_FlapAtThresholdSummarisesWindow(t *testing.T) {
tr := newFindingStormThrottler()
base := time.Now()
f := newStormFinding("f1", "vm/100", "container exited")
var got *FindingFlapping
for i := 0; i < findingFlapThreshold; i++ {
got = tr.observeFlapLocked(f, base.Add(time.Duration(i)*time.Hour))
}
if got == nil {
t.Fatalf("expected flapping summary at threshold %d", findingFlapThreshold)
}
if got.TransitionCount != findingFlapThreshold {
t.Errorf("TransitionCount = %d, want %d", got.TransitionCount, findingFlapThreshold)
}
if got.WindowHours != int(findingFlapWindow/time.Hour) {
t.Errorf("WindowHours = %d, want %d", got.WindowHours, int(findingFlapWindow/time.Hour))
}
if !got.FirstTransitionAt.Equal(base) {
t.Errorf("FirstTransitionAt = %v, want %v", got.FirstTransitionAt, base)
}
last := base.Add(time.Duration(findingFlapThreshold-1) * time.Hour)
if !got.LastTransitionAt.Equal(last) {
t.Errorf("LastTransitionAt = %v, want %v", got.LastTransitionAt, last)
}
}
func TestStormThrottler_FlapWindowEvictionClearsLabel(t *testing.T) {
tr := newFindingStormThrottler()
base := time.Now()
f := newStormFinding("f1", "vm/100", "container exited")
for i := 0; i < findingFlapThreshold; i++ {
tr.observeFlapLocked(f, base.Add(time.Duration(i)*time.Minute))
}
// One more transition a day and an hour later: every earlier transition
// has aged out, so the finding is a single recurrence again.
if got := tr.observeFlapLocked(f, base.Add(findingFlapWindow+time.Hour)); got != nil {
t.Fatalf("aged-out window still reported flapping: %+v", got)
}
}
func TestStormThrottler_FlapHydratesFromPersistedLifecycle(t *testing.T) {
tr := newFindingStormThrottler()
base := time.Now()
f := newStormFinding("f1", "vm/100", "container exited")
f.Lifecycle = []FindingLifecycleEvent{
{At: base.Add(-3 * time.Hour), Type: "regressed"},
{At: base.Add(-2 * time.Hour), Type: "auto_resolved"},
{At: base.Add(-30 * time.Hour), Type: "regressed"}, // outside window
{At: base.Add(-time.Hour), Type: FindingLifecycleFlapping, Metadata: map[string]string{"transition_count": "5"}},
}
got := tr.observeFlapLocked(f, base)
if got == nil {
t.Fatal("expected persisted lifecycle to hydrate the flap window")
}
// 1 + 1 + 5 hydrated transitions plus the observed one; the 30h-old row is dropped.
if got.TransitionCount != 8 {
t.Errorf("TransitionCount = %d, want 8", got.TransitionCount)
}
}
func TestStormThrottler_FlapTrackerLRUEvictsAtCap(t *testing.T) {
tr := newFindingStormThrottler()
tr.flapCap = 2
base := time.Now()
tr.observeFlapLocked(newStormFinding("a", "vm/1", "x"), base)
tr.observeFlapLocked(newStormFinding("b", "vm/2", "x"), base.Add(time.Second))
tr.observeFlapLocked(newStormFinding("c", "vm/3", "x"), base.Add(2*time.Second))
if _, ok := tr.flaps["a"]; ok {
t.Fatal("least-recently-touched flap tracker survived the cap")
}
if len(tr.flaps) != 2 {
t.Fatalf("tracker count = %d, want 2", len(tr.flaps))
}
}
func TestStormThrottler_FlapIgnoresEmptyID(t *testing.T) {
tr := newFindingStormThrottler()
if got := tr.observeFlapLocked(&Finding{}, time.Now()); got != nil {
t.Fatalf("empty ID must not be tracked, got %+v", got)
}
if len(tr.flaps) != 0 {
t.Fatalf("empty ID created a tracker")
}
}
+8
View File
@@ -654,6 +654,14 @@ func (p *PatrolService) runPatrolWithTriggerStart(ctx context.Context, trigger T
}
}
// Fold findings that restate an active alert under that alert. Runs after
// both the deterministic watchers and the model have written this cycle's
// findings, against the unscoped alert set, so scoped runs cannot release
// mirrors outside their scope.
if mirrored := p.reconcileAlertMirrors(); mirrored > 0 {
log.Debug().Int("changed", mirrored).Msg("AI Patrol: Updated alert-mirror stamps on findings")
}
// Count resolved findings: LLM-resolved (via tool) + auto-reconciled stale findings.
var resolvedCount int
if runStats.aiAnalysis != nil {