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 });
+ });
+});