mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 22:12:23 +00:00
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:
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user