From df448c41d9ad117de71ef8d84fbe5c337ed554b9 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 29 Mar 2026 14:50:09 +0100 Subject: [PATCH] Expose Patrol verification activity mix --- .../v6/internal/subsystems/api-contracts.md | 1 + .../subsystems/frontend-primitives.md | 4 + .../subsystems/patrol-intelligence.md | 4 + .../patrol/PatrolIntelligenceSummary.tsx | 5 + .../pages/__tests__/AIIntelligence.test.tsx | 136 ++++++++++++++++++ .../patrolSummaryPresentation.test.ts | 99 +++++++++++++ .../src/utils/patrolSummaryPresentation.ts | 31 ++++ .../tests/18-patrol-runtime-state.spec.ts | 5 + 8 files changed, 285 insertions(+) diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index eb794b2ed..e1203279d 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -135,6 +135,7 @@ Own canonical runtime payload shapes between backend and frontend. and the Patrol findings empty-state behavior, so `0 active findings` only renders as a healthy frontend conclusion when the same governed AI summary contract still reports healthy overall health; degraded or not-fully-verified health predictions must flow through to the Patrol findings surface instead of being replaced by page-local "looks healthy" copy and the Patrol assessment headline plus compact summary-strip behavior, so the same governed AI summary contract decides whether the page leads with verified health, issues detected, coverage incomplete, or another attention state instead of letting count-only page fragments emit a stale `No issues found` conclusion and the Patrol verification summary derived from run history, so the page also states whether recent Patrol evidence came from a successful full patrol or only from scoped/erroring runs instead of leaving verification scope implicit + and the same-day activity-mix explanation derived from that governed run history, so when a recent full patrol is followed by alert-triggered or anomaly-triggered scoped work the verification surface can explain the mix directly instead of reconstructing it from page-local timing heuristics and the Patrol status recency split, so `last_patrol_at` remains reserved for completed full Patrol sweeps while scoped runs and verification checks advance `last_activity_at` without claiming a fresh full-estate verification pass and the canonical alert-triggered Patrol enqueue path in `internal/api/router.go`, so alert-fired Patrol work flows through the unified alert bridge and trigger manager instead of being duplicated by monitor callback wiring and the shared `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx` card, so canonical recent-change timelines stay rendered through one governed frontend card instead of separate page-local list loops diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 240a55b78..ecf18512c 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -1076,6 +1076,10 @@ When the same governed run-history contract shows a recent full patrol plus same-day scoped follow-up work, that summary shell should also carry a compact activity-mix explanation rather than forcing operators to infer why Patrol looked busy from a separate activity strip alone. +That explanation belongs on the verification surface itself when operators are +reconciling `Recently verified` copy against same-day scoped Patrol bursts; the +status strip may support the readout, but it is not sufficient as the only +explanation path. That same shell rule also owns Patrol recency labels. Shared Patrol header and status-shell surfaces must keep `Last full patrol` tied only to the full-sweep transport fact and use `Last activity` for scoped or verification work instead diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 3e9979230..a80694727 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -227,6 +227,10 @@ operator whether Patrol recently completed a successful full patrol, only ran scoped alert-triggered checks, or ended its most recent full patrol with errors, so the page does not leave trust and coverage as implicit background knowledge. +When same-day run history shows both a recent full patrol and a burst of +scoped follow-up activity, that same verification surface should expose the +recent activity mix explicitly instead of leaving operators to reconcile a +`Recently verified` headline with a busy Patrol strip elsewhere on the page. Fix-verification checks belong to that same explanation layer as targeted activity, not as evidence of a fresh full-estate sweep. The same hierarchy applies to investigation context. Correlations, recent diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx index 5a9204b69..e21074911 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx @@ -205,6 +205,11 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat

{verification().description}

+ +

+ Recent activity mix: {verification().activityMixLabel} +

