mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-05 23:36:37 +00:00
fix(recovery): preserve route-owned platform filters
This commit is contained in:
parent
823c429c03
commit
0f736ef5eb
5 changed files with 229 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
});
|
||||
|
||||
|
|
|
|||
204
tests/integration/tests/25-truenas-recovery-route-filter.spec.ts
Normal file
204
tests/integration/tests/25-truenas-recovery-route-filter.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue