From 0f736ef5eb7a02bac6738dc7845f670d9d512cac Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 30 Mar 2026 02:13:55 +0100 Subject: [PATCH] fix(recovery): preserve route-owned platform filters --- .../subsystems/frontend-primitives.md | 8 + .../internal/subsystems/storage-recovery.md | 1 + .../Recovery/__tests__/Recovery.test.tsx | 14 ++ .../recovery/useRecoverySurfaceState.ts | 2 + .../25-truenas-recovery-route-filter.spec.ts | 204 ++++++++++++++++++ 5 files changed, 229 insertions(+) create mode 100644 tests/integration/tests/25-truenas-recovery-route-filter.spec.ts diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 6e0c54488..7cb2da783 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -896,6 +896,14 @@ consume that shared `platform` filter surface directly. They must not keep recovery-local `provider` route/query vocabulary alive behind renamed labels, or the UI will drift back to backend-shaped navigation even when the copy says `Platform`. +That same shared recovery filter owner must also preserve route-owned platform +visibility while transport-backed options are still hydrating. If +`frontend-modern/src/features/recovery/useRecoverySurfaceState.ts` restores a +canonical `platform` selection such as `truenas` from the route before the +rollups, points, or facets payloads arrive, it must keep that selected +platform present in the option set so the shared `LabeledFilterSelect` shows +the owned value immediately instead of flashing back to `All Platforms` until +recovery data warms. `frontend-modern/src/utils/problemResourcePresentation.ts` now also belongs to that same dashboard overview boundary so the problem-resource severity contract stays shared with `ProblemResourcesTable.tsx` instead of floating as an diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 7e0b627f7..98be39d41 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -100,6 +100,7 @@ querying, and the operator-facing storage health presentation layer. 9. Letting whitespace-padded recovery timeline params fall off canonical route state; shared recovery URLs must trim and normalize `day`, `range`, `scope`, `status`, `verification`, `cluster`, `node`, `namespace`, `itemType`, and adjacent history filters before the page model validates them so pasted or hand-edited links resolve to the same canonical timeline and filter state as UI-authored routes 10. Letting explicit recovery `all` sentinels survive in canonical route state; shared recovery URLs must collapse case- or whitespace-variant `all` values for `cluster`, `node`, `namespace`, and `itemType` back to the canonical unset route state so copied links do not preserve fake active filters 11. Letting non-canonical recovery platform values survive in route or transport state; shared recovery URLs must collapse unsupported or fake `platform` values back to the canonical unset state, and only owned source-platform options or canonical legacy aliases may reach rollups, points, series, and facets transport filters +11c. Letting route-owned recovery platform selections disappear while filter options are still hydrating; the recovery page state owner must keep the current canonical `platform` query value present in the platform option set until transport-backed facets and records arrive so shared filter selects keep the user-visible TrueNAS or other owned platform selection instead of flashing back to `All Platforms` 11a. Letting adjacent workload-route changes in shared `frontend-modern/src/routing/resourceLinks.ts` perturb recovery parse/build semantics; expanding canonical `/workloads` platform scoping must not alter the owned `/recovery` `platform` and `itemType` vocabulary, legacy alias rewrites, or recovery drill-down workspace selection 11b. Letting adjacent storage-link additions in shared `frontend-modern/src/routing/resourceLinks.ts` perturb recovery route semantics; expanding canonical `/storage` deep links for unified resources must not reuse recovery-owned query names or alter the owned `/recovery` parse/build contract while those surfaces continue sharing the same route-helper module 12. Letting protected-item recovery outcome filtering fork from the canonical history status filter; the protected inventory status control must drive the same route-backed `status` field and the same rollups, points, series, and facets transport filters as the history surface instead of keeping a protected-only local outcome branch diff --git a/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx b/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx index 1777e9a96..4c0882c3e 100644 --- a/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx +++ b/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx @@ -806,6 +806,20 @@ describe('Recovery', () => { }); }); + it('keeps route-owned recovery platform and node filters visible while options hydrate', async () => { + mockLocationSearch = '?view=events&platform=truenas&node=tower'; + apiFetchMock.mockImplementation(() => new Promise(() => {})); + + render(() => ); + + await waitFor(() => expect(screen.getByLabelText('Platform')).toBeInTheDocument()); + + expect(screen.getByLabelText('Platform')).toHaveValue('truenas'); + expect(screen.getByRole('option', { name: 'TrueNAS' }).selected).toBe(true); + expect(screen.getByText('Host / Agent')).toBeInTheDocument(); + expect(screen.getByText('tower')).toBeInTheDocument(); + }); + it('uses the shared reset action for protected item filters', async () => { render(() => ); diff --git a/frontend-modern/src/features/recovery/useRecoverySurfaceState.ts b/frontend-modern/src/features/recovery/useRecoverySurfaceState.ts index 0bf3f700d..512fbf12b 100644 --- a/frontend-modern/src/features/recovery/useRecoverySurfaceState.ts +++ b/frontend-modern/src/features/recovery/useRecoverySurfaceState.ts @@ -342,6 +342,8 @@ export function useRecoverySurfaceState() { const normalized = normalizeSourcePlatformQueryValue(getRecoveryPointPlatform(point)); if (normalized) platforms.add(normalized); } + const selected = normalizeRecoveryPlatformSelection(platformFilter()); + if (selected !== 'all') platforms.add(selected); return ['all', ...buildSourcePlatformOptions(platforms).map((option) => option.key)]; }); diff --git a/tests/integration/tests/25-truenas-recovery-route-filter.spec.ts b/tests/integration/tests/25-truenas-recovery-route-filter.spec.ts new file mode 100644 index 000000000..4f8f233fc --- /dev/null +++ b/tests/integration/tests/25-truenas-recovery-route-filter.spec.ts @@ -0,0 +1,204 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { expect, test as base } from '@playwright/test'; + +import { createAuthenticatedStorageState } from './helpers'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SCREENSHOT_PATH = '/tmp/truenas-recovery-route-filter.png'; + +type WorkerFixtures = { + authStorageStatePath: string; +}; + +const test = base.extend<{}, WorkerFixtures>({ + storageState: async ({ authStorageStatePath }, use) => { + await use(authStorageStatePath); + }, + authStorageStatePath: [async ({ browser }, use, workerInfo) => { + const storageStatePath = path.resolve( + __dirname, + '..', + '..', + 'tmp', + 'playwright-auth', + `truenas-recovery-route-filter-${workerInfo.project.name}.json`, + ); + fs.mkdirSync(path.dirname(storageStatePath), { recursive: true }); + await createAuthenticatedStorageState(browser, storageStatePath); + try { + await use(storageStatePath); + } finally { + fs.rmSync(storageStatePath, { force: true }); + } + }, { scope: 'worker' }], +}); + +test.describe('TrueNAS recovery route filters', () => { + test.setTimeout(180_000); + + test('keeps route-owned platform and node filters visible while recovery data warms', async ({ + page, + }) => { + const recoveryRequests: string[] = []; + let releaseRecoveryResponses: (() => void) | null = null; + const recoveryResponseGate = new Promise((resolve) => { + releaseRecoveryResponses = resolve; + }); + + await page.route('**/api/resources**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: [ + { + id: 'truenas-main', + type: 'truenas', + name: 'tower', + displayName: 'TrueNAS Tower', + platformId: 'truenas-main', + platformType: 'truenas', + sourceType: 'api', + status: 'online', + lastSeen: '2026-03-29T22:00:00Z', + platformData: { + sources: ['truenas'], + }, + }, + ], + meta: { page: 1, limit: 200, total: 1, totalPages: 1 }, + }), + }); + }); + + await page.route('**/api/recovery/rollups*', async (route) => { + recoveryRequests.push(route.request().url()); + await recoveryResponseGate; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: [ + { + rollupId: 'ext:truenas-1', + itemRef: { type: 'truenas-dataset', name: 'tank/apps', id: 'tank/apps' }, + display: { itemType: 'dataset', subjectLabel: 'tank/apps', nodeHostLabel: 'tower' }, + lastAttemptAt: '2026-03-29T09:00:00.000Z', + lastSuccessAt: '2026-03-29T09:00:00.000Z', + lastOutcome: 'success', + platforms: ['truenas'], + }, + ], + meta: { page: 1, limit: 500, total: 1, totalPages: 1 }, + }), + }); + }); + + await page.route('**/api/recovery/points*', async (route) => { + recoveryRequests.push(route.request().url()); + await recoveryResponseGate; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: [ + { + id: 'truenas-point-1', + platform: 'truenas', + kind: 'snapshot', + mode: 'snapshot', + outcome: 'success', + completedAt: '2026-03-29T09:00:00.000Z', + node: 'tower', + itemRef: { + type: 'truenas-dataset', + name: 'tank/apps', + id: 'tank/apps', + }, + display: { + itemType: 'dataset', + subjectType: 'truenas-dataset', + subjectLabel: 'tank/apps', + nodeHostLabel: 'tower', + }, + }, + ], + meta: { page: 1, limit: 200, total: 1, totalPages: 1 }, + }), + }); + }); + + await page.route('**/api/recovery/facets*', async (route) => { + recoveryRequests.push(route.request().url()); + await recoveryResponseGate; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { + clusters: [], + nodesAgents: ['tower'], + namespaces: [], + itemTypes: ['dataset'], + hasSize: false, + hasVerification: false, + hasEntityId: false, + }, + }), + }); + }); + + await page.route('**/api/recovery/series*', async (route) => { + recoveryRequests.push(route.request().url()); + await recoveryResponseGate; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: [{ day: '2026-03-29', total: 1, snapshot: 1, local: 0, remote: 0 }], + }), + }); + }); + + await page.goto('/recovery?view=events&platform=truenas&node=tower', { + waitUntil: 'domcontentloaded', + }); + + await expect(page).toHaveURL(/\/recovery\?view=events&platform=truenas&node=tower/); + await expect.poll(() => recoveryRequests.length).toBeGreaterThan(0); + + releaseRecoveryResponses?.(); + + await expect(page.getByTestId('recovery-page')).toBeVisible(); + await expect(page.getByLabel('Platform')).toHaveValue('truenas'); + await expect(page.getByText('Host / Agent')).toBeVisible(); + await expect(page.getByText('tower')).toBeVisible(); + await expect(page.getByText('tank/apps')).toBeVisible(); + + expect( + recoveryRequests.some( + (url) => url.includes('/api/recovery/rollups') && url.includes('platform=truenas'), + ), + ).toBe(true); + expect( + recoveryRequests.some( + (url) => + url.includes('/api/recovery/points') && + url.includes('platform=truenas') && + url.includes('node=tower'), + ), + ).toBe(true); + expect( + recoveryRequests.some( + (url) => + url.includes('/api/recovery/facets') && + url.includes('platform=truenas') && + url.includes('node=tower'), + ), + ).toBe(true); + + await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }); + }); +});