Scope Patrol findings badge to run snapshots

This commit is contained in:
rcourtman 2026-03-25 22:42:13 +00:00
parent 7354453f73
commit 624d25ba4f
5 changed files with 72 additions and 20 deletions

View file

@ -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

View file

@ -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');
});

View file

@ -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
<Show when={state.summaryStats().totalActive > 0}>
<Show when={(state.findingsTabBadgeCount() ?? 0) > 0}>
<span
aria-hidden="true"
class={`ml-1.5 px-1.5 py-0.5 text-xs rounded-full ${findingsBadgePresentation().toneClasses}`}
>
{' '}
{state.summaryStats().totalActive}
{state.findingsTabBadgeCount()}
</span>
</Show>
</button>
@ -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}
/>
</Show>

View file

@ -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,

View file

@ -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',
});
});
});