diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 17e2fcfed..c9467c4bd 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -379,6 +379,13 @@ denial handling. `ApprovalSection.tsx` and `usePatrolIntelligenceState.ts` may still choose Patrol-specific success copy, but they must not reintroduce local `startProTrial()` status-code branches that diverge from the commercial backend contract. +Pending Patrol fix approvals now also require a canonical urgency order across +the store and Patrol approval surfaces. `frontend-modern/src/stores/aiIntelligence.ts`, +`frontend-modern/src/components/patrol/ApprovalBanner.tsx`, and dashboard +approval consumers must treat the approval queue as `soonest expiry first`, +then higher risk, then older request time, rather than inheriting raw API +order. Approval-linked findings must follow that same ordering so multi-approval +`Review` actions jump to the most urgent finding instead of an arbitrary one. That same store now owns the Patrol dashboard load bundle as well, so the page refresh path stays aligned on a single orchestrated AI bundle instead of repeating the individual summary, findings, approval, and correlation fetches diff --git a/frontend-modern/src/components/patrol/__tests__/ApprovalBanner.test.tsx b/frontend-modern/src/components/patrol/__tests__/ApprovalBanner.test.tsx new file mode 100644 index 000000000..98cb55318 --- /dev/null +++ b/frontend-modern/src/components/patrol/__tests__/ApprovalBanner.test.tsx @@ -0,0 +1,87 @@ +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import ApprovalBanner from '../ApprovalBanner'; +import type { ApprovalRequest } from '@/api/ai'; +import type { UnifiedFinding } from '@/stores/aiIntelligence'; + +const state = vi.hoisted(() => ({ + pendingApprovals: [] as ApprovalRequest[], + findingsWithPendingApprovals: [] as UnifiedFinding[], +})); + +vi.mock('@/stores/aiIntelligence', () => ({ + aiIntelligenceStore: { + get pendingApprovals() { + return state.pendingApprovals; + }, + get findingsWithPendingApprovals() { + return state.findingsWithPendingApprovals; + }, + approveInvestigationFix: vi.fn(), + denyInvestigationFix: vi.fn(), + }, +})); + +vi.mock('@/stores/notifications', () => ({ + notificationStore: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +describe('ApprovalBanner', () => { + beforeEach(() => { + state.pendingApprovals = []; + state.findingsWithPendingApprovals = []; + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-01T00:00:00Z')); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it('reviews the first approval-linked finding in canonical urgency order', () => { + state.pendingApprovals = [ + { + id: 'approval-sooner', + toolId: 'investigation_fix', + command: 'restart sooner', + targetType: 'host', + targetId: 'finding-sooner', + targetName: 'node-201', + context: 'Sooner approval', + riskLevel: 'high', + status: 'pending', + requestedAt: '2026-03-01T00:02:00Z', + expiresAt: '2026-03-01T00:06:00Z', + }, + { + id: 'approval-later', + toolId: 'investigation_fix', + command: 'restart later', + targetType: 'host', + targetId: 'finding-later', + targetName: 'node-200', + context: 'Later approval', + riskLevel: 'low', + status: 'pending', + requestedAt: '2026-03-01T00:01:00Z', + expiresAt: '2026-03-01T00:10:00Z', + }, + ] as ApprovalRequest[]; + + state.findingsWithPendingApprovals = [ + { id: 'finding-sooner' }, + { id: 'finding-later' }, + ] as UnifiedFinding[]; + + const onScrollToFinding = vi.fn(); + render(() => ); + + fireEvent.click(screen.getByRole('button', { name: 'Review' })); + + expect(onScrollToFinding).toHaveBeenCalledWith('finding-sooner'); + }); +}); diff --git a/frontend-modern/src/stores/__tests__/aiIntelligence.test.ts b/frontend-modern/src/stores/__tests__/aiIntelligence.test.ts index a0fb913dc..b1c4271fd 100644 --- a/frontend-modern/src/stores/__tests__/aiIntelligence.test.ts +++ b/frontend-modern/src/stores/__tests__/aiIntelligence.test.ts @@ -474,4 +474,81 @@ describe('aiIntelligenceStore', () => { 'infra-warning', ]); }); + + it('sorts pending approvals and approval-linked findings by urgency', async () => { + vi.mocked(AIAPI.getUnifiedFindings).mockResolvedValueOnce({ + findings: [ + { + id: 'finding-later', + source: 'ai-patrol', + severity: 'warning', + category: 'performance', + resource_id: 'instance:node:200', + resource_name: 'node-200', + resource_type: 'host', + title: 'Queued remediation later', + description: 'Later approval.', + detected_at: '2026-03-01T00:00:00Z', + status: 'active', + investigation_outcome: 'fix_queued', + }, + { + id: 'finding-sooner', + source: 'ai-patrol', + severity: 'warning', + category: 'performance', + resource_id: 'instance:node:201', + resource_name: 'node-201', + resource_type: 'host', + title: 'Queued remediation sooner', + description: 'Sooner approval.', + detected_at: '2026-03-01T00:00:00Z', + status: 'active', + investigation_outcome: 'fix_queued', + }, + ], + count: 2, + }); + + vi.mocked(AIAPI.getPendingApprovals).mockResolvedValueOnce([ + { + id: 'approval-later', + toolId: 'investigation_fix', + command: 'restart later', + targetType: 'host', + targetId: 'finding-later', + targetName: 'node-200', + context: 'Later approval', + riskLevel: 'low', + status: 'pending', + requestedAt: '2026-03-01T00:01:00Z', + expiresAt: '2026-04-01T00:10:00Z', + }, + { + id: 'approval-sooner', + toolId: 'investigation_fix', + command: 'restart sooner', + targetType: 'host', + targetId: 'finding-sooner', + targetName: 'node-201', + context: 'Sooner approval', + riskLevel: 'high', + status: 'pending', + requestedAt: '2026-03-01T00:02:00Z', + expiresAt: '2026-04-01T00:06:00Z', + }, + ]); + + await aiIntelligenceStore.loadFindings(); + await aiIntelligenceStore.loadPendingApprovals(); + + expect(aiIntelligenceStore.pendingApprovals.map((approval) => approval.id)).toEqual([ + 'approval-sooner', + 'approval-later', + ]); + expect(aiIntelligenceStore.findingsWithPendingApprovals.map((finding) => finding.id)).toEqual([ + 'finding-sooner', + 'finding-later', + ]); + }); }); diff --git a/frontend-modern/src/stores/aiIntelligence.ts b/frontend-modern/src/stores/aiIntelligence.ts index 99d7fab5e..f73d5e812 100644 --- a/frontend-modern/src/stores/aiIntelligence.ts +++ b/frontend-modern/src/stores/aiIntelligence.ts @@ -25,6 +25,7 @@ import { sortFindingsForAttentionQueue, } from '@/utils/aiFindingPresentation'; import { getApprovalExpiryTime, isLivePendingApproval } from '@/utils/approvalState'; +import { sortPendingApprovalsByUrgency } from '@/utils/approvalRiskPresentation'; import { logger } from '@/utils/logger'; import type { CorrelationsResponse, @@ -434,7 +435,7 @@ export const aiIntelligenceStore = { // Pending Approvals get pendingApprovals() { - return getLivePendingApprovals(); + return sortPendingApprovalsByUrgency(getLivePendingApprovals()); }, get approvalsError() { return approvalsError(); @@ -447,9 +448,16 @@ export const aiIntelligenceStore = { get findingsWithPendingApprovals() { const approvals = getLivePendingApprovals(); - return unifiedFindings().filter((finding) => - hasPendingInvestigationFixApproval(finding.id, approvals), + const approvalOrder = new Map( + sortPendingApprovalsByUrgency(approvals).map((approval, index) => [approval.targetId, index]), ); + return unifiedFindings() + .filter((finding) => hasPendingInvestigationFixApproval(finding.id, approvals)) + .sort( + (a, b) => + (approvalOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - + (approvalOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER), + ); }, get findingsNeedingAttention() { diff --git a/frontend-modern/src/utils/approvalRiskPresentation.ts b/frontend-modern/src/utils/approvalRiskPresentation.ts index 5a5e18015..9e561585f 100644 --- a/frontend-modern/src/utils/approvalRiskPresentation.ts +++ b/frontend-modern/src/utils/approvalRiskPresentation.ts @@ -3,6 +3,14 @@ export interface ApprovalRiskPresentation { label: string; } +const APPROVAL_RISK_SORT_ORDER: Record = { + critical: 0, + high: 0, + medium: 1, + low: 2, + unknown: 3, +}; + function normalizeApprovalRiskLevel(level?: string): string { const normalized = level?.trim().toLowerCase(); if (!normalized) return 'unknown'; @@ -36,3 +44,22 @@ export function getApprovalRiskPresentation(level?: string): ApprovalRiskPresent }; } } + +export function getApprovalRiskSortOrder(level?: string): number { + const normalized = normalizeApprovalRiskLevel(level); + return APPROVAL_RISK_SORT_ORDER[normalized] ?? APPROVAL_RISK_SORT_ORDER.unknown; +} + +export function sortPendingApprovalsByUrgency< + T extends { expiresAt: string; requestedAt: string; riskLevel?: string }, +>(approvals: T[]): T[] { + return [...approvals].sort((a, b) => { + const expiryDiff = new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime(); + if (expiryDiff !== 0) return expiryDiff; + + const riskDiff = getApprovalRiskSortOrder(a.riskLevel) - getApprovalRiskSortOrder(b.riskLevel); + if (riskDiff !== 0) return riskDiff; + + return new Date(a.requestedAt).getTime() - new Date(b.requestedAt).getTime(); + }); +}