From 624d25ba4f50cc483efeb0586c6827d91c2fc6e2 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 25 Mar 2026 22:42:13 +0000 Subject: [PATCH] Scope Patrol findings badge to run snapshots --- .../subsystems/patrol-intelligence.md | 4 ++ .../AI/__tests__/FindingsPanel.test.ts | 3 +- .../patrol/PatrolIntelligenceWorkspace.tsx | 9 ++-- .../patrol/usePatrolIntelligenceState.ts | 46 ++++++++++++++----- .../pages/__tests__/AIIntelligence.test.tsx | 30 ++++++++++-- 5 files changed, 72 insertions(+), 20 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 484501225..c6aa36bc6 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -255,6 +255,10 @@ That same snapshot scoping must apply to the findings control bar too. When the user is looking at an explicit run snapshot, filter pills and their attention or approval counts must derive from that snapshot-scoped finding set rather than borrowing global Patrol finding counts from outside the selected run. +That same snapshot scoping must also apply to the `Findings` tab badge itself. +When the selected run carries an explicit empty `finding_ids` snapshot, or when +the run lacks snapshot ids entirely, the tab must fail closed instead of +borrowing global active-finding counts and tones from outside the selected run. That same findings surface should keep its section chrome functional rather than promotional. Inside the Patrol findings tab, the selected tab already names the surface, so the findings card should not add another in-card product diff --git a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts index 7a288ead8..adbb750c1 100644 --- a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts +++ b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts @@ -481,12 +481,13 @@ describe('aiFindingPresentation', () => { it('uses explicit textual separators for patrol tab badges instead of css-only spacing', () => { expect(patrolWorkspaceSource).toContain("aria-hidden=\"true\""); expect(patrolWorkspaceSource).toContain("{' '}"); - expect(patrolWorkspaceSource).toContain('{state.summaryStats().totalActive}'); + expect(patrolWorkspaceSource).toContain('{state.findingsTabBadgeCount()}'); expect(patrolWorkspaceSource).toContain('{state.displayRunHistory().length}'); }); it('routes the findings tab badge tone through the shared patrol findings badge helper', () => { expect(patrolWorkspaceSource).toContain('getPatrolFindingsBadgePresentation'); + expect(patrolWorkspaceSource).toContain('state.findingsTabBadgeFindings()'); expect(patrolWorkspaceSource).toContain('findingsBadgePresentation().toneClasses'); }); diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx index b1e415da8..3a22a8250 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx @@ -11,7 +11,8 @@ import type { PatrolIntelligenceState } from './usePatrolIntelligenceState'; export function PatrolIntelligenceWorkspace(props: { state: PatrolIntelligenceState }) { const state = props.state; - const findingsBadgePresentation = () => getPatrolFindingsBadgePresentation(state.activePatrolFindings()); + const findingsBadgePresentation = () => + getPatrolFindingsBadgePresentation(state.findingsTabBadgeFindings()); return ( <> @@ -49,13 +50,13 @@ export function PatrolIntelligenceWorkspace(props: { state: PatrolIntelligenceSt }`} > Findings - 0}> + 0}> @@ -108,11 +109,11 @@ export function PatrolIntelligenceWorkspace(props: { state: PatrolIntelligenceSt filterFindingIds={state.selectedRunFindingIds()} scopeResourceIds={state.selectedRunScopeResourceIds()} scopeResourceTypes={state.selectedRun()?.scope_resource_types} - runSnapshot={state.selectedRun() ?? undefined} showScopeWarnings={Boolean(state.selectedRun())} runtimeState={state.runtimeState()} blockedReason={state.blockedReason()} overallHealth={state.intelligenceSummary()?.overall_health} + runSnapshot={state.selectedRun() ?? undefined} /> diff --git a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts index 054ba7426..c2dc060ac 100644 --- a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts +++ b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts @@ -437,6 +437,19 @@ export function usePatrolIntelligenceState() { }); const selectedRunScopeResourceIds = createMemo(() => getCanonicalScopeResourceIds(selectedRun())); + const allPatrolFindings = createMemo(() => + aiIntelligenceStore.findings.filter( + (finding) => + finding.source !== 'threshold' && !finding.isThreshold && !hasTriggeringAlert(finding), + ), + ); + const selectedRunPatrolFindings = createMemo(() => { + const run = selectedRun(); + if (!run) return null; + if (run.finding_ids === undefined) return undefined; + const snapshotFindingIds = new Set(run.finding_ids); + return allPatrolFindings().filter((finding) => snapshotFindingIds.has(finding.id)); + }); const intelligenceSummary = createMemo(() => aiIntelligenceStore.intelligenceSummary); const policyPosture = createMemo(() => intelligenceSummary()?.policy_posture); @@ -584,11 +597,7 @@ export function usePatrolIntelligenceState() { } const summaryStats = () => { - const allFindings = aiIntelligenceStore.findings; - const patrolFindings = allFindings.filter( - (finding) => - finding.source !== 'threshold' && !finding.isThreshold && !hasTriggeringAlert(finding), - ); + const patrolFindings = allPatrolFindings(); const activeFindings = patrolFindings.filter((finding) => finding.status === 'active'); return { @@ -606,13 +615,24 @@ export function usePatrolIntelligenceState() { }; const activePatrolFindings = () => - aiIntelligenceStore.findings.filter( - (finding) => - finding.status === 'active' && - finding.source !== 'threshold' && - !finding.isThreshold && - !hasTriggeringAlert(finding), - ); + allPatrolFindings().filter((finding) => finding.status === 'active'); + const findingsTabBadgeFindings = createMemo(() => { + const snapshotFindings = selectedRunPatrolFindings(); + if (snapshotFindings === null) { + return activePatrolFindings(); + } + return snapshotFindings ?? []; + }); + const findingsTabBadgeCount = createMemo(() => { + const snapshotFindings = selectedRunPatrolFindings(); + if (snapshotFindings === null) { + return activePatrolFindings().length; + } + if (snapshotFindings === undefined) { + return undefined; + } + return snapshotFindings.length; + }); onMount(async () => { await Promise.all([ @@ -670,6 +690,8 @@ export function usePatrolIntelligenceState() { clearScrollToFindingTimer, defaultModel, displayRunHistory, + findingsTabBadgeCount, + findingsTabBadgeFindings, findingsFilterOverride, fullModeUnlocked, handleAlertTriggeredAnalysisChange, diff --git a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx index a50b99695..2bea676cd 100644 --- a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx +++ b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx @@ -983,6 +983,16 @@ describe('AIIntelligence entitlement gating', () => { hasFeatureMock.mockReturnValue(true); licenseStatusMock.mockReturnValue({ subscription_state: 'active' }); getPatrolStatusMock.mockResolvedValue(defaultPatrolStatus({ license_required: false })); + intelligenceState.findings = [ + { + id: 'finding-runtime', + status: 'active', + severity: 'warning', + resourceId: 'ai-service', + resourceName: 'Pulse Patrol Service', + title: 'Pulse Patrol: Insufficient API credits', + }, + ]; runHistoryState.selection = { id: 'run-empty', started_at: '2026-03-12T10:00:00Z', @@ -1019,7 +1029,6 @@ describe('AIIntelligence entitlement gating', () => { await waitFor(() => { expect(getPatrolStatusMock).toHaveBeenCalled(); expect(findingsPanelState.latestProps).not.toBeNull(); - runSnapshotId: 'run-empty', }); fireEvent.click(screen.getByRole('button', { name: 'Runs' })); @@ -1030,12 +1039,15 @@ describe('AIIntelligence entitlement gating', () => { expect(screen.getByText(/Filtered to run/i)).toBeInTheDocument(); }); + expect(screen.getByRole('button', { name: 'Findings' }).textContent).toBe('Findings'); + expect(findingsPanelState.latestProps).toMatchObject({ filterOverride: 'all', filterFindingIds: [], scopeResourceIds: ['expanded-a', 'expanded-b'], scopeResourceTypes: ['vm'], showScopeWarnings: true, + runSnapshotId: 'run-empty', }); }); @@ -1079,7 +1091,6 @@ describe('AIIntelligence entitlement gating', () => { await waitFor(() => { expect(getPatrolStatusMock).toHaveBeenCalled(); expect(findingsPanelState.latestProps).not.toBeNull(); - runSnapshotId: 'run-empty-effective-scope', }); fireEvent.click(screen.getByRole('button', { name: 'Runs' })); @@ -1096,6 +1107,7 @@ describe('AIIntelligence entitlement gating', () => { scopeResourceIds: [], scopeResourceTypes: ['vm'], showScopeWarnings: true, + runSnapshotId: 'run-empty-effective-scope', }); }); @@ -1103,6 +1115,16 @@ describe('AIIntelligence entitlement gating', () => { hasFeatureMock.mockReturnValue(true); licenseStatusMock.mockReturnValue({ subscription_state: 'active' }); getPatrolStatusMock.mockResolvedValue(defaultPatrolStatus({ license_required: false })); + intelligenceState.findings = [ + { + id: 'finding-runtime', + status: 'active', + severity: 'warning', + resourceId: 'ai-service', + resourceName: 'Pulse Patrol Service', + title: 'Pulse Patrol: Insufficient API credits', + }, + ]; runHistoryState.selection = { id: 'run-missing-snapshot', started_at: '2026-03-12T10:10:00Z', @@ -1138,7 +1160,6 @@ describe('AIIntelligence entitlement gating', () => { await waitFor(() => { expect(getPatrolStatusMock).toHaveBeenCalled(); expect(findingsPanelState.latestProps).not.toBeNull(); - runSnapshotId: 'run-missing-snapshot', }); fireEvent.click(screen.getByRole('button', { name: 'Runs' })); @@ -1149,12 +1170,15 @@ describe('AIIntelligence entitlement gating', () => { expect(screen.getByText(/Filtered to run/i)).toBeInTheDocument(); }); + expect(screen.getByRole('button', { name: 'Findings' }).textContent).toBe('Findings'); + expect(findingsPanelState.latestProps).toMatchObject({ filterOverride: 'all', filterFindingIds: undefined, scopeResourceIds: ['expanded-a'], scopeResourceTypes: ['vm'], showScopeWarnings: true, + runSnapshotId: 'run-missing-snapshot', }); }); });