mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Say a reviewed Patrol issue stays reviewed when suppression ends (#2615)
Ending a suppression puts the alert in Acknowledged when it is
acknowledged and in Open otherwise (`unsuppressOperationalAlert` in
internal/alerts/active_lifecycle.go). Acknowledged is not active
attention (`attentionStateCountsAsActive`).
The Patrol attention detail still promised otherwise for an issue that
was marked reviewed and then suppressed:
- its early-end button read "Return to active attention"
- the handled-view notice read "Returned to decision inbox"
- the suppression form offered "Return it to active attention after"
In each case the issue actually went back to Reviewed.
| | Reviewed issue | Unreviewed issue (unchanged) |
|---|---|---|
| Suppression duration | End suppression after | Return it to active
attention after |
| Early-end button | End suppression | Return to active attention |
| Handled-view notice | Suppression ended. It stays reviewed. | Returned
to decision inbox |
The duration label and the button follow the detail's
`operationalRecord.acknowledgement`. The notice instead reads the item's
state from the reload that follows the change. If the review was removed
elsewhere after the detail loaded (another tab), it still reports what
actually happened. `getAttentionSuppressionEndPresentation` owns the
copy.
The countdown line under the suppression ("Returns to active attention
in 1d.") is fixed separately by fix/future-relative-time, using the same
wording.
Verification:
- Three new tests in `PatrolAttentionWorkbench.test.tsx`:
- The reviewed case fails on main.
- The stale-detail case fails on a version that predicts the notice from
the detail.
- The unreviewed case pins the copy that stays the same.
- `src/features/patrol` and the two other test files that import the
workbench pass (492 tests).
- Run live on a private mock stack at 1440px, 800px and 390px:
- Reviewed case: mark an issue reviewed, suppress it, end the
suppression early from the handled view. The button read End
suppression, the notice read "Suppression ended. It stays reviewed. N
handled issues remain.", and the attention API reported the item
`acknowledged` with the active count unchanged.
- Unreviewed case: the item came back to the inbox as `open`.
- Stale-detail case: the review was removed through the API while the
detail stayed open. The notice read "Returned to decision inbox",
matching the API state `open`.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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<unknown>,
|
||||
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<void>;
|
||||
onUnacknowledge: (itemId: string) => Promise<void>;
|
||||
onSuppress: (itemId: string, reason: string, expiresAt: string) => Promise<void>;
|
||||
onUnsuppress: (itemId: string) => Promise<void>;
|
||||
onUnsuppress: (itemId: string, reviewed: boolean) => Promise<void>;
|
||||
onOpenFindings?: (item: AttentionItem) => void;
|
||||
findings?: () => UnifiedFinding[];
|
||||
}) {
|
||||
@@ -1254,12 +1281,14 @@ function AttentionLifecycleControls(props: {
|
||||
onAcknowledge: (itemId: string) => Promise<void>;
|
||||
onUnacknowledge: (itemId: string) => Promise<void>;
|
||||
onSuppress: (itemId: string, reason: string, expiresAt: string) => Promise<void>;
|
||||
onUnsuppress: (itemId: string) => Promise<void>;
|
||||
onUnsuppress: (itemId: string, reviewed: boolean) => Promise<void>;
|
||||
}) {
|
||||
const [showSuppression, setShowSuppression] = createSignal(false);
|
||||
const [reason, setReason] = createSignal('');
|
||||
const [durationMs, setDurationMs] = createSignal<number>(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}
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={canSuppress() && !showSuppression()}>
|
||||
@@ -1376,7 +1405,7 @@ function AttentionLifecycleControls(props: {
|
||||
/>
|
||||
<FormSelect
|
||||
id={`attention-suppression-duration-${props.detail.item.id}`}
|
||||
label="Return it to active attention after"
|
||||
label={suppressionEnd().durationLabel}
|
||||
fieldBaseClass="block"
|
||||
labelClass="text-xs"
|
||||
selectClass="mt-1 w-auto"
|
||||
|
||||
@@ -759,6 +759,189 @@ describe('PatrolAttentionWorkbench', () => {
|
||||
).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(
|
||||
|
||||
Reference in New Issue
Block a user