fix(recovery): preserve route-owned platform filters

This commit is contained in:
rcourtman 2026-03-30 02:13:55 +01:00
parent 823c429c03
commit 0f736ef5eb
5 changed files with 229 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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(() => <Recovery />);
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(() => <Recovery />);

View file

@ -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)];
});

View file

@ -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<void>((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 });
});
});