refactor(recovery): canonicalize platform filter boundary

This commit is contained in:
rcourtman
2026-03-26 09:36:32 +00:00
parent 37e2322c0e
commit 2ca885b0d0
25 changed files with 250 additions and 102 deletions
@@ -698,6 +698,11 @@ those source-specific labels onto the governed recovery item vocabulary before
the UI route/filter state sees them, so lifecycle-adjacent drill-downs remain
coherent across platforms instead of reintroducing Proxmox-native subject
types as the de facto recovery model.
That same shared recovery boundary now also treats `platform` as the canonical
operator-facing filter query for lifecycle-adjacent drill-down links. Any
legacy `provider` alias support must remain compatibility-only input behind
the shared API/router layer rather than becoming the route shape lifecycle
surfaces copy back out to operators.
The updater/runtime surfaces must preserve the one-shot `updated_from`
continuity handoff and the non-TLS continuity path for supported self-hosted
@@ -198,6 +198,7 @@ Own canonical runtime payload shapes between backend and frontend.
11. Treat Patrol findings counts as a singular supporting surface as well: when the summary shell already exposes count cards for active findings, warnings, criticals, and fixes, the primary assessment card must not repeat those same payload-derived counts as secondary badges
12. Treat Patrol schedule and recency as header-owned metadata on the main Patrol page: findings empty-state consumers should not receive or restate `next_patrol_at`, `last_patrol_at`, or interval timing once those transport fields are already presented by the primary header and verification shell
13. Keep recovery payload filters canonical across `/api/recovery/rollups`, `/api/recovery/points`, `/api/recovery/series`, and `/api/recovery/facets`: when `internal/api/recovery_handlers.go` adds a governed recovery filter or display field such as provider-neutral `itemType`, the same normalized transport must land across all four endpoints and the contract tests must pin both outbound payload shape and accepted query aliases in the same slice
14. Keep recovery platform-query vocabulary canonical across that same `/api/recovery/*` surface: operator-facing transport must emit `platform` as the canonical query field, accepted legacy `provider` aliases must remain compatibility-only input, and `internal/api/contract_test.go` must pin that fallback behavior in the same slice as any handler change
## Current State
@@ -1361,6 +1362,9 @@ operator-facing query field across `/api/recovery/rollups`, `/api/recovery/point
mapping that boundary onto internal provider fields, but accepted legacy
`provider` aliases must be compatibility-only input and must not replace the
canonical transport query shape.
`internal/api/contract_test.go` must pin that alias behavior directly, so the
canonical `platform` query and the legacy `provider` fallback cannot drift
between recovery endpoints without tripping the shared API proof surface.
`internal/api/contract_test.go` is the canonical proof owner for that
boundary, so route and query compatibility like `itemType` and accepted alias
inputs such as `type` or legacy `provider` must be pinned there whenever the shared recovery
@@ -685,6 +685,11 @@ as page-local toolbar state inside individual recovery sections.
That same shared recovery state owner now also keeps `platform` as the
canonical route and transport filter name for operator-facing recovery links,
while any accepted legacy `provider` aliases remain parser compatibility only.
Recovery section owners under `frontend-modern/src/components/Recovery/` must
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`.
`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
@@ -90,7 +90,7 @@ querying, and the operator-facing storage health presentation layer.
8. Letting explicit storage `all` sentinels survive in canonical route state; shared storage URLs must collapse case- or whitespace-variant `all` values for the managed `node` filter back to the canonical unset state so copied links do not preserve a fake active node filter
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 provider values survive in route or transport state; shared recovery URLs must collapse unsupported or fake `provider` values back to the canonical unset state, and only owned source-platform provider options or canonical aliases may reach rollups, points, series, and facets transport 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
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
13. Letting visible protected-item filters fall out of shared recovery links; the protected `Stale only` toggle must restore from the canonical recovery URL and rewrite to one owned `stale=1` route form instead of disappearing on refresh or copy/paste
14. Reintroducing stacked full-width recovery tables as the primary desktop layout; the governed recovery surface must expose one primary data region at a time with explicit protected-items versus recovery-events view switching so Pulse stays inventory-first for Proxmox operators without collapsing the page back into a single-platform backup screen
@@ -158,7 +158,10 @@ That same operator-facing vocabulary should also prefer `item` over backend
primary recovery headers, tables, focus chips, and detail metadata labels.
The data model can keep its internal subject/provider fields, but the page
frame that operators read should present one consistent protected-item and
platform model from summary through drill-in.
platform model from summary through drill-in. Shared recovery URLs and
transport filters should likewise treat `platform` as the canonical
operator-facing query field, with legacy `provider` aliases accepted only as
compatibility input that rewrites back to canonical `platform` route state.
That same shared presentation layer also owns the distinction between
aggregate recovery-method language and single-record recovery-method language.
Timeline legends and daily breakdowns must use aggregate labels such as
@@ -479,7 +482,7 @@ coverage only through pages or higher-level recovery components.
Those recovery transport surfaces now also share one normalized filter
contract: protected-item rollups, point history, facets, and chart series must
all honor the same provider, canonical `itemType`, cluster, node, namespace,
all honor the same canonical `platform`, canonical `itemType`, cluster, node, namespace,
workload-scope, verification, and route-backed free-text `q` filter so the
protected-items list cannot drift from the timeline and facet state under the
same active recovery view. That same recovery filter contract now depends on
@@ -958,6 +958,11 @@ as `vm`, `dataset`, or `pvc`, and
aliases like `proxmox-vm` into that shared vocabulary during parse/build, but
recovery route state must not drift back to raw platform-specific
`subjectType` values in shared navigation.
That same route contract also owns the canonical recovery `platform` query.
`/recovery` links must emit `platform=<owned-source-key>` as the shared
operator-facing route shape, while accepted legacy `provider` aliases may be
parsed only as compatibility input that rewrites back to canonical platform
route state.
Shared API consumers now also depend on a single registry-list snapshot per
request when deriving canonical type aggregations for resource list and stats
responses. Re-reading `registry.List()` for the same `/api/resources` request
@@ -105,13 +105,14 @@ describe('ResourceDetailDrawer service cards', () => {
expect(queryByText('Job breakdown')).toBeNull();
expect(queryByText('Types')).toBeNull();
expect(queryByText('Show job detail')).toBeNull();
fireEvent.click(getByRole('button', { name: 'Show access' }));
fireEvent.click(getByRole('button', { name: 'Show jobs' }));
expect(getByText('Datastores')).toBeInTheDocument();
expect(getByText('Jobs')).toBeInTheDocument();
expect(getByText('Types')).toBeInTheDocument();
expect(getByRole('link', { name: /open pbs backups/i })).toHaveAttribute(
'href',
'/recovery?provider=proxmox-pbs&mode=remote',
'/recovery?platform=proxmox-pbs&mode=remote',
);
});
@@ -169,6 +170,7 @@ describe('ResourceDetailDrawer service cards', () => {
expect(pmgSupportContext.getByText('Updated')).toBeInTheDocument();
expect(getByText('Queue detail').closest('summary')?.textContent).toBe('Queue detail');
expect(getByText('Mail detail').closest('summary')?.textContent).toBe('Mail detail');
fireEvent.click(getByRole('button', { name: 'Show access' }));
expect(getByRole('link', { name: /open pmg thresholds/i })).toHaveAttribute(
'href',
'/alerts/thresholds/mail-gateway',
@@ -250,7 +250,7 @@ describe('UnifiedResourceTable workloads links', () => {
const pbsLink = getByRole('link', { name: /open pbs backups/i });
expect(pbsLink).toHaveTextContent('Recovery');
expect(pbsLink).toHaveAttribute('href', '/recovery?provider=proxmox-pbs&mode=remote');
expect(pbsLink).toHaveAttribute('href', '/recovery?platform=proxmox-pbs&mode=remote');
const pmgLink = getByRole('link', { name: /open pmg thresholds/i });
expect(pmgLink).toHaveTextContent('Thresholds');
expect(pmgLink).toHaveAttribute('href', '/alerts/thresholds/mail-gateway');
@@ -31,7 +31,7 @@ describe('buildServiceDetailLinks', () => {
expect(links).toEqual([
{
href: '/recovery?provider=proxmox-pbs&mode=remote',
href: '/recovery?platform=proxmox-pbs&mode=remote',
label: 'Open in Recovery',
compactLabel: 'Recovery',
ariaLabel: 'Open PBS backups in Recovery for PBS Main',
@@ -15,7 +15,7 @@ export const buildServiceDetailLinks = (resource: Resource): ServiceDetailLink[]
if (resource.type === 'pbs') {
return [
{
href: buildRecoveryPath({ provider: 'proxmox-pbs', mode: 'remote' }),
href: buildRecoveryPath({ platform: 'proxmox-pbs', mode: 'remote' }),
label: 'Open in Recovery',
compactLabel: 'Recovery',
ariaLabel: `Open PBS backups in Recovery for ${label}`,
@@ -65,8 +65,8 @@ const Recovery: Component = () => {
nodeFilter,
nodeOptions,
protectedStaleOnly,
providerFilter,
providerOptions,
platformFilter,
platformOptions,
queryFilter,
recoveryPoints,
recoveryRollups,
@@ -85,7 +85,7 @@ const Recovery: Component = () => {
setNamespaceFilter,
setNodeFilter,
setProtectedStaleOnly,
setProviderFilter,
setPlatformFilter,
setQueryFilter,
setRollupId,
setScopeFilter,
@@ -104,7 +104,7 @@ const Recovery: Component = () => {
const baseRollups = createMemo<ProtectionRollup[]>(() => {
const query = queryFilter().trim().toLowerCase();
const provider = providerFilter() === 'all' ? '' : providerFilter();
const platform = platformFilter() === 'all' ? '' : platformFilter();
const itemType = itemTypeFilter() === 'all' ? '' : itemTypeFilter();
const resourceIndex = resourcesById();
@@ -112,7 +112,7 @@ const Recovery: Component = () => {
const providers = (rollup.providers || [])
.map((entry) => String(entry || '').trim())
.filter(Boolean);
if (provider && !providers.includes(provider)) return false;
if (platform && !providers.includes(platform)) return false;
const rollupItemType = normalizeRecoveryItemTypeQueryValue(
rollup.display?.itemType || rollup.display?.subjectType || rollup.subjectRef?.type,
);
@@ -408,7 +408,7 @@ const Recovery: Component = () => {
const hasActiveArtifactFilters = createMemo(
() =>
queryFilter().trim() !== '' ||
providerFilter() !== 'all' ||
platformFilter() !== 'all' ||
itemTypeFilter() !== 'all' ||
clusterFilter() !== 'all' ||
modeFilter() !== 'all' ||
@@ -444,7 +444,7 @@ const Recovery: Component = () => {
const resetAllArtifactFilters = () => {
setQueryFilter('');
setProviderFilter('all');
setPlatformFilter('all');
setItemTypeFilter('all');
setClusterFilter('all');
setModeFilter('all');
@@ -578,8 +578,8 @@ const Recovery: Component = () => {
protectedStaleOnly={protectedStaleOnly}
itemTypeFilter={itemTypeFilter}
itemTypeOptions={itemTypeOptions}
providerFilter={providerFilter}
providerOptions={providerOptions}
platformFilter={platformFilter}
platformOptions={platformOptions}
queryFilter={queryFilter}
resourcesById={resourcesById}
rollups={rollups}
@@ -587,7 +587,7 @@ const Recovery: Component = () => {
setHistoryOutcomeFilter={setHistoryOutcomeFilter}
setItemTypeFilter={setItemTypeFilter}
setProtectedStaleOnly={setProtectedStaleOnly}
setProviderFilter={setProviderFilter}
setPlatformFilter={setPlatformFilter}
setQueryFilter={setQueryFilter}
setVerificationFilter={setVerificationFilter}
/>
@@ -625,8 +625,8 @@ const Recovery: Component = () => {
namespaceOptions={namespaceOptions}
nodeFilter={nodeFilter}
nodeOptions={nodeOptions}
providerFilter={providerFilter}
providerOptions={providerOptions}
platformFilter={platformFilter}
platformOptions={platformOptions}
queryFilter={queryFilter}
recoveryPoints={recoveryPoints}
resetAdvancedArtifactFilters={resetAdvancedArtifactFilters}
@@ -640,7 +640,7 @@ const Recovery: Component = () => {
setModeFilter={setModeFilter}
setNamespaceFilter={setNamespaceFilter}
setNodeFilter={setNodeFilter}
setProviderFilter={setProviderFilter}
setPlatformFilter={setPlatformFilter}
setQueryFilter={setQueryFilter}
setScopeFilter={setScopeFilter}
setVerificationFilter={setVerificationFilter}
@@ -72,8 +72,8 @@ interface RecoveryHistorySectionProps {
namespaceOptions: Accessor<string[]>;
nodeFilter: Accessor<string>;
nodeOptions: Accessor<string[]>;
providerFilter: Accessor<string>;
providerOptions: Accessor<string[]>;
platformFilter: Accessor<string>;
platformOptions: Accessor<string[]>;
queryFilter: Accessor<string>;
recoveryPoints: RecoveryPointsModel;
resetAdvancedArtifactFilters: () => void;
@@ -87,7 +87,7 @@ interface RecoveryHistorySectionProps {
setModeFilter: (value: 'all' | ArtifactMode) => void;
setNamespaceFilter: (value: string) => void;
setNodeFilter: (value: string) => void;
setProviderFilter: (value: string) => void;
setPlatformFilter: (value: string) => void;
setQueryFilter: (value: string) => void;
setScopeFilter: (value: 'all' | 'workload') => void;
setVerificationFilter: (value: VerificationFilter) => void;
@@ -121,7 +121,7 @@ export const RecoveryHistorySection: Component<RecoveryHistorySectionProps> = (p
modeFilter: props.modeFilter,
namespaceFilter: props.namespaceFilter,
nodeFilter: props.nodeFilter,
providerFilter: props.providerFilter,
platformFilter: props.platformFilter,
queryFilter: props.queryFilter,
scopeFilter: props.scopeFilter,
verificationFilter: props.verificationFilter,
@@ -370,18 +370,18 @@ export const RecoveryHistorySection: Component<RecoveryHistorySectionProps> = (p
</LabeledFilterSelect>
<LabeledFilterSelect
id="recovery-provider-filter-history"
id="recovery-platform-filter-history"
label="History platform"
value={props.providerFilter()}
value={props.platformFilter()}
onChange={(event) => {
props.setProviderFilter(
props.setPlatformFilter(
normalizeSourcePlatformQueryValue(event.currentTarget.value),
);
props.setCurrentPage(1);
}}
selectClass="min-w-[10rem] max-w-[14rem]"
>
<For each={props.providerOptions()}>
<For each={props.platformOptions()}>
{(provider) => (
<option value={provider}>
{provider === 'all' ? 'All Platforms' : getSourcePlatformLabel(provider)}
@@ -64,8 +64,8 @@ interface RecoveryProtectedInventorySectionProps {
kioskMode: boolean;
onSelectRollup: (rollupId: string) => void;
protectedStaleOnly: Accessor<boolean>;
providerFilter: Accessor<string>;
providerOptions: Accessor<string[]>;
platformFilter: Accessor<string>;
platformOptions: Accessor<string[]>;
queryFilter: Accessor<string>;
resourcesById: Accessor<Map<string, Resource>>;
rollups: Accessor<ProtectionRollup[]>;
@@ -73,7 +73,7 @@ interface RecoveryProtectedInventorySectionProps {
setHistoryOutcomeFilter: (value: 'all' | RecoveryOutcome) => void;
setItemTypeFilter: (value: string) => void;
setProtectedStaleOnly: (value: boolean | ((prev: boolean) => boolean)) => void;
setProviderFilter: (value: string) => void;
setPlatformFilter: (value: string) => void;
setQueryFilter: (value: string) => void;
setVerificationFilter: (value: VerificationFilter) => void;
loading: Accessor<boolean>;
@@ -101,7 +101,7 @@ export const RecoveryProtectedInventorySection: Component<
const protectedActiveFilterCount = createMemo(() => {
let count = 0;
if (props.queryFilter().trim() !== '') count += 1;
if (props.providerFilter() !== 'all') count += 1;
if (props.platformFilter() !== 'all') count += 1;
if (props.itemTypeFilter() !== 'all') count += 1;
if (props.historyOutcomeFilter() !== 'all') count += 1;
if (props.protectedStaleOnly()) count += 1;
@@ -237,17 +237,17 @@ export const RecoveryProtectedInventorySection: Component<
</LabeledFilterSelect>
<LabeledFilterSelect
id="recovery-provider-filter"
id="recovery-platform-filter"
label="Platform"
value={props.providerFilter()}
value={props.platformFilter()}
onChange={(event) =>
props.setProviderFilter(
props.setPlatformFilter(
normalizeSourcePlatformQueryValue(event.currentTarget.value),
)
}
selectClass="min-w-[10rem] max-w-[14rem]"
>
<For each={props.providerOptions()}>
<For each={props.platformOptions()}>
{(provider) => (
<option value={provider}>
{provider === 'all' ? 'All Platforms' : getSourcePlatformLabel(provider)}
@@ -74,8 +74,8 @@ describe('Recovery layout guards', () => {
nodeOptions: () => ['all'],
overallRollupsSummary: () => ({ total: 2, stale: 0, neverSucceeded: 0 }),
protectedStaleOnly: () => false,
providerFilter: () => 'all',
providerOptions: () => ['all'],
platformFilter: () => 'all',
platformOptions: () => ['all'],
queryFilter: () => '',
recoveryPoints: {
meta: () => ({ page: 1, limit: 200, total: 0, totalPages: 1 }),
@@ -112,7 +112,7 @@ describe('Recovery layout guards', () => {
setNamespaceFilter: vi.fn(),
setNodeFilter: vi.fn(),
setProtectedStaleOnly: vi.fn(),
setProviderFilter: vi.fn(),
setPlatformFilter: vi.fn(),
setQueryFilter: vi.fn(),
setRollupId: vi.fn(),
setScopeFilter: vi.fn(),
@@ -389,7 +389,7 @@ describe('Recovery', () => {
expect(within(table).getByText('Lab Cluster')).toBeInTheDocument();
});
it('filters protected rollups by provider', async () => {
it('filters protected rollups by platform', async () => {
render(() => <Recovery />);
expect(await screen.findByText('VM 123')).toBeInTheDocument();
@@ -397,7 +397,7 @@ describe('Recovery', () => {
fireEvent.change(screen.getByLabelText('Platform'), { target: { value: 'truenas' } });
await waitFor(() => {
expect(navigateSpy).toHaveBeenCalledWith('/recovery?provider=truenas', { replace: true });
expect(navigateSpy).toHaveBeenCalledWith('/recovery?platform=truenas', { replace: true });
expect(screen.queryByText('VM 123')).not.toBeInTheDocument();
});
expect(screen.getByText('tank/apps')).toBeInTheDocument();
@@ -405,16 +405,16 @@ describe('Recovery', () => {
await waitFor(() => {
const urls = apiFetchMock.mock.calls.map((call) => String(call[0] || ''));
const hasRollups = urls.some(
(url) => url.includes('/api/recovery/rollups') && url.includes('provider=truenas'),
(url) => url.includes('/api/recovery/rollups') && url.includes('platform=truenas'),
);
const hasPoints = urls.some(
(url) => url.includes('/api/recovery/points') && url.includes('provider=truenas'),
(url) => url.includes('/api/recovery/points') && url.includes('platform=truenas'),
);
const hasSeries = urls.some(
(url) => url.includes('/api/recovery/series') && url.includes('provider=truenas'),
(url) => url.includes('/api/recovery/series') && url.includes('platform=truenas'),
);
const hasFacets = urls.some(
(url) => url.includes('/api/recovery/facets') && url.includes('provider=truenas'),
(url) => url.includes('/api/recovery/facets') && url.includes('platform=truenas'),
);
expect(hasRollups && hasPoints && hasSeries && hasFacets).toBe(true);
});
@@ -532,18 +532,18 @@ describe('Recovery', () => {
);
});
it('normalizes legacy provider aliases from the URL into canonical history filters', async () => {
it('normalizes legacy provider aliases into canonical platform route state', async () => {
mockLocationSearch = '?provider=proxmox';
render(() => <Recovery />);
await waitFor(() => {
expect(navigateSpy).toHaveBeenCalledWith('/recovery?provider=proxmox-pve', {
expect(navigateSpy).toHaveBeenCalledWith('/recovery?platform=proxmox-pve', {
replace: true,
});
});
});
it('collapses unknown recovery provider values back to canonical unset state', async () => {
it('collapses unknown recovery platform values back to canonical unset state', async () => {
mockLocationSearch = '?provider=%20custom-provider%20';
render(() => <Recovery />);
@@ -561,7 +561,11 @@ describe('Recovery', () => {
url.includes('/api/recovery/facets') ||
url.includes('/api/recovery/series'),
);
expect(filteredUrls.some((url) => url.includes('provider=custom-provider'))).toBe(false);
expect(
filteredUrls.some(
(url) => url.includes('platform=custom-provider') || url.includes('provider=custom-provider'),
),
).toBe(false);
});
});
@@ -622,6 +626,8 @@ describe('Recovery', () => {
expect(
filteredUrls.some(
(url) =>
url.includes('platform=all') ||
url.includes('platform=ALL') ||
url.includes('provider=all') ||
url.includes('provider=ALL') ||
url.includes('scope=all') ||
@@ -15,7 +15,7 @@ interface UseRecoveryHistorySectionStateParams {
modeFilter: Accessor<'all' | ArtifactMode>;
namespaceFilter: Accessor<string>;
nodeFilter: Accessor<string>;
providerFilter: Accessor<string>;
platformFilter: Accessor<string>;
queryFilter: Accessor<string>;
scopeFilter: Accessor<'all' | 'workload'>;
verificationFilter: Accessor<VerificationFilter>;
@@ -33,7 +33,7 @@ export function useRecoveryHistorySectionState(
const historyActiveFilterCount = createMemo(() => {
let count = 0;
if (params.queryFilter().trim() !== '') count += 1;
if (params.providerFilter() !== 'all') count += 1;
if (params.platformFilter() !== 'all') count += 1;
if (params.itemTypeFilter() !== 'all') count += 1;
if (params.historyOutcomeFilter() !== 'all') count += 1;
if (params.scopeFilter() !== 'all') count += 1;
@@ -47,7 +47,7 @@ export function useRecoveryHistorySectionState(
createEffect(() => {
params.currentPage();
params.providerFilter();
params.platformFilter();
params.itemTypeFilter();
params.historyOutcomeFilter();
params.scopeFilter();
@@ -58,7 +58,7 @@ const normalizeRecoveryBooleanFlag = (value: string | null | undefined): boolean
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
};
const normalizeRecoveryProviderSelection = (value: string | null | undefined): string => {
const normalizeRecoveryPlatformSelection = (value: string | null | undefined): string => {
const normalized = normalizeSourcePlatformQueryValue(value);
if (!normalized || normalized === 'all') return 'all';
return normalizeSourcePlatformKey(normalized) || 'all';
@@ -78,7 +78,7 @@ export function useRecoverySurfaceState() {
const [rollupId, setRollupId] = createSignal('');
const [workspaceView, setWorkspaceView] = createSignal<RecoveryWorkspaceView>('inventory');
const [queryFilter, setQueryFilter] = createSignal('');
const [providerFilter, setProviderFilter] = createSignal('all');
const [platformFilter, setPlatformFilter] = createSignal('all');
const [itemTypeFilter, setItemTypeFilter] = createSignal('all');
const [clusterFilter, setClusterFilter] = createSignal('all');
const [modeFilter, setModeFilter] = createSignal<'all' | ArtifactMode>('all');
@@ -124,7 +124,7 @@ export function useRecoverySurfaceState() {
const vf = verificationFilter();
return {
rollupId: rid || null,
provider: providerFilter() === 'all' ? null : providerFilter(),
platform: platformFilter() === 'all' ? null : platformFilter(),
itemType: itemTypeFilter() === 'all' ? null : itemTypeFilter(),
mode: modeFilter() === 'all' ? null : modeFilter(),
outcome: historyOutcomeFilter() === 'all' ? null : historyOutcomeFilter(),
@@ -149,7 +149,7 @@ export function useRecoverySurfaceState() {
page: currentPage(),
limit: 200,
rollupId: rid || null,
provider: providerFilter() === 'all' ? null : providerFilter(),
platform: platformFilter() === 'all' ? null : platformFilter(),
itemType: itemTypeFilter() === 'all' ? null : itemTypeFilter(),
cluster: clusterFilter() === 'all' ? null : clusterFilter(),
mode: modeFilter() === 'all' ? null : modeFilter(),
@@ -170,7 +170,7 @@ export function useRecoverySurfaceState() {
const vf = verificationFilter();
return {
rollupId: rid || null,
provider: providerFilter() === 'all' ? null : providerFilter(),
platform: platformFilter() === 'all' ? null : platformFilter(),
itemType: itemTypeFilter() === 'all' ? null : itemTypeFilter(),
cluster: clusterFilter() === 'all' ? null : clusterFilter(),
mode: modeFilter() === 'all' ? null : modeFilter(),
@@ -191,7 +191,7 @@ export function useRecoverySurfaceState() {
const vf = verificationFilter();
return {
rollupId: rid || null,
provider: providerFilter() === 'all' ? null : providerFilter(),
platform: platformFilter() === 'all' ? null : platformFilter(),
itemType: itemTypeFilter() === 'all' ? null : itemTypeFilter(),
cluster: clusterFilter() === 'all' ? null : clusterFilter(),
mode: modeFilter() === 'all' ? null : modeFilter(),
@@ -221,7 +221,7 @@ export function useRecoverySurfaceState() {
const nextRollup = normalizeRecoveryRouteValue(parsed.rollupId);
const nextView = normalizeRecoveryWorkspaceViewValue(parsed.view);
const nextQuery = normalizeRecoveryRouteValue(parsed.query);
const nextProvider = normalizeRecoveryProviderSelection(parsed.provider || '');
const nextPlatform = normalizeRecoveryPlatformSelection(parsed.platform || '');
const nextItemType = normalizeRecoveryItemTypeSelection(parsed.itemType || '');
const nextStaleOnly = normalizeRecoveryBooleanFlag(parsed.stale);
const normalizedRange = normalizeRecoveryRouteValue(parsed.range);
@@ -243,7 +243,7 @@ export function useRecoverySurfaceState() {
if (nextRollup !== untrack(rollupId)) setRollupId(nextRollup);
if (resolvedView !== untrack(workspaceView)) setWorkspaceView(resolvedView);
if (nextQuery !== untrack(queryFilter)) setQueryFilter(nextQuery);
if (nextProvider !== untrack(providerFilter)) setProviderFilter(nextProvider);
if (nextPlatform !== untrack(platformFilter)) setPlatformFilter(nextPlatform);
if (nextItemType !== untrack(itemTypeFilter)) setItemTypeFilter(nextItemType);
if (nextStaleOnly !== untrack(protectedStaleOnly)) setProtectedStaleOnly(nextStaleOnly);
if (nextRange !== untrack(chartRangeDays)) setChartRangeDays(nextRange as 7 | 30 | 90 | 365);
@@ -280,7 +280,7 @@ export function useRecoverySurfaceState() {
rollupId();
workspaceView();
queryFilter();
providerFilter();
platformFilter();
itemTypeFilter();
clusterFilter();
modeFilter();
@@ -305,7 +305,7 @@ export function useRecoverySurfaceState() {
rollupId: rid || null,
view: workspaceView() !== defaultView ? workspaceView() : null,
query: queryFilter().trim() || null,
provider: providerFilter() !== 'all' ? providerFilter() : null,
platform: platformFilter() !== 'all' ? platformFilter() : null,
itemType: itemTypeFilter() !== 'all' ? itemTypeFilter() : null,
stale: protectedStaleOnly() ? '1' : null,
range: chartRangeDays() !== 30 ? String(chartRangeDays()) : null,
@@ -327,19 +327,19 @@ export function useRecoverySurfaceState() {
const facets = createMemo(() => recoveryFacets.facets() || {});
const providerOptions = createMemo(() => {
const providers = new Set<string>();
const platformOptions = createMemo(() => {
const platforms = new Set<string>();
for (const rollup of rollups()) {
for (const provider of rollup.providers || []) {
const normalized = normalizeSourcePlatformQueryValue(String(provider || '').trim());
if (normalized) providers.add(normalized);
if (normalized) platforms.add(normalized);
}
}
for (const point of recoveryPoints.points() || []) {
const normalized = normalizeSourcePlatformQueryValue(String(point?.provider || '').trim());
if (normalized) providers.add(normalized);
if (normalized) platforms.add(normalized);
}
return ['all', ...buildSourcePlatformOptions(providers).map((option) => option.key)];
return ['all', ...buildSourcePlatformOptions(platforms).map((option) => option.key)];
});
const itemTypeOptions = createMemo(() => {
@@ -436,8 +436,8 @@ export function useRecoverySurfaceState() {
nodeFilter,
nodeOptions,
protectedStaleOnly,
providerFilter,
providerOptions,
platformFilter,
platformOptions,
queryFilter,
recoveryFacets,
recoveryPoints,
@@ -457,7 +457,7 @@ export function useRecoverySurfaceState() {
setNamespaceFilter,
setNodeFilter,
setProtectedStaleOnly,
setProviderFilter,
setPlatformFilter,
setQueryFilter,
setRollupId,
setScopeFilter,
@@ -13,7 +13,7 @@ export type RecoveryPointsQuery = {
// Primary filters (server-side)
rollupId?: string | null;
provider?: string | null;
platform?: string | null;
kind?: string | null;
mode?: string | null;
outcome?: string | null;
@@ -48,7 +48,7 @@ const normalizeQuery = (query: RecoveryPointsQuery | undefined): RecoveryPointsQ
limit,
rollupId: norm(q.rollupId) || null,
provider: norm(q.provider) || null,
platform: norm(q.platform) || null,
kind: norm(q.kind) || null,
mode: norm(q.mode) || null,
outcome: norm(q.outcome) || null,
@@ -88,7 +88,7 @@ const buildURL = (query: RecoveryPointsQuery | undefined): string => {
params.set('limit', String(q.limit || DEFAULT_LIMIT));
if (q.rollupId) params.set('rollupId', q.rollupId);
if (q.provider) params.set('provider', q.provider);
if (q.platform) params.set('platform', q.platform);
if (q.kind) params.set('kind', q.kind);
if (q.mode) params.set('mode', q.mode);
if (q.outcome) params.set('outcome', q.outcome);
@@ -7,7 +7,7 @@ const REFRESH_MS = 30_000;
export type RecoveryFacetsQuery = {
rollupId?: string | null;
provider?: string | null;
platform?: string | null;
kind?: string | null;
mode?: string | null;
outcome?: string | null;
@@ -29,7 +29,7 @@ const normalizeQuery = (query: RecoveryFacetsQuery | undefined): RecoveryFacetsQ
const norm = (value: string | null | undefined) => (value || '').trim();
return {
rollupId: norm(q.rollupId) || null,
provider: norm(q.provider) || null,
platform: norm(q.platform) || null,
kind: norm(q.kind) || null,
mode: norm(q.mode) || null,
outcome: norm(q.outcome) || null,
@@ -65,7 +65,7 @@ const buildURL = (query: RecoveryFacetsQuery | undefined): string => {
const params = new URLSearchParams();
if (q.rollupId) params.set('rollupId', q.rollupId);
if (q.provider) params.set('provider', q.provider);
if (q.platform) params.set('platform', q.platform);
if (q.kind) params.set('kind', q.kind);
if (q.mode) params.set('mode', q.mode);
if (q.outcome) params.set('outcome', q.outcome);
@@ -7,7 +7,7 @@ const REFRESH_MS = 30_000;
export type RecoverySeriesQuery = {
rollupId?: string | null;
provider?: string | null;
platform?: string | null;
kind?: string | null;
mode?: string | null;
outcome?: string | null;
@@ -35,7 +35,7 @@ const normalizeQuery = (query: RecoverySeriesQuery | undefined): RecoverySeriesQ
: 0;
return {
rollupId: norm(q.rollupId) || null,
provider: norm(q.provider) || null,
platform: norm(q.platform) || null,
kind: norm(q.kind) || null,
mode: norm(q.mode) || null,
outcome: norm(q.outcome) || null,
@@ -73,7 +73,7 @@ const buildURL = (query: RecoverySeriesQuery | undefined): string => {
const params = new URLSearchParams();
if (q.rollupId) params.set('rollupId', q.rollupId);
if (q.provider) params.set('provider', q.provider);
if (q.platform) params.set('platform', q.platform);
if (q.kind) params.set('kind', q.kind);
if (q.mode) params.set('mode', q.mode);
if (q.outcome) params.set('outcome', q.outcome);
@@ -9,7 +9,7 @@ const REFRESH_MS = 30_000;
export type RecoveryRollupsQuery = {
rollupId?: string | null;
provider?: string | null;
platform?: string | null;
kind?: string | null;
mode?: string | null;
outcome?: string | null;
@@ -30,7 +30,7 @@ const normalizeQuery = (query: RecoveryRollupsQuery | undefined): RecoveryRollup
const norm = (value: string | null | undefined) => (value || '').trim();
return {
rollupId: norm(q.rollupId) || null,
provider: norm(q.provider) || null,
platform: norm(q.platform) || null,
kind: norm(q.kind) || null,
mode: norm(q.mode) || null,
outcome: norm(q.outcome) || null,
@@ -66,7 +66,7 @@ const buildURL = (page: number, limit: number, query: RecoveryRollupsQuery | und
params.set('page', String(page));
params.set('limit', String(limit));
if (q.rollupId) params.set('rollupId', q.rollupId);
if (q.provider) params.set('provider', q.provider);
if (q.platform) params.set('platform', q.platform);
if (q.kind) params.set('kind', q.kind);
if (q.mode) params.set('mode', q.mode);
if (q.outcome) params.set('outcome', q.outcome);
@@ -209,7 +209,7 @@ describe('resource link routing contract', () => {
it('builds and parses recovery query params', () => {
const href = buildRecoveryPath({
view: 'events',
provider: 'proxmox-pbs',
platform: 'proxmox-pbs',
stale: '1',
range: '7',
cluster: 'cluster-main',
@@ -226,7 +226,7 @@ describe('resource link routing contract', () => {
const url = new URL(href, 'http://localhost');
expect(url.pathname).toBe('/recovery');
expect(url.searchParams.get('view')).toBe('events');
expect(url.searchParams.get('provider')).toBe('proxmox-pbs');
expect(url.searchParams.get('platform')).toBe('proxmox-pbs');
expect(url.searchParams.get('stale')).toBe('1');
expect(url.searchParams.get('range')).toBe('7');
expect(url.searchParams.get('cluster')).toBe('cluster-main');
@@ -244,7 +244,7 @@ describe('resource link routing contract', () => {
expect(parsed).toEqual({
rollupId: '',
view: 'events',
provider: 'proxmox-pbs',
platform: 'proxmox-pbs',
stale: '1',
range: '7',
cluster: 'cluster-main',
@@ -259,7 +259,7 @@ describe('resource link routing contract', () => {
query: 'node:pve1',
});
expect(RECOVERY_QUERY_PARAMS.provider).toBe('provider');
expect(RECOVERY_QUERY_PARAMS.platform).toBe('platform');
expect(RECOVERY_QUERY_PARAMS.view).toBe('view');
expect(RECOVERY_QUERY_PARAMS.stale).toBe('stale');
expect(RECOVERY_QUERY_PARAMS.range).toBe('range');
@@ -275,12 +275,12 @@ describe('resource link routing contract', () => {
expect(PMG_THRESHOLDS_PATH).toBe('/alerts/thresholds/mail-gateway');
});
it('canonicalizes recovery provider aliases when building and parsing links', () => {
expect(buildRecoveryPath({ provider: 'pbs', mode: 'remote' })).toBe(
'/recovery?provider=proxmox-pbs&mode=remote',
it('canonicalizes recovery platform aliases when building and parsing links', () => {
expect(buildRecoveryPath({ platform: 'pbs', mode: 'remote' })).toBe(
'/recovery?platform=proxmox-pbs&mode=remote',
);
expect(parseRecoveryLinkSearch('?provider=proxmox&mode=local')).toMatchObject({
provider: 'proxmox-pve',
platform: 'proxmox-pve',
mode: 'local',
});
expect(parseRecoveryLinkSearch('?itemType=proxmox-vm')).toMatchObject({
@@ -289,17 +289,17 @@ describe('resource link routing contract', () => {
});
it('canonicalizes stale-only recovery route flags to the owned query shape', () => {
expect(buildRecoveryPath({ stale: 'true', provider: 'proxmox-pve' })).toBe(
'/recovery?provider=proxmox-pve&stale=1',
expect(buildRecoveryPath({ stale: 'true', platform: 'proxmox-pve' })).toBe(
'/recovery?platform=proxmox-pve&stale=1',
);
expect(parseRecoveryLinkSearch('?stale=%201%20')).toMatchObject({ stale: '1' });
});
it('preserves explicit recovery chart range values in route state', () => {
const href = buildRecoveryPath({ range: '30', provider: 'proxmox-pve' });
const href = buildRecoveryPath({ range: '30', platform: 'proxmox-pve' });
const url = new URL(href, 'http://localhost');
expect(url.pathname).toBe('/recovery');
expect(url.searchParams.get('provider')).toBe('proxmox-pve');
expect(url.searchParams.get('platform')).toBe('proxmox-pve');
expect(url.searchParams.get('range')).toBe('30');
expect(parseRecoveryLinkSearch('?range=90')).toMatchObject({ range: '90' });
});
+12 -4
View File
@@ -48,7 +48,7 @@ export const STORAGE_QUERY_PARAMS = {
export const RECOVERY_QUERY_PARAMS = {
rollupId: 'rollupId',
view: 'view',
provider: 'provider',
platform: 'platform',
stale: 'stale',
range: 'range',
cluster: 'cluster',
@@ -113,6 +113,7 @@ type StorageLinkOptions = {
type RecoveryLinkOptions = {
rollupId?: string | null;
view?: string | null;
platform?: string | null;
provider?: string | null;
stale?: string | null;
range?: string | null;
@@ -258,7 +259,12 @@ export const parseRecoveryLinkSearch = (search: string) => {
return {
rollupId: normalizeQueryValue(params.get(RECOVERY_QUERY_PARAMS.rollupId)),
view: normalizeQueryValue(params.get(RECOVERY_QUERY_PARAMS.view)),
provider: normalizeSourcePlatformQueryValue(params.get(RECOVERY_QUERY_PARAMS.provider)),
platform: normalizeSourcePlatformQueryValue(
firstNonEmpty([
params.get(RECOVERY_QUERY_PARAMS.platform),
params.get('provider'),
]),
),
stale: normalizeQueryBooleanFlag(params.get(RECOVERY_QUERY_PARAMS.stale)),
range: normalizeQueryValue(params.get(RECOVERY_QUERY_PARAMS.range)),
cluster: normalizeQueryValue(params.get(RECOVERY_QUERY_PARAMS.cluster)),
@@ -278,7 +284,9 @@ export const buildRecoveryPath = (options: RecoveryLinkOptions = {}): string =>
const params = new URLSearchParams();
const rollupId = normalizeQueryValue(options.rollupId);
const view = normalizeQueryValue(options.view);
const provider = normalizeSourcePlatformQueryValue(options.provider);
const platform = normalizeSourcePlatformQueryValue(
firstNonEmpty([options.platform, options.provider]),
);
const stale = normalizeQueryBooleanFlag(options.stale);
const range = normalizeQueryValue(options.range);
const cluster = normalizeQueryValue(options.cluster);
@@ -294,7 +302,7 @@ export const buildRecoveryPath = (options: RecoveryLinkOptions = {}): string =>
if (rollupId) params.set(RECOVERY_QUERY_PARAMS.rollupId, rollupId);
if (view) params.set(RECOVERY_QUERY_PARAMS.view, view);
if (provider) params.set(RECOVERY_QUERY_PARAMS.provider, provider);
if (platform) params.set(RECOVERY_QUERY_PARAMS.platform, platform);
if (stale) params.set(RECOVERY_QUERY_PARAMS.stale, stale);
if (range) params.set(RECOVERY_QUERY_PARAMS.range, range);
if (cluster) params.set(RECOVERY_QUERY_PARAMS.cluster, cluster);
+17
View File
@@ -2147,6 +2147,23 @@ func TestContract_FilterRecoveryPointsForRollupsIncludesNormalizedFilters(t *tes
}
}
func TestContract_ParseRecoveryPlatformQueryPrefersCanonicalPlatformAlias(t *testing.T) {
t.Parallel()
if got := parseRecoveryPlatformQuery(url.Values{
"platform": []string{" truenas "},
"provider": []string{"proxmox-pve"},
}); got != recovery.Provider("truenas") {
t.Fatalf("parseRecoveryPlatformQuery(platform first) = %q, want %q", got, "truenas")
}
if got := parseRecoveryPlatformQuery(url.Values{
"provider": []string{" proxmox-pbs "},
}); got != recovery.Provider("proxmox-pbs") {
t.Fatalf("parseRecoveryPlatformQuery(provider fallback) = %q, want %q", got, "proxmox-pbs")
}
}
func TestContract_BillingStateJSONSnapshot(t *testing.T) {
payload := entitlements.BillingState{
Capabilities: []string{"relay", "mobile_app"},
+12 -4
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
@@ -79,6 +80,13 @@ func firstQueryValue(qs map[string][]string, key string) string {
return values[0]
}
func parseRecoveryPlatformQuery(qs url.Values) recovery.Provider {
return recovery.Provider(strings.TrimSpace(firstNonEmpty(
qs.Get("platform"),
qs.Get("provider"),
)))
}
func (h *RecoveryHandlers) HandleListPoints(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -104,7 +112,7 @@ func (h *RecoveryHandlers) HandleListPoints(w http.ResponseWriter, r *http.Reque
}
opts := recovery.ListPointsOptions{
Provider: recovery.Provider(strings.TrimSpace(qs.Get("provider"))),
Provider: parseRecoveryPlatformQuery(qs),
Kind: recovery.Kind(strings.TrimSpace(qs.Get("kind"))),
Mode: recovery.Mode(strings.TrimSpace(qs.Get("mode"))),
Outcome: recovery.Outcome(strings.TrimSpace(qs.Get("outcome"))),
@@ -218,7 +226,7 @@ func (h *RecoveryHandlers) HandleListSeries(w http.ResponseWriter, r *http.Reque
tzOffsetMin := parseIntQuery(qs, "tzOffsetMinutes", 0)
opts := recovery.ListPointsOptions{
Provider: recovery.Provider(strings.TrimSpace(qs.Get("provider"))),
Provider: parseRecoveryPlatformQuery(qs),
Kind: recovery.Kind(strings.TrimSpace(qs.Get("kind"))),
Mode: recovery.Mode(strings.TrimSpace(qs.Get("mode"))),
Outcome: recovery.Outcome(strings.TrimSpace(qs.Get("outcome"))),
@@ -284,7 +292,7 @@ func (h *RecoveryHandlers) HandleListFacets(w http.ResponseWriter, r *http.Reque
}
opts := recovery.ListPointsOptions{
Provider: recovery.Provider(strings.TrimSpace(qs.Get("provider"))),
Provider: parseRecoveryPlatformQuery(qs),
Kind: recovery.Kind(strings.TrimSpace(qs.Get("kind"))),
Mode: recovery.Mode(strings.TrimSpace(qs.Get("mode"))),
Outcome: recovery.Outcome(strings.TrimSpace(qs.Get("outcome"))),
@@ -546,7 +554,7 @@ func (h *RecoveryHandlers) HandleListRollups(w http.ResponseWriter, r *http.Requ
}
opts := recovery.ListPointsOptions{
Provider: recovery.Provider(strings.TrimSpace(qs.Get("provider"))),
Provider: parseRecoveryPlatformQuery(qs),
Kind: recovery.Kind(strings.TrimSpace(qs.Get("kind"))),
Mode: recovery.Mode(strings.TrimSpace(qs.Get("mode"))),
Outcome: recovery.Outcome(strings.TrimSpace(qs.Get("outcome"))),
+85
View File
@@ -0,0 +1,85 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/recovery"
)
func TestParseRecoveryPlatformQuery(t *testing.T) {
t.Parallel()
tests := []struct {
name string
qs url.Values
want recovery.Provider
}{
{
name: "prefers canonical platform query",
qs: url.Values{
"platform": []string{" truenas "},
"provider": []string{"proxmox-pve"},
},
want: recovery.Provider("truenas"),
},
{
name: "falls back to legacy provider query",
qs: url.Values{
"provider": []string{" proxmox-pbs "},
},
want: recovery.Provider("proxmox-pbs"),
},
{
name: "returns empty when neither is present",
qs: url.Values{},
want: recovery.Provider(""),
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := parseRecoveryPlatformQuery(tc.qs); got != tc.want {
t.Fatalf("parseRecoveryPlatformQuery() = %q, want %q", got, tc.want)
}
})
}
}
func TestHandleListPointsAcceptsCanonicalPlatformQuery(t *testing.T) {
prevMock := mock.IsMockEnabled()
mock.SetEnabled(true)
t.Cleanup(func() {
mock.SetEnabled(prevMock)
})
req := httptest.NewRequest(http.MethodGet, "/api/recovery/points?platform=truenas&limit=500", nil)
rec := httptest.NewRecorder()
NewRecoveryHandlers(nil).HandleListPoints(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("HandleListPoints() status = %d, want %d", rec.Code, http.StatusOK)
}
var resp struct {
Data []recovery.RecoveryPoint `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if len(resp.Data) == 0 {
t.Fatal("expected recovery points for platform=truenas, got none")
}
for _, point := range resp.Data {
if point.Provider != recovery.Provider("truenas") {
t.Fatalf("expected only truenas recovery points, got provider %q", point.Provider)
}
}
}