Point patrol attention lifecycle at the durable threshold and finding controls

Two users independently concluded the permanent 'Remember as expected'
dismissals were removed (discussions #1623, #1699) because the Needs
attention workbench only offers acknowledge and temporary suppression,
while the finding-level Manage menu sits behind a collapsed disclosure
below it.

The attention detail's Lifecycle section now states that its controls
cover only the current occurrence and links the two durable paths: the
alert thresholds page, and the Patrol findings panel via a control that
expands the disclosure and scrolls to it. The disclosure summary also
carries an open-work count badge so it no longer reads as an empty
archive.
This commit is contained in:
rcourtman
2026-08-13 21:53:56 +01:00
parent 929c03b490
commit bcabda085f
5 changed files with 141 additions and 32 deletions
+22 -25
View File
@@ -1,25 +1,27 @@
{
"version": 1,
"base_sha": "3adeb77d60620cdcc4c71431685725384bdc562a",
"verified_at": "2026-08-12T08:19:01Z",
"base_sha": "929c03b4906590fa12b7d7ce1d63e81ab384854b",
"verified_at": "2026-08-13T20:53:32Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/api/settings.ts",
"frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts",
"frontend-modern/src/components/Settings/useSystemSettingsState.ts"
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx",
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx",
"frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx",
"frontend-modern/src/routing/resourceLinks.ts"
],
"content_sha256": {
"frontend-modern/src/api/settings.ts": "cb95289d5df7cd9332cc78573a5c6052f8968cc0fbb2d66c15c8ddc49f44ab62",
"frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts": "528b32af6b1c777ed87f5dd2d5b15b531f830c520b0f624acfc5a2d75ad3e314",
"frontend-modern/src/components/Settings/useSystemSettingsState.ts": "acb66acc4a0254a6816564713f65a1ed5cf723eb526e4960076b4c84335e2c09"
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx": "34d7d90cfb0a0a6405346115393688a8db89be602e4112512a61fce7f17237f2",
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "396357b7930259e4be0947e97264d5311956c118295c10d0daaa2f59be89e44c",
"frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx": "d809cd5ee80973042e8c703e746a392ee0235d3868a0177ff8e874c87d7b811e",
"frontend-modern/src/routing/resourceLinks.ts": "9b44383c2061a6ec4f2e49a124be766a2d7294862dd32f3343bde1199e690030"
},
"routes": [
"/settings/system-general"
"/patrol"
],
"viewports": [
{
"width": 1280,
"height": 720
"width": 1311,
"height": 1234
},
{
"width": 375,
@@ -27,21 +29,16 @@
}
],
"states": [
"Admin session Monitoring Cadence card at the fresh-install Realtime (10s) default",
"Admin card after selecting the Low (60s) preset and saving (GET /api/system/settings confirmed pvePollingInterval 60)",
"Non-admin session (persisted session cookie whose user no longer matches the configured admin) with GET /api/system/settings returning 403 while GET /api/runtime/display returned pvePollingInterval 60: card showed Current cadence: 60 seconds with Low (60s) pressed instead of the old Realtime fallback",
"Admin card after switching to Custom 45s and saving (GET /api/system/settings confirmed 45)",
"Non-admin card after reload showing Current cadence: 45 seconds with Custom pressed and 45 in the custom input",
"Same non-admin Custom 45s state re-exercised at the 375px mobile viewport",
"Admin session reload showing Custom 45s restored through the refactored preset-resolution path"
"Needs attention list with 10 active mock items",
"Attention detail open with Lifecycle controls and the new durable-options guidance",
"Findings disclosure collapsed with '5 open' badge on the summary",
"Findings disclosure expanded showing the Open work findings list",
"Expanded finding Manage menu listing Remember as expected and dismiss actions"
],
"interactions": [
"Built the sidecar backend and ran it as a scratch instance; logged in as admin on the pulse-1601-admin Vite frontend",
"Selected Low (60s) on the Monitoring Cadence card and saved; verified the effective interval via the settings and runtime-display APIs",
"Restarted the backend with a different configured admin username so the persisted browser session became a genuine non-admin session (settings 403, security-status capabilities all false)",
"Reloaded /settings/system-general in that non-admin session and confirmed the cadence card rendered the server's real 60s interval from the runtime-display projection",
"Logged in as the new admin in a separate cookie jar, set a non-preset Custom 45s cadence, and saved",
"Reloaded the non-admin session and confirmed Custom 45s displayed, then repeated the check at the 375px mobile viewport",
"Reloaded the admin session to confirm the shared preset-resolution refactor still renders the saved Custom 45s state"
"Clicked an attention item to open the detail panel (desktop and 375px mobile)",
"Verified 'adjust alert thresholds' renders as a link with href /alerts/thresholds",
"Clicked 'review Patrol findings' on both viewports: disclosure opened and scrolled into view",
"Expanded a finding row and opened its Manage menu to confirm Remember as expected is reachable"
]
}
@@ -1,4 +1,4 @@
import { useLocation } from '@solidjs/router';
import { A, useLocation } from '@solidjs/router';
import {
createEffect,
createMemo,
@@ -46,6 +46,7 @@ import {
import { aiChatStore } from '@/stores/aiChat';
import { patrolAttentionStore } from '@/stores/patrolAttention';
import {
ALERT_THRESHOLDS_PATH,
buildPatrolAttentionPath,
buildStandalonePath,
buildWorkloadsRouteSearch,
@@ -67,7 +68,7 @@ const FILTERS: Array<{ id: AttentionFilter; label: string }> = [
{ id: 'resolved', label: 'Recent resolved' },
];
export function PatrolAttentionWorkbench() {
export function PatrolAttentionWorkbench(props: { onOpenFindings?: () => void } = {}) {
const location = useLocation();
const [selectedItemId, setSelectedItemId] = createSignal('');
const [actionDetail, setActionDetail] = createSignal<ActionDetailResponse | null>(null);
@@ -306,6 +307,7 @@ export function PatrolAttentionWorkbench() {
changeLifecycle(() => suppressPatrolAttention(itemId, reason, expiresAt))
}
onUnsuppress={(itemId) => changeLifecycle(() => unsuppressPatrolAttention(itemId))}
onOpenFindings={props.onOpenFindings}
/>
</div>
</Show>
@@ -466,6 +468,7 @@ function AttentionDetail(props: {
onUnacknowledge: (itemId: string) => Promise<void>;
onSuppress: (itemId: string, reason: string, expiresAt: string) => Promise<void>;
onUnsuppress: (itemId: string) => Promise<void>;
onOpenFindings?: () => void;
}) {
const detail = () => props.detail;
const item = () => detail()?.item;
@@ -597,6 +600,7 @@ function AttentionDetail(props: {
onUnacknowledge={props.onUnacknowledge}
onSuppress={props.onSuppress}
onUnsuppress={props.onUnsuppress}
onOpenFindings={props.onOpenFindings}
/>
<DetailSection title="Affected resource">
@@ -810,6 +814,7 @@ function AttentionLifecycleControls(props: {
onUnacknowledge: (itemId: string) => Promise<void>;
onSuppress: (itemId: string, reason: string, expiresAt: string) => Promise<void>;
onUnsuppress: (itemId: string) => Promise<void>;
onOpenFindings?: () => void;
}) {
const [showSuppression, setShowSuppression] = createSignal(false);
const [reason, setReason] = createSignal('');
@@ -975,6 +980,30 @@ function AttentionLifecycleControls(props: {
{props.error}
</p>
</Show>
<div class="mt-3 border-t border-border-subtle pt-3 text-xs leading-5 text-muted">
<p>These controls cover only this occurrence, and suppression always expires.</p>
<p class="mt-1">
For a permanent change,{' '}
<A
href={ALERT_THRESHOLDS_PATH}
class="font-medium text-blue-700 hover:underline dark:text-blue-300"
>
adjust alert thresholds
</A>{' '}
to change or turn off this alert for the affected resource
<Show when={props.onOpenFindings} fallback=".">
, or{' '}
<button
type="button"
class="font-medium text-blue-700 hover:underline dark:text-blue-300"
onClick={() => props.onOpenFindings?.()}
>
review Patrol findings
</button>{' '}
to mark a finding as expected so Patrol stops raising it.
</Show>
</p>
</div>
</div>
</DetailSection>
);
@@ -1,3 +1,6 @@
import { createSignal, Show } from 'solid-js';
import { MetadataBadge } from '@/components/shared/MetadataBadge';
import { aiIntelligenceStore } from '@/stores/aiIntelligence';
import { usePatrolIntelligenceState } from './usePatrolIntelligenceState';
import { PatrolIntelligenceHeader } from './PatrolIntelligenceHeader';
import { PatrolIntelligenceBanners } from './PatrolIntelligenceBanners';
@@ -6,16 +9,45 @@ import { PatrolAttentionWorkbench } from './PatrolAttentionWorkbench';
export function PatrolIntelligenceSurface() {
const state = usePatrolIntelligenceState();
const [findingsOpen, setFindingsOpen] = createSignal(false);
let findingsSummary: HTMLElement | undefined;
const openWorkCount = () => aiIntelligenceStore.patrolOpenWorkCount;
const openFindings = () => {
setFindingsOpen(true);
queueMicrotask(() => {
findingsSummary?.scrollIntoView?.({ block: 'start' });
findingsSummary?.focus?.({ preventScroll: true });
});
};
return (
<div class="space-y-6">
<PatrolIntelligenceHeader state={state} />
<PatrolIntelligenceBanners state={state} />
<PatrolAttentionWorkbench />
<PatrolAttentionWorkbench onOpenFindings={openFindings} />
<details class="rounded-lg border border-border bg-surface">
<summary class="cursor-pointer px-4 py-3 text-sm font-medium text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 sm:px-5">
Patrol checks, investigations, and run history
<details
class="rounded-lg border border-border bg-surface"
open={findingsOpen()}
onToggle={(event) => setFindingsOpen(event.currentTarget.open)}
>
<summary
ref={findingsSummary}
class="cursor-pointer px-4 py-3 text-sm font-medium text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 sm:px-5"
>
<span class="inline-flex flex-wrap items-center gap-2">
Patrol checks, investigations, and run history
<Show when={openWorkCount() > 0}>
<MetadataBadge
tone="info"
size="xs"
shape="rounded"
aria-label={`${openWorkCount()} open patrol ${openWorkCount() === 1 ? 'finding' : 'findings'}`}
>
{openWorkCount()} open
</MetadataBadge>
</Show>
</span>
</summary>
<div
class={`space-y-4 border-t border-border p-4 sm:p-5 ${!state.patrolEnabledLocal() ? 'opacity-50 pointer-events-none' : ''}`}
@@ -194,7 +194,7 @@ describe('PatrolAttentionWorkbench', () => {
const renderWorkbench = () =>
render(() => (
<Router>
<Route path="/patrol" component={PatrolAttentionWorkbench} />
<Route path="/patrol" component={() => <PatrolAttentionWorkbench />} />
</Router>
));
@@ -471,6 +471,56 @@ describe('PatrolAttentionWorkbench', () => {
expect(screen.getByRole('button', { name: 'Return to active attention' })).toBeInTheDocument();
});
it('points lifecycle users at the durable threshold and finding controls', async () => {
const active = item();
apiMocks.getList.mockResolvedValue(
listResponse([active], summary({ activeCount: 1, openCount: 1, calm: false })),
);
apiMocks.getDetail.mockResolvedValue(detail(active));
const onOpenFindings = vi.fn();
render(() => (
<Router>
<Route path="/patrol" component={() => <PatrolAttentionWorkbench onOpenFindings={onOpenFindings} />} />
</Router>
));
fireEvent.click(
await screen.findByRole('button', {
name: 'Open Disk pressure on Database VM',
}),
);
expect(
await screen.findByText(/These controls cover only this occurrence/i),
).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'adjust alert thresholds' })).toHaveAttribute(
'href',
'/alerts/thresholds',
);
fireEvent.click(screen.getByRole('button', { name: 'review Patrol findings' }));
expect(onOpenFindings).toHaveBeenCalledTimes(1);
});
it('omits the findings pointer when no findings surface is wired', async () => {
const active = item();
apiMocks.getList.mockResolvedValue(
listResponse([active], summary({ activeCount: 1, openCount: 1, calm: false })),
);
apiMocks.getDetail.mockResolvedValue(detail(active));
renderWorkbench();
fireEvent.click(
await screen.findByRole('button', {
name: 'Open Disk pressure on Database VM',
}),
);
expect(
await screen.findByText(/These controls cover only this occurrence/i),
).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'review Patrol findings' })).not.toBeInTheDocument();
});
it('opens the canonical governed action review from an eligible attention item', async () => {
const actionOffer = {
targetResourceId: 'docker:host-1/container-1',
@@ -41,6 +41,7 @@ export const TRUENAS_PATH = '/truenas';
export const TRUENAS_DEFAULT_TAB = 'overview';
export const VMWARE_PATH = '/vmware';
export const VMWARE_DEFAULT_TAB = 'overview';
export const ALERT_THRESHOLDS_PATH = '/alerts/thresholds';
export const PMG_THRESHOLDS_PATH = '/alerts/thresholds/mail-gateway';
export const PATROL_PATH = '/patrol';
export const PATROL_ATTENTION_QUERY_PARAM = 'attention';