diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index b18c3d3a0..0d27c6955 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 5bd85bd6a..515e7ce6b 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 6240f76a5..2fb173068 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 4568639b6..fdb639af6 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 4f38eb1a0..dcc3e7902 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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=` 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 diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.service-cards.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.service-cards.test.tsx index 41a5127ce..f5dd3397b 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.service-cards.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.service-cards.test.tsx @@ -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', diff --git a/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.workloads-link.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.workloads-link.test.tsx index 7b3fdf564..3b837c1a1 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.workloads-link.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.workloads-link.test.tsx @@ -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'); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/serviceDetailLinks.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/serviceDetailLinks.test.ts index 05a61a948..3501931e3 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/serviceDetailLinks.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/serviceDetailLinks.test.ts @@ -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', diff --git a/frontend-modern/src/components/Infrastructure/serviceDetailLinks.ts b/frontend-modern/src/components/Infrastructure/serviceDetailLinks.ts index f64b36b1e..7cd88b967 100644 --- a/frontend-modern/src/components/Infrastructure/serviceDetailLinks.ts +++ b/frontend-modern/src/components/Infrastructure/serviceDetailLinks.ts @@ -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}`, diff --git a/frontend-modern/src/components/Recovery/Recovery.tsx b/frontend-modern/src/components/Recovery/Recovery.tsx index a96be5d42..1d8b16722 100644 --- a/frontend-modern/src/components/Recovery/Recovery.tsx +++ b/frontend-modern/src/components/Recovery/Recovery.tsx @@ -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(() => { 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} diff --git a/frontend-modern/src/components/Recovery/RecoveryHistorySection.tsx b/frontend-modern/src/components/Recovery/RecoveryHistorySection.tsx index c42c26dc1..06c537a35 100644 --- a/frontend-modern/src/components/Recovery/RecoveryHistorySection.tsx +++ b/frontend-modern/src/components/Recovery/RecoveryHistorySection.tsx @@ -72,8 +72,8 @@ interface RecoveryHistorySectionProps { namespaceOptions: Accessor; nodeFilter: Accessor; nodeOptions: Accessor; - providerFilter: Accessor; - providerOptions: Accessor; + platformFilter: Accessor; + platformOptions: Accessor; queryFilter: Accessor; 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 = (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 = (p { - props.setProviderFilter( + props.setPlatformFilter( normalizeSourcePlatformQueryValue(event.currentTarget.value), ); props.setCurrentPage(1); }} selectClass="min-w-[10rem] max-w-[14rem]" > - + {(provider) => ( - props.setProviderFilter( + props.setPlatformFilter( normalizeSourcePlatformQueryValue(event.currentTarget.value), ) } selectClass="min-w-[10rem] max-w-[14rem]" > - + {(provider) => (