Make Patrol attention filters responsive

This commit is contained in:
rcourtman
2026-08-03 19:48:20 +01:00
parent d00249fd7d
commit 32d373395c
6 changed files with 84 additions and 56 deletions
@@ -3691,6 +3691,10 @@ drift back to a single-scope gate: a registered phone carries only
mobile alert sync on upgrade (v6.1.0-rc.4 regression).
Lists use bounded pagination with a maximum of 200 records and one bounded
protection-posture batch. The summary path does not read recovery history.
The Patrol workbench may project the summary counts into its fixed lifecycle
filter labels at either desktop or narrow widths, but changing presentation
does not create a parallel count or filter vocabulary: every selection still
uses the canonical `AttentionFilter` request and the returned list summary.
Lifecycle read failure returns a typed unavailable response and never a
synthetic zero or calm state. Protection lookup failure may return lifecycle
@@ -1111,9 +1111,14 @@ not a replacement status card, CTA band, or page-local nested card.
columns. Compact, stable, mutually-exclusive filters
with two to five options should use `LabeledFilterToggleGroup` as a
responsive control: toggle buttons at wide desktop widths and the native
select fallback below that. Dynamic, user/environment-sized, or six-plus
option filters remain `LabeledFilterSelect` surfaces so estate-sized lists
such as nodes never become button groups. Filters that change which other
select fallback below that. Dynamic and user/environment-sized option
filters remain `LabeledFilterSelect` surfaces so estate-sized lists such as
nodes never become button groups. A fixed six-state operational lifecycle
may use `LabeledFilterToggleGroup` when every choice is a frequent triage
action, the complete segmented group fits at its wide breakpoint, and the
native select fallback owns all narrower layouts; this bounded exception
must not be generalized to dynamic six-plus filters. Filters that change
which other
filters exist, such as Workloads Type, must stay in a stable primary filter
band ahead of the dependent estate/data filters so changing the parent
filter does not move its own click target or the adjacent primary filters;
@@ -954,7 +954,11 @@ The primary Patrol workspace is now
must not derive lifecycle state from legacy Patrol findings. Active, open,
acknowledged, suppressed, stale/unknown, recent-resolved, calm, partial, and
unavailable states all come from the same backend projection. Navigation and
the queue consume the same active summary.
the queue consume the same active summary. Those six fixed lifecycle views are
frequent operator triage choices: wide layouts retain one-click segmented
choices with the backend-projected count in every label, while narrower
layouts use the shared labeled select fallback. The control must not become a
horizontally scrolling rail that leaves a lifecycle view clipped or hidden.
The default view owns only the ordered action queue. Legacy Patrol checks,
investigations, and run history remain available under collapsed supporting
+13 -24
View File
@@ -1,25 +1,16 @@
{
"version": 1,
"base_sha": "caaf5ed232c293331f9c3aec4d19788bf220f18a",
"verified_at": "2026-08-03T18:33:55Z",
"base_sha": "d00249fd7ddcc6cee0f5f35911dd6c0127cf1f33",
"verified_at": "2026-08-03T18:47:21Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/shared/horizontalRailVisibilityModel.ts",
"frontend-modern/src/components/shared/useActiveHorizontalRailItemVisibility.ts",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx",
"frontend-modern/src/pages/Alerts.tsx"
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx"
],
"content_sha256": {
"frontend-modern/src/components/shared/horizontalRailVisibilityModel.ts": "670f68a047a3e9dd8fda676e7a46819d2fd98b99ae19d493161ffb0c626c177d",
"frontend-modern/src/components/shared/useActiveHorizontalRailItemVisibility.ts": "19488bf7edf26d30c966b962254acfe588b26af86cc4d606dc32cf4d95360f54",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "9c6abbb54e00786351d3185e70ecf231df1f4caabf110d3a69010ed0c5584a17",
"frontend-modern/src/pages/Alerts.tsx": "0c411a83c86d2aa448fe21fb01264794832154abc45b456e2a549d336f03d703"
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx": "87e314940a667c8ff49e2aaf63b45f8d3f6278c3c01747fcaf9f850295d3e413"
},
"routes": [
"/alerts/schedule",
"/alerts/overview",
"/alerts/notifications",
"/docker/networks"
"/patrol"
],
"viewports": [
{
@@ -32,17 +23,15 @@
}
],
"states": [
"Phone-width Alerts Schedule route loaded directly with the active destination fully visible",
"Phone-width Alerts Overview selected after starting from the right edge of the rail",
"Phone-width Alerts Notifications selected and fully contained",
"Desktop Alerts Notifications surface with canonical sidebar navigation",
"Phone-width Docker Networks surface after the platform rail adopted the shared visibility owner"
"Desktop Patrol attention workbench with all six counted lifecycle choices visible in the segmented control",
"Desktop Recent resolved selection with the resolved queue item loaded",
"Narrow Patrol attention workbench with the segmented control hidden and the labeled native select visible",
"Narrow Recent resolved selection with the resolved queue item loaded"
],
"interactions": [
"Loaded /alerts/schedule directly at 390 by 844 and confirmed the rail moved from scrollLeft 0 to 55 so Schedule was fully visible",
"Selected Overview and confirmed the active destination changed and the rail returned to scrollLeft 0",
"Selected Notifications and confirmed its active button remained fully inside the rail bounds",
"Resized to 1280 by 720 and confirmed Alerts retained its desktop sidebar navigation with no document overflow",
"Reloaded /docker/networks at 390 by 844 and confirmed the active Networks destination remained fully visible after the shared hook extraction"
"Selected Recent resolved from the desktop segmented control and confirmed its pressed state and resolved queue result",
"Reloaded /patrol at 390 by 844 and confirmed the Attention state select and every counted option remained available without horizontal overflow",
"Selected Recent resolved from the narrow native select and confirmed its selected state and resolved queue result",
"Measured the desktop segmented group inside the viewport and the narrow document at exactly 390 pixels wide with the wide group display set to none"
]
}
@@ -34,6 +34,7 @@ import {
} from '@/api/patrolAttention';
import { ResourceActionsAPI } from '@/api/resourceActions';
import { Button, ButtonLink } from '@/components/shared/Button';
import { LabeledFilterToggleGroup } from '@/components/shared/FilterToolbar';
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
import { MetadataBadge, type MetadataBadgeTone } from '@/components/shared/MetadataBadge';
import { ActionReviewDialog } from '@/features/actions/ActionReviewDialog';
@@ -95,6 +96,15 @@ export function PatrolAttentionWorkbench() {
return undefined;
}
};
const attentionFilterOptions = createMemo(() =>
FILTERS.map((option) => {
const count = filterCount(option.id);
return {
value: option.id,
label: count === undefined ? option.label : `${option.label} ${count}`,
};
}),
);
const loadCurrentFilter = () => patrolAttentionStore.load(patrolAttentionStore.filter());
const scrollDetailIntoView = () => {
@@ -248,34 +258,15 @@ export function PatrolAttentionWorkbench() {
</Button>
</div>
<div
class="mt-4 flex gap-2 overflow-x-auto pb-1"
role="group"
aria-label="Attention filter"
>
<For each={FILTERS}>
{(option) => {
const selected = () => patrolAttentionStore.filter() === option.id;
const count = () => filterCount(option.id);
return (
<button
type="button"
class={`inline-flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${
selected()
? 'border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-200'
: 'border-border bg-surface text-muted hover:bg-surface-hover hover:text-base-content'
}`}
aria-pressed={selected()}
onClick={() => changeFilter(option.id)}
>
{option.label}
<Show when={count() !== undefined}>
<span class="tabular-nums text-[10px]">{count()}</span>
</Show>
</button>
);
}}
</For>
<div class="mt-4">
<LabeledFilterToggleGroup
id="patrol-attention-state"
label="Attention state"
value={patrolAttentionStore.filter()}
onChange={(value) => changeFilter(value as AttentionFilter)}
options={attentionFilterOptions()}
selectClass="min-w-[11rem]"
/>
</div>
</div>
@@ -211,6 +211,41 @@ describe('PatrolAttentionWorkbench', () => {
expect(screen.queryByText(/auto-resolved/i)).not.toBeInTheDocument();
});
it('keeps the fixed attention states available through one responsive shared control', async () => {
const attentionSummary = summary({
activeCount: 1,
openCount: 1,
acknowledgedCount: 2,
suppressedCount: 3,
uncertainCount: 4,
resolvedCount: 5,
calm: false,
});
apiMocks.getList.mockResolvedValue(listResponse([item()], attentionSummary));
renderWorkbench();
const activeButton = await screen.findByRole('button', { name: 'Active 1' });
const stateGroup = screen.getByRole('group', { name: 'Attention state' });
expect(stateGroup).toHaveClass('hidden', 'xl:inline-flex');
expect(activeButton).toHaveAttribute('aria-pressed', 'true');
const stateSelect = screen.getByRole('combobox', { name: 'Attention state' });
expect(stateSelect.parentElement).toHaveClass('xl:hidden');
expect(stateSelect).toHaveValue('active');
expect(within(stateSelect).getByRole('option', { name: 'Recent resolved 5' })).toHaveValue(
'resolved',
);
fireEvent.change(stateSelect, { target: { value: 'resolved' } });
await waitFor(() => expect(apiMocks.getList).toHaveBeenLastCalledWith('resolved'));
expect(stateSelect).toHaveValue('resolved');
expect(screen.getByRole('button', { name: 'Recent resolved 5' })).toHaveAttribute(
'aria-pressed',
'true',
);
});
it('opens deepest typed evidence, protection, and timeline detail from one queue item', async () => {
const active = item();
apiMocks.getList.mockResolvedValue(