diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 251bfc8252..3ac898ea7a 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -1361,8 +1361,21 @@ remove settled items and append genuinely new work. Acknowledging an occurrence is presented as `Mark reviewed`: on success the item leaves the active inbox, Patrol announces the remaining decision count, and advances to the next current item. Temporary suppression uses the same advancement loop and explicitly says -that the occurrence will return at the chosen time. Clearing the final item -returns to the calm inbox with an honest completion announcement. +when it ends and which state it hands the occurrence back to. Clearing the +final item returns to the calm inbox with an honest completion announcement. +Ending a suppression, early or at its expiry, puts the alert in Acknowledged +when it is acknowledged and in Open otherwise (`unsuppressOperationalAlert`), +and Acknowledged is not active attention. So for an issue marked reviewed +(`operationalRecord.acknowledgement` present) the suppression form's duration +reads `End suppression after` and the early-end control reads `End +suppression`; an unreviewed issue keeps `Return it to active attention after` +and `Return to active attention`. The handled-view notice after ending it reads +the state the post-change reload reports for the item, `Suppression ended. It +stays reviewed.` for Acknowledged and `Returned to decision inbox` otherwise, +so a detail read before the review changed elsewhere cannot misstate the +outcome; it falls back to the detail's acknowledgement only when the item is +gone from the reload. `getAttentionSuppressionEndPresentation` owns that copy +and `PatrolAttentionWorkbench.test.tsx` pins all three cases. The attention queue orders genuine operator decisions by severity, actionable approval/review state, and latest observation time. Collapsed rows expose the diff --git a/frontend-modern/browser-verification/fb946589c643778e.json b/frontend-modern/browser-verification/fb946589c643778e.json new file mode 100644 index 0000000000..772fcdb973 --- /dev/null +++ b/frontend-modern/browser-verification/fb946589c643778e.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "base_sha": "9aaac4d3e502c01913fb3637d0997b0ce03a8b7b", + "verified_at": "2026-10-06T19:36:25Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx" + ], + "content_sha256": { + "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx": "11fbba8f8a4e3a62aaceaed796ff9d67a09e6248bfe8eb95163a92c3dd85e728" + }, + "routes": [ + "/patrol" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 800, + "height": 600 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack (PULSE_MOCK_MODE) built from this tree on origin/main 9aaac4d3e5, Vite on the same checkout; real alert-manager lifecycle, no injected data", + "Reviewed then suppressed issue (operationalRecord.acknowledgement and suppression both set, state suppressed): Lifecycle shows the acknowledgement line, the suppression reason and an End suppression button, and no Return to active attention button (1440, 800 and 390 widths)", + "Suppression form on a Reviewed issue labels its duration End suppression after; on an open issue it still reads Return it to active attention after", + "After ending a reviewed issue's suppression: notice Suppression ended. It stays reviewed. N handled issues remain., list row reads Reviewed, and the attention API reports state acknowledged with acknowledgement kept and the active count unchanged", + "After ending an unreviewed issue's suppression: Return to active attention button, notice Returned to decision inbox. N handled issues remain., API state open (1440 and 390 widths)", + "Stale detail: a reviewed, suppressed issue whose review was removed through POST /api/ai/patrol/attention/{id}/unacknowledge while its detail stayed open; the button still read End suppression, and after clicking it the notice read Returned to decision inbox. 5 handled issues remain., matching the API state open", + "The suppression expiry line still reads Returns to active attention just now on main; fix/future-relative-time changes that line separately, and it was left untouched here", + "No page errors in any run" + ], + "interactions": [ + "Browser pane at 800x600: opened west-d-service-pool (local-zfs) \u00b7 ZFS pool state, Mark reviewed, Reviewed and suppressed (1), reopened it, Suppress temporarily with a typed reason and 24 hours, then End suppression", + "Playwright 1440x900: reviewed case (Mark reviewed, handled view, suppress, End suppression) on mail-gateway-eu and esxi-07.lab.local; unreviewed case (suppress from the inbox, handled view, Return to active attention) on backend-seafile and edge-cold-iscsi; stale-detail case on Utility Network", + "Playwright 390x844: reviewed case on postgres-replica-18 and Edge Stateful, unreviewed case on dev-portal-01 and archive-tier, reopening items through Back to handled issues after the queue advanced", + "Read each item's resulting state through GET /api/ai/patrol/attention/{id} and the attention summary after every lifecycle change" + ] +} diff --git a/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx b/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx index 72ac52cd43..3b34ff89bc 100644 --- a/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx +++ b/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx @@ -142,6 +142,23 @@ export function sortPatrolAttentionDecisions( }); } +// Ending a suppression, early or at its expiry, puts the alert in +// Acknowledged when it is acknowledged, which is not active attention, and +// otherwise in Open, back in the decision inbox. +function getAttentionSuppressionEndPresentation(reviewed: boolean) { + return reviewed + ? { + durationLabel: 'End suppression after', + actionLabel: 'End suppression', + successLabel: 'Suppression ended. It stays reviewed', + } + : { + durationLabel: 'Return it to active attention after', + actionLabel: 'Return to active attention', + successLabel: 'Returned to decision inbox', + }; +} + export function PatrolAttentionWorkbench( props: { autonomyLevel?: PatrolAutonomyLevel; @@ -311,7 +328,7 @@ export function PatrolAttentionWorkbench( }; const changeLifecycle = async ( operation: () => Promise, - options: { advanceAfter?: boolean; successLabel?: string } = {}, + options: { advanceAfter?: boolean; successLabel?: string | (() => string) } = {}, ) => { if (lifecycleBusy()) return; const selected = selectedItemId(); @@ -328,7 +345,10 @@ export function PatrolAttentionWorkbench( const remaining = orderedDecisions(); const next = remaining.find((decision) => decision.item.id === nextCandidateId) ?? remaining[0]; - const successLabel = options.successLabel ?? 'Decision updated'; + const successLabel = + (typeof options.successLabel === 'function' + ? options.successLabel() + : options.successLabel) ?? 'Decision updated'; setReviewNotice( attentionView() === 'handled' ? remaining.length > 0 @@ -565,10 +585,17 @@ export function PatrolAttentionWorkbench( successLabel: 'Suppressed temporarily', }) } - onUnsuppress={(itemId) => + onUnsuppress={(itemId, reviewed) => changeLifecycle(() => unsuppressPatrolAttention(itemId), { advanceAfter: attentionView() === 'handled', - successLabel: 'Returned to decision inbox', + // Announce the state the reload reports, so a detail read + // before the review changed elsewhere cannot misstate it. + successLabel: () => { + const after = patrolAttentionStore.items().find((entry) => entry.id === itemId); + return getAttentionSuppressionEndPresentation( + after ? after.state === 'acknowledged' : reviewed, + ).successLabel; + }, }) } onOpenFindings={props.onOpenFindings} @@ -835,7 +862,7 @@ function AttentionDetail(props: { onAcknowledge: (itemId: string) => Promise; onUnacknowledge: (itemId: string) => Promise; onSuppress: (itemId: string, reason: string, expiresAt: string) => Promise; - onUnsuppress: (itemId: string) => Promise; + onUnsuppress: (itemId: string, reviewed: boolean) => Promise; onOpenFindings?: (item: AttentionItem) => void; findings?: () => UnifiedFinding[]; }) { @@ -1254,12 +1281,14 @@ function AttentionLifecycleControls(props: { onAcknowledge: (itemId: string) => Promise; onUnacknowledge: (itemId: string) => Promise; onSuppress: (itemId: string, reason: string, expiresAt: string) => Promise; - onUnsuppress: (itemId: string) => Promise; + onUnsuppress: (itemId: string, reviewed: boolean) => Promise; }) { const [showSuppression, setShowSuppression] = createSignal(false); const [reason, setReason] = createSignal(''); const [durationMs, setDurationMs] = createSignal(SUPPRESSION_DURATIONS[1].value); const state = () => props.detail.item.state; + const reviewed = () => Boolean(props.detail.operationalRecord.acknowledgement); + const suppressionEnd = () => getAttentionSuppressionEndPresentation(reviewed()); const canAcknowledge = () => ['open', 'stale', 'unknown', 'resolving'].includes(state()); const canSuppress = () => ['open', 'acknowledged', 'stale', 'unknown', 'resolving'].includes(state()); @@ -1333,9 +1362,9 @@ function AttentionLifecycleControls(props: { size="sm" class="min-h-11 sm:min-h-0" isLoading={props.busy} - onClick={() => void props.onUnsuppress(props.detail.item.id)} + onClick={() => void props.onUnsuppress(props.detail.item.id, reviewed())} > - Return to active attention + {suppressionEnd().actionLabel} @@ -1376,7 +1405,7 @@ function AttentionLifecycleControls(props: { /> { ).toBeInTheDocument(); }); + it('keeps a reviewed issue reviewed when its suppression ends early', async () => { + // Ending the suppression restores Acknowledged, not active attention, so + // nothing on the way in or out may promise the decision inbox. + const reviewed = item({ state: 'acknowledged' }); + const suppressed = item({ state: 'suppressed' }); + const acknowledgement = { at: evaluatedAt, by: 'admin' }; + const reviewedDetail = detail(reviewed); + reviewedDetail.operationalRecord.acknowledgement = acknowledgement; + const suppressedDetail = detail(suppressed); + suppressedDetail.operationalRecord.acknowledgement = acknowledgement; + suppressedDetail.operationalRecord.suppression = { + at: evaluatedAt, + by: 'admin', + reason: 'Planned storage maintenance', + expiresAt: '2026-07-20T08:00:00Z', + }; + let isSuppressed = false; + apiMocks.getList.mockImplementation((filter: string) => { + const responseSummary = summary( + isSuppressed ? { suppressedCount: 1 } : { acknowledgedCount: 1 }, + ); + return Promise.resolve( + listResponse( + filter === 'all' ? [isSuppressed ? suppressed : reviewed] : [], + responseSummary, + ), + ); + }); + apiMocks.getDetail.mockImplementation(() => + Promise.resolve(isSuppressed ? suppressedDetail : reviewedDetail), + ); + apiMocks.suppress.mockImplementation(() => { + isSuppressed = true; + return Promise.resolve({ success: true }); + }); + apiMocks.unsuppress.mockImplementation(() => { + isSuppressed = false; + return Promise.resolve({ success: true }); + }); + renderWorkbench(); + + fireEvent.click(await screen.findByRole('button', { name: 'Reviewed and suppressed (1)' })); + fireEvent.click( + await screen.findByRole('button', { + name: 'Open Database VM · Disk pressure', + }), + ); + fireEvent.click(await screen.findByRole('button', { name: 'Suppress temporarily' })); + expect( + screen.queryByRole('combobox', { name: 'Return it to active attention after' }), + ).not.toBeInTheDocument(); + fireEvent.input( + screen.getByRole('textbox', { + name: 'Why is this safe to hide from active attention?', + }), + { target: { value: 'Planned storage maintenance' } }, + ); + fireEvent.change(screen.getByRole('combobox', { name: 'End suppression after' }), { + target: { value: String(60 * 60 * 1000) }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Suppress temporarily' })); + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent( + 'Suppressed temporarily. 1 handled issue remains.', + ), + ); + + const endSuppression = await screen.findByRole('button', { name: 'End suppression' }); + expect( + screen.queryByRole('button', { name: 'Return to active attention' }), + ).not.toBeInTheDocument(); + fireEvent.click(endSuppression); + + await waitFor(() => expect(apiMocks.unsuppress).toHaveBeenCalledWith('record-1')); + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent( + 'Suppression ended. It stays reviewed. 1 handled issue remains.', + ), + ); + expect(screen.getByRole('status')).not.toHaveTextContent('decision inbox'); + expect( + screen.getByRole('heading', { name: '1 reviewed or suppressed issue' }), + ).toBeInTheDocument(); + expect( + await screen.findByRole('button', { name: 'Return to decision inbox' }), + ).toBeInTheDocument(); + }); + + it('returns an unreviewed issue to the decision inbox when its suppression ends early', async () => { + const active = item(); + const suppressed = item({ state: 'suppressed' }); + const suppressedDetail = detail(suppressed); + suppressedDetail.operationalRecord.suppression = { + at: evaluatedAt, + by: 'admin', + reason: 'Planned storage maintenance', + expiresAt: '2026-07-20T08:00:00Z', + }; + let isSuppressed = true; + apiMocks.getList.mockImplementation((filter: string) => { + if (isSuppressed) { + return Promise.resolve( + listResponse(filter === 'all' ? [suppressed] : [], summary({ suppressedCount: 1 })), + ); + } + return Promise.resolve( + listResponse([active], summary({ activeCount: 1, openCount: 1, calm: false })), + ); + }); + apiMocks.getDetail.mockImplementation(() => + Promise.resolve(isSuppressed ? suppressedDetail : detail(active)), + ); + apiMocks.unsuppress.mockImplementation(() => { + isSuppressed = false; + return Promise.resolve({ success: true }); + }); + renderWorkbench(); + + fireEvent.click(await screen.findByRole('button', { name: 'Reviewed and suppressed (1)' })); + fireEvent.click( + await screen.findByRole('button', { + name: 'Open Database VM · Disk pressure', + }), + ); + const returnToAttention = await screen.findByRole('button', { + name: 'Return to active attention', + }); + expect(screen.queryByRole('button', { name: 'End suppression' })).not.toBeInTheDocument(); + fireEvent.click(returnToAttention); + + await waitFor(() => expect(apiMocks.unsuppress).toHaveBeenCalledWith('record-1')); + expect(await screen.findByRole('status')).toHaveTextContent( + 'Returned to decision inbox. No other handled issues remain.', + ); + expect( + screen.getByRole('heading', { name: 'No reviewed or suppressed issues' }), + ).toBeInTheDocument(); + }); + + it('announces the state the reload reports when the review changed after the detail loaded', async () => { + // The detail still carries the acknowledgement, but the review was removed + // elsewhere, so ending the suppression reopens the issue. + const active = item(); + const suppressed = item({ state: 'suppressed' }); + const staleDetail = detail(suppressed); + staleDetail.operationalRecord.acknowledgement = { at: evaluatedAt, by: 'admin' }; + staleDetail.operationalRecord.suppression = { + at: evaluatedAt, + by: 'admin', + reason: 'Planned storage maintenance', + expiresAt: '2026-07-20T08:00:00Z', + }; + let isSuppressed = true; + apiMocks.getList.mockImplementation((filter: string) => + Promise.resolve( + isSuppressed + ? listResponse(filter === 'all' ? [suppressed] : [], summary({ suppressedCount: 1 })) + : listResponse([active], summary({ activeCount: 1, openCount: 1, calm: false })), + ), + ); + apiMocks.getDetail.mockImplementation(() => + Promise.resolve(isSuppressed ? staleDetail : detail(active)), + ); + apiMocks.unsuppress.mockImplementation(() => { + isSuppressed = false; + return Promise.resolve({ success: true }); + }); + renderWorkbench(); + + fireEvent.click(await screen.findByRole('button', { name: 'Reviewed and suppressed (1)' })); + fireEvent.click( + await screen.findByRole('button', { + name: 'Open Database VM · Disk pressure', + }), + ); + fireEvent.click(await screen.findByRole('button', { name: 'End suppression' })); + + await waitFor(() => expect(apiMocks.unsuppress).toHaveBeenCalledWith('record-1')); + expect(await screen.findByRole('status')).toHaveTextContent( + 'Returned to decision inbox. No other handled issues remain.', + ); + }); + it('offers every lasting Patrol decision on the finding that mirrors the alert', async () => { const active = item(); apiMocks.getList.mockResolvedValue(