Canonicalize Patrol approval queue order

Sort pending Patrol approvals and approval-linked findings by urgency in the shared store contract so Patrol review surfaces consistently lead with the most urgent approval.
This commit is contained in:
rcourtman
2026-03-25 21:47:53 +00:00
parent dee01cb85f
commit eabebcce7f
5 changed files with 209 additions and 3 deletions
@@ -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
@@ -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(() => <ApprovalBanner onScrollToFinding={onScrollToFinding} />);
fireEvent.click(screen.getByRole('button', { name: 'Review' }));
expect(onScrollToFinding).toHaveBeenCalledWith('finding-sooner');
});
});
@@ -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',
]);
});
});
+11 -3
View File
@@ -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() {
@@ -3,6 +3,14 @@ export interface ApprovalRiskPresentation {
label: string;
}
const APPROVAL_RISK_SORT_ORDER: Record<string, number> = {
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();
});
}