mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
refactor(recovery): canonicalize platform filter boundary
This commit is contained in:
@@ -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
|
||||
|
||||
+3
-1
@@ -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',
|
||||
|
||||
+1
-1
@@ -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' });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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"))),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user