mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Make Patrol attention filters responsive
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user