+
diff --git a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx index 92acb7b93..dbabc4618 100644 --- a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx +++ b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx @@ -1029,6 +1029,142 @@ describe('AIIntelligence entitlement gating', () => { ).not.toBeInTheDocument(); }); + it('surfaces the recent activity mix in the verification summary when scoped runs are creating noise', async () => { + hasFeatureMock.mockReturnValue(true); + licenseStatusMock.mockReturnValue({ subscription_state: 'active' }); + getPatrolStatusMock.mockResolvedValue(defaultPatrolStatus({ license_required: false })); + getPatrolRunHistoryMock.mockResolvedValue([ + { + id: 'run-scoped-alert', + started_at: '2026-03-12T10:00:00Z', + completed_at: '2026-03-12T10:01:00Z', + duration_ms: 60000, + type: 'scoped', + trigger_reason: 'alert_fired', + scope_resource_ids: [], + effective_scope_resource_ids: [], + scope_resource_types: [], + resources_checked: 1, + nodes_checked: 0, + guests_checked: 0, + docker_checked: 0, + storage_checked: 0, + hosts_checked: 0, + pbs_checked: 0, + pmg_checked: 0, + kubernetes_checked: 0, + new_findings: 0, + existing_findings: 0, + rejected_findings: 0, + resolved_findings: 0, + auto_fix_count: 0, + findings_summary: '', + finding_ids: [], + error_count: 0, + status: 'healthy', + triage_flags: 0, + tool_call_count: 0, + }, + { + id: 'run-scoped-anomaly', + started_at: '2026-03-12T09:58:00Z', + completed_at: '2026-03-12T09:59:00Z', + duration_ms: 60000, + type: 'scoped', + trigger_reason: 'anomaly', + scope_resource_ids: [], + effective_scope_resource_ids: [], + scope_resource_types: [], + resources_checked: 1, + nodes_checked: 0, + guests_checked: 0, + docker_checked: 0, + storage_checked: 0, + hosts_checked: 0, + pbs_checked: 0, + pmg_checked: 0, + kubernetes_checked: 0, + new_findings: 0, + existing_findings: 0, + rejected_findings: 0, + resolved_findings: 0, + auto_fix_count: 0, + findings_summary: '', + finding_ids: [], + error_count: 0, + status: 'healthy', + triage_flags: 0, + tool_call_count: 0, + }, + { + id: 'run-full', + started_at: '2026-03-12T09:50:00Z', + completed_at: '2026-03-12T09:57:00Z', + duration_ms: 420000, + type: 'patrol', + trigger_reason: 'scheduled', + scope_resource_ids: [], + effective_scope_resource_ids: [], + scope_resource_types: [], + resources_checked: 58, + nodes_checked: 0, + guests_checked: 0, + docker_checked: 0, + storage_checked: 0, + hosts_checked: 0, + pbs_checked: 0, + pmg_checked: 0, + kubernetes_checked: 0, + new_findings: 0, + existing_findings: 0, + rejected_findings: 0, + resolved_findings: 0, + auto_fix_count: 0, + findings_summary: 'No active findings', + finding_ids: [], + error_count: 0, + status: 'healthy', + triage_flags: 0, + tool_call_count: 0, + }, + ]); + intelligenceState.summary = { + timestamp: '2026-03-12T10:05:00Z', + overall_health: { + score: 100, + grade: 'A', + trend: 'stable', + factors: [], + prediction: 'Infrastructure is healthy with no significant issues detected.', + }, + findings_count: { + critical: 0, + warning: 0, + watch: 0, + info: 0, + total: 0, + }, + predictions_count: 0, + recent_changes_count: 0, + learning: { + resources_with_knowledge: 0, + total_notes: 0, + resources_with_baselines: 0, + patterns_detected: 0, + correlations_learned: 0, + incidents_tracked: 0, + }, + }; + + render(() => ); + + await waitFor(() => { + expect( + screen.getByText('Recent activity mix: 1 full, 1 alert-triggered, 1 anomaly-triggered'), + ).toBeInTheDocument(); + }); + }); + it('treats a selected zero-finding run as an empty snapshot and uses effective scope ids', async () => { hasFeatureMock.mockReturnValue(true); licenseStatusMock.mockReturnValue({ subscription_state: 'active' }); diff --git a/frontend-modern/src/utils/__tests__/patrolSummaryPresentation.test.ts b/frontend-modern/src/utils/__tests__/patrolSummaryPresentation.test.ts index 17f423445..229248038 100644 --- a/frontend-modern/src/utils/__tests__/patrolSummaryPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/patrolSummaryPresentation.test.ts @@ -344,6 +344,105 @@ describe('getPatrolSummaryPresentation', () => { }); }); + it('adds an activity mix when scoped runs make recent verification look busy', () => { + expect( + getPatrolVerificationPresentation({ + runs: [ + { + id: 'run-scoped-alert', + started_at: '2026-03-12T10:00:00Z', + completed_at: '2026-03-12T10:01:00Z', + duration_ms: 60000, + type: 'scoped', + trigger_reason: 'alert_fired', + resources_checked: 1, + nodes_checked: 0, + guests_checked: 0, + docker_checked: 0, + storage_checked: 0, + hosts_checked: 0, + pbs_checked: 0, + pmg_checked: 0, + kubernetes_checked: 0, + new_findings: 0, + existing_findings: 0, + rejected_findings: 0, + resolved_findings: 0, + auto_fix_count: 0, + findings_summary: '', + finding_ids: [], + error_count: 0, + status: 'healthy', + triage_flags: 0, + tool_call_count: 0, + }, + { + id: 'run-scoped-anomaly', + started_at: '2026-03-12T09:58:00Z', + completed_at: '2026-03-12T09:59:00Z', + duration_ms: 60000, + type: 'scoped', + trigger_reason: 'anomaly', + resources_checked: 1, + nodes_checked: 0, + guests_checked: 0, + docker_checked: 0, + storage_checked: 0, + hosts_checked: 0, + pbs_checked: 0, + pmg_checked: 0, + kubernetes_checked: 0, + new_findings: 0, + existing_findings: 0, + rejected_findings: 0, + resolved_findings: 0, + auto_fix_count: 0, + findings_summary: '', + finding_ids: [], + error_count: 0, + status: 'healthy', + triage_flags: 0, + tool_call_count: 0, + }, + { + id: 'run-full', + started_at: '2026-03-12T09:50:00Z', + completed_at: '2026-03-12T09:57:00Z', + duration_ms: 420000, + type: 'patrol', + resources_checked: 58, + nodes_checked: 0, + guests_checked: 0, + docker_checked: 0, + storage_checked: 0, + hosts_checked: 0, + pbs_checked: 0, + pmg_checked: 0, + kubernetes_checked: 0, + new_findings: 0, + existing_findings: 1, + rejected_findings: 0, + resolved_findings: 0, + auto_fix_count: 0, + findings_summary: '1 warning', + finding_ids: ['finding-1'], + error_count: 0, + status: 'issues_found', + triage_flags: 0, + tool_call_count: 0, + }, + ] as never, + }), + ).toEqual({ + title: 'Recently verified', + description: 'The most recent full patrol completed successfully and checked 58 resources.', + compactLabel: 'Recently verified', + tone: 'success', + lastFullRunAt: '2026-03-12T09:57:00Z', + activityMixLabel: '1 full, 1 alert-triggered, 1 anomaly-triggered', + }); + }); + it('reports partial verification when only scoped runs are recent', () => { expect( getPatrolVerificationPresentation({ diff --git a/frontend-modern/src/utils/patrolSummaryPresentation.ts b/frontend-modern/src/utils/patrolSummaryPresentation.ts index 0f5945814..36d683886 100644 --- a/frontend-modern/src/utils/patrolSummaryPresentation.ts +++ b/frontend-modern/src/utils/patrolSummaryPresentation.ts @@ -2,6 +2,10 @@ import type { PatrolRunRecord, PatrolRuntimeState } from '@/api/patrol'; import type { UnifiedFinding } from '@/stores/aiIntelligence'; import type { IntelligenceHealthScore } from '@/types/aiIntelligence'; import { isPatrolRuntimeFinding } from '@/utils/aiFindingPresentation'; +import { + formatPatrolActivityBreakdown, + getPatrolActivityBreakdown, +} from '@/utils/patrolRunPresentation'; import type { SemanticTone } from '@/utils/semanticTonePresentation'; import { getPatrolRuntimePresentation } from '@/utils/patrolRuntimePresentation'; @@ -50,6 +54,7 @@ export interface PatrolVerificationPresentation { compactLabel: string; tone: SemanticTone; lastFullRunAt?: string; + activityMixLabel?: string; } export interface PatrolRecencyPresentation { @@ -312,6 +317,28 @@ function isVerificationPatrolRun(run: PatrolRunRecord): boolean { return normalizeRunType(run.type) === 'verification'; } +function getVerificationActivityMixLabel(runs: PatrolRunRecord[]): string | undefined { + const latestCompletedRun = runs.find((run) => isCompletedPatrolRun(run)); + const referenceTimestamp = latestCompletedRun?.completed_at || latestCompletedRun?.started_at; + if (!referenceTimestamp) { + return undefined; + } + + const breakdown = getPatrolActivityBreakdown(runs, new Date(referenceTimestamp)); + const scopedRuns = + breakdown.alertTriggeredRuns + + breakdown.anomalyTriggeredRuns + + breakdown.alertClearedRuns + + breakdown.verificationChecks + + breakdown.otherScopedRuns; + if (breakdown.totalRuns <= 1 || scopedRuns <= 0) { + return undefined; + } + + const label = formatPatrolActivityBreakdown(breakdown); + return label || undefined; +} + function isCompletedPatrolRun(run: PatrolRunRecord): boolean { return Boolean(run.completed_at?.trim()); } @@ -442,6 +469,7 @@ export function getPatrolVerificationPresentation(args: { } const completedRuns = (args.runs ?? []).filter((run) => isCompletedPatrolRun(run)); + const activityMixLabel = getVerificationActivityMixLabel(completedRuns); const recentFullRun = completedRuns.find((run) => isFullPatrolRun(run)); if (recentFullRun) { @@ -456,6 +484,7 @@ export function getPatrolVerificationPresentation(args: { compactLabel: 'Verification limited', tone: 'warning', lastFullRunAt: recentFullRun.completed_at, + activityMixLabel, }; } @@ -468,6 +497,7 @@ export function getPatrolVerificationPresentation(args: { compactLabel: 'Recently verified', tone: 'success', lastFullRunAt: recentFullRun.completed_at, + activityMixLabel, }; } @@ -496,6 +526,7 @@ export function getPatrolVerificationPresentation(args: { description, compactLabel: 'Partial verification', tone: 'warning', + activityMixLabel, }; } diff --git a/tests/integration/tests/18-patrol-runtime-state.spec.ts b/tests/integration/tests/18-patrol-runtime-state.spec.ts index 14f3980bf..6a867aa0f 100644 --- a/tests/integration/tests/18-patrol-runtime-state.spec.ts +++ b/tests/integration/tests/18-patrol-runtime-state.spec.ts @@ -516,6 +516,11 @@ test.describe("Patrol runtime-state browser contract", () => { "Breakdown: 1 full, 1 alert-triggered, 1 anomaly-triggered", ), ).toBeVisible(); + await expect( + page.getByText( + "Recent activity mix: 1 full, 1 alert-triggered, 1 anomaly-triggered", + ), + ).toBeVisible(); await expect( page.getByText("Scoped triggers: 4 queued · busy mode · anomalies off"), ).toBeVisible();