diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 9c88477f9..02b6cac06 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -1223,6 +1223,14 @@ Future alert-history control-flow work should extend the feature hook, new grouping or trend semantics should extend the history model, and render-heavy history surfaces should extend those section owners instead of putting fetch, resource-incident state, or table rendering back into the shell. +Alert History toolbar state is one route-owned unit. Search, period, and +severity are read and written by `useAlertHistoryState.ts`; its active-filter +count includes search, and `AlertHistoryFiltersCard.tsx` delegates contextual +Clear filters to one composite hook mutation instead of letting `FilterBar` +issue sequential route writes. That reset removes all three query parameters +in one navigation and clears any transient chart-bucket selection, so a +search-only result set remains visibly resettable and an older URL write +cannot resurrect another filter. That same history surface now also owns the canonical resource-incident handoff. `frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx` must treat the selected incident resource as a unified-resource consumer, diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 9c3b8f042..7abac7612 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -3926,6 +3926,12 @@ Its feature owner may clear incompatible hidden facets when the workspace changes and may preserve that workspace during a composite reset, but the shared `FilterBar` remains the sole owner of Saved, Clear, filter-menu, and popover chrome across desktop and narrow layouts. +Alert History follows the same rule for its route-owned search, period, and +severity state. Its feature hook owns one composite reset and exposes a +search-aware active-state accessor to `FilterBar`; the shared shell owns where +the contextual Clear filters action appears, while the alerts feature must not +fall back to sequential per-control URL writes or hide that action for a +search-only result set. Because that popover combines view application, default selection, removal, and an inline naming form, it is a labelled non-modal dialog rather than an ARIA menu. Its trigger exposes the dialog relationship, Escape returns focus diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 0af7f58e9..bb56b0583 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,17 +1,17 @@ { "version": 1, - "base_sha": "59eb6dd68f3f1f7ac58639c2b82099eb076c14bc", - "verified_at": "2026-08-03T10:53:05Z", + "base_sha": "2402858e3c80b29d898e551c82969566c6a649b8", + "verified_at": "2026-08-03T11:15:05Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx", - "frontend-modern/src/routing/resourceLinks.ts" + "frontend-modern/src/features/alerts/AlertHistoryFiltersCard.tsx", + "frontend-modern/src/features/alerts/useAlertHistoryState.ts" ], "content_sha256": { - "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "6c838d09098ea243c8368753ef0f00c80cf11b59c7cbcd5f62750bba75fbafb2", - "frontend-modern/src/routing/resourceLinks.ts": "c57c920355766f401923340b746614e02b6e6a40fbd871bfd3be47611d952e93" + "frontend-modern/src/features/alerts/AlertHistoryFiltersCard.tsx": "768d706dad29c92c8a205059e48f16bd4fc8f652ad093501759f7f50c72d849c", + "frontend-modern/src/features/alerts/useAlertHistoryState.ts": "e190111f1b89a806b7bd07cf2b10a5ba4f8e5d204c0cebea871964252c5ccebd" }, - "routes": ["/proxmox/backups"], + "routes": ["/alerts/history"], "viewports": [ { "width": 1280, @@ -23,21 +23,20 @@ } ], "states": [ - "Desktop Proxmox Backups default Coverage workspace with Posture filtering", - "Desktop By date workspace with URL-backed search and PBS snapshots source filtering", - "Desktop Saved views empty, named-view, restored-view, and removed-view states", - "Phone-width filter shell collapsed and expanded in Coverage and By date workspaces", + "Desktop default, search-only, combined period/severity/search, and chart-bucket states", + "Desktop Saved views empty, named-view, restored-view, removed-view, and deep-linked states", + "Phone-width filter shell collapsed and expanded with search-only and combined filter state", "Phone-width Saved views dialog bounded within the viewport with active filter state" ], "interactions": [ - "Switched from Coverage to By date and confirmed the incompatible posture parameter was removed in the same navigation", - "Entered backup-vault search text, selected PBS snapshots, and confirmed the route became ?view=date&q=backup-vault&source=pbs", - "Saved the filtered slice as Backup URL audit, cleared it, reapplied it, and confirmed the exact query string and visible controls were restored", - "Reloaded the explicit deep link and confirmed search and source state remained selected", - "Cleared the combined filter state and confirmed one navigation retained ?view=date while removing every narrowing parameter", - "Removed the temporary saved view and confirmed the Saved count returned to zero", - "Expanded the phone-width filter shell, selected Unknown posture, switched to By date, and confirmed hidden posture state did not survive", - "Opened and dismissed the phone-width Saved dialog with Escape and confirmed it stayed viewport-bounded without document overflow", - "Read both browser consoles after the final responsive pass and confirmed no warnings or errors" + "Entered search text alone and confirmed Clear filters appeared, then cleared the URL and visible search state in one action", + "Selected Last 30d and Critical with search text, confirmed all three query parameters, and cleared them atomically back to defaults", + "Saved the combined slice as Alert reset audit, cleared it, reapplied it, and confirmed the exact query string and visible controls were restored", + "Reloaded the explicit deep link and confirmed search, period, and severity remained selected", + "Removed the temporary saved view and confirmed it no longer appeared", + "Selected an alert-frequency bucket, added a toolbar filter, and confirmed Clear filters removed both the route-owned filters and transient chart selection", + "Expanded the phone-width filter shell and confirmed Saved and Clear filters remained aligned without overflow", + "Opened and dismissed the phone-width Saved dialog with Escape and confirmed it stayed viewport-bounded", + "Read the browser console after the final responsive pass and confirmed no warnings or errors" ] } diff --git a/frontend-modern/src/features/alerts/AlertHistoryFiltersCard.tsx b/frontend-modern/src/features/alerts/AlertHistoryFiltersCard.tsx index 7d2778660..436886efb 100644 --- a/frontend-modern/src/features/alerts/AlertHistoryFiltersCard.tsx +++ b/frontend-modern/src/features/alerts/AlertHistoryFiltersCard.tsx @@ -69,6 +69,8 @@ export function AlertHistoryFiltersCard(props: AlertHistoryFiltersCardProps) { clearOnEscape: true, }} filters={buildFilters()} + onClearAll={props.state.clearFilters} + showClearAll={() => props.state.activeFilterCount() > 0} /> ); } diff --git a/frontend-modern/src/features/alerts/__tests__/useAlertHistoryState.test.tsx b/frontend-modern/src/features/alerts/__tests__/useAlertHistoryState.test.tsx index 9402c28db..060d854a2 100644 --- a/frontend-modern/src/features/alerts/__tests__/useAlertHistoryState.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/useAlertHistoryState.test.tsx @@ -146,4 +146,34 @@ describe('useAlertHistoryState', () => { expect(AlertsAPI.clearHistory).toHaveBeenCalledTimes(1); expect(result.alertHistory()).toEqual([]); }); + + it('clears search, period, and severity in one route write', async () => { + const [activeAlerts] = createSignal({}); + vi.mocked(AlertsAPI.getHistory).mockResolvedValue([] as any); + setMockLocation('?q=backup+failed&period=30d&severity=critical'); + + const { result } = renderHook(() => + useAlertHistoryState({ + activeAlerts, + getResource: () => undefined, + allResources: () => [], + }), + ); + + await waitFor(() => expect(AlertsAPI.getHistory).toHaveBeenCalledTimes(1)); + expect(result.activeFilterCount()).toBe(3); + expect(result.searchTerm()).toBe('backup failed'); + expect(result.timeFilter()).toBe('30d'); + expect(result.severityFilter()).toBe('critical'); + + navigateSpy.mockClear(); + result.clearFilters(); + + expect(navigateSpy).toHaveBeenCalledTimes(1); + expect(navigateSpy).toHaveBeenCalledWith('/alerts/history', { replace: true }); + expect(result.activeFilterCount()).toBe(0); + expect(result.searchTerm()).toBe(''); + expect(result.timeFilter()).toBe('7d'); + expect(result.severityFilter()).toBe('all'); + }); }); diff --git a/frontend-modern/src/features/alerts/useAlertHistoryState.ts b/frontend-modern/src/features/alerts/useAlertHistoryState.ts index 6fef13e59..2bddf9854 100644 --- a/frontend-modern/src/features/alerts/useAlertHistoryState.ts +++ b/frontend-modern/src/features/alerts/useAlertHistoryState.ts @@ -42,6 +42,11 @@ export interface UseAlertHistoryStateProps { const DEFAULT_TIME_FILTER: AlertHistoryRange = '7d'; const DEFAULT_SEVERITY_FILTER: AlertSeverityFilter = 'all'; +const ALERT_HISTORY_QUERY_PARAMS = { + query: 'q', + period: 'period', + severity: 'severity', +} as const; const parsePeriod = (raw: string | null | undefined): AlertHistoryRange => raw === '24h' || raw === '7d' || raw === '30d' || raw === 'all' ? raw : DEFAULT_TIME_FILTER; @@ -54,10 +59,11 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) { const navigate = useNavigate(); const timeFilter: Accessor = () => - parsePeriod(new URLSearchParams(location.search).get('period')); + parsePeriod(new URLSearchParams(location.search).get(ALERT_HISTORY_QUERY_PARAMS.period)); const severityFilter: Accessor = () => - parseSeverity(new URLSearchParams(location.search).get('severity')); - const searchTerm: Accessor = () => new URLSearchParams(location.search).get('q') ?? ''; + parseSeverity(new URLSearchParams(location.search).get(ALERT_HISTORY_QUERY_PARAMS.severity)); + const searchTerm: Accessor = () => + new URLSearchParams(location.search).get(ALERT_HISTORY_QUERY_PARAMS.query) ?? ''; const updateSearchParam = (mutate: (params: URLSearchParams) => void): void => { const params = new URLSearchParams(location.search); @@ -69,9 +75,9 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) { const setTimeFilter = (value: AlertHistoryRange): void => { updateSearchParam((params) => { if (value === DEFAULT_TIME_FILTER) { - params.delete('period'); + params.delete(ALERT_HISTORY_QUERY_PARAMS.period); } else { - params.set('period', value); + params.set(ALERT_HISTORY_QUERY_PARAMS.period, value); } }); }; @@ -79,9 +85,9 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) { const setSeverityFilter = (value: AlertSeverityFilter): void => { updateSearchParam((params) => { if (value === DEFAULT_SEVERITY_FILTER) { - params.delete('severity'); + params.delete(ALERT_HISTORY_QUERY_PARAMS.severity); } else { - params.set('severity', value); + params.set(ALERT_HISTORY_QUERY_PARAMS.severity, value); } }); }; @@ -89,9 +95,9 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) { const setSearchTerm = (value: string): void => { updateSearchParam((params) => { if (value === '') { - params.delete('q'); + params.delete(ALERT_HISTORY_QUERY_PARAMS.query); } else { - params.set('q', value); + params.set(ALERT_HISTORY_QUERY_PARAMS.query, value); } }); }; @@ -119,11 +125,21 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) { const activeFilterCount = createMemo(() => { let count = 0; + if (searchTerm().trim()) count++; if (timeFilter() !== '7d') count++; if (severityFilter() !== 'all') count++; return count; }); + const clearFilters = (): void => { + updateSearchParam((params) => { + params.delete(ALERT_HISTORY_QUERY_PARAMS.query); + params.delete(ALERT_HISTORY_QUERY_PARAMS.period); + params.delete(ALERT_HISTORY_QUERY_PARAMS.severity); + }); + setSelectedBarIndex(null); + }; + const userLocale = Intl.DateTimeFormat().resolvedOptions().locale || (typeof navigator !== 'undefined' ? navigator.language : undefined) || @@ -176,20 +192,20 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) { const params = new URLSearchParams(window.location.search); let mutated = false; - if (!params.has('period')) { + if (!params.has(ALERT_HISTORY_QUERY_PARAMS.period)) { const legacy = window.localStorage.getItem('alertHistoryTimeFilter'); const parsed = parsePeriod(legacy); if (parsed !== DEFAULT_TIME_FILTER && legacy === parsed) { - params.set('period', parsed); + params.set(ALERT_HISTORY_QUERY_PARAMS.period, parsed); mutated = true; } } - if (!params.has('severity')) { + if (!params.has(ALERT_HISTORY_QUERY_PARAMS.severity)) { const legacy = window.localStorage.getItem('alertHistorySeverityFilter'); const parsed = parseSeverity(legacy); if (parsed !== DEFAULT_SEVERITY_FILTER && legacy === parsed) { - params.set('severity', parsed); + params.set(ALERT_HISTORY_QUERY_PARAMS.severity, parsed); mutated = true; } } @@ -303,6 +319,7 @@ export function useAlertHistoryState(props: UseAlertHistoryStateProps) { resourceIncidentEventFilters: resourceIncidentsState.resourceIncidentEventFilters, setResourceIncidentEventFilters: resourceIncidentsState.setResourceIncidentEventFilters, activeFilterCount, + clearFilters, incidentTimelines, incidentLoading, incidentErrors, diff --git a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts index 39ec5b80d..7f356271b 100644 --- a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts +++ b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts @@ -466,6 +466,10 @@ describe('tab path helpers', () => { expect(alertHistoryFiltersCardSource).toContain('export function AlertHistoryFiltersCard'); expect(alertHistoryFiltersCardSource).toContain('getAlertHistorySearchPlaceholder'); expect(alertHistoryFiltersCardSource).toContain('filterChipStatusDot'); + expect(alertHistoryFiltersCardSource).toContain('onClearAll={props.state.clearFilters}'); + expect(alertHistoryFiltersCardSource).toContain( + 'showClearAll={() => props.state.activeFilterCount() > 0}', + ); expect(alertHistoryFiltersCardSource).not.toContain('severityDot'); expect(alertHistoryFiltersCardSource).not.toContain('h-2 w-2 rounded-full ${className}'); expect(alertResourceIncidentsPanelSource).toContain( @@ -526,6 +530,7 @@ describe('tab path helpers', () => { ); expect(alertHistoryStateSource).toContain('export function useAlertHistoryState'); expect(alertHistoryStateSource).toContain('export type AlertHistoryState'); + expect(alertHistoryStateSource).toContain('const clearFilters = (): void =>'); expect(alertHistoryStateSource).toContain('AlertsAPI.getHistory'); expect(alertHistoryStateSource).toContain('AlertsAPI.clearHistory'); expect(alertHistoryStateSource).toContain('useAlertResourceIncidentsState');