Drop the dead recovery serializer and its stale contract prose

The aggregate Recovery page and its route-state owner were deleted on
2026-05-26, but RECOVERY_QUERY_PARAMS, parseRecoveryLinkSearch and
buildRecoveryRouteSearch stayed in resourceLinks.ts with no production
caller, which kept recoveryItemTypePresentation.ts alive as their only
reader. Both go.

The frontend-primitives, storage-recovery, unified-resources, alerts and
cloud-paid contracts still described the deleted page as live: its shell,
event-first workspace, section owners, filter and column models, recovery
route state, and the incident panel's link chips. Those passages now say
what exists. Recovery rows render in the Proxmox Backups and TrueNAS
Protection tabs, useRecoveryPoints decodes every recovery-points read, and
rules that only governed the deleted page are gone or reduced to a
reintroduction guard.

New assertions pin the rewritten claims: resourceLinks exports nothing
recovery-shaped, the recovery-points hook hands out normalized points and
is the only reader, the retired hooks stay deleted, each tab keeps its own
view and filter state, and the incident panel imports no route helpers.
This commit is contained in:
rcourtman
2026-10-08 00:17:30 +01:00
parent 1a094646d7
commit 1eb4a84fe6
17 changed files with 510 additions and 1757 deletions
@@ -2642,7 +2642,7 @@ table subgroup-row contract. `AlertHistoryTableGroupRow` and grouped rows in
`AlertResourceTableDesktop` must route their date/resource group bands through
`frontend-modern/src/components/shared/groupedTableRowPresentation.ts` instead
of local `bg-surface-alt` fills, so alert subgroup hierarchy stays visually
consistent with Infrastructure, Workloads, Storage, and Recovery tables.
consistent with Infrastructure, Workloads, and Storage tables.
Alert history table shells must also rely on the shared `TableCard` frame and
the shared `Table` primitive for horizontal overflow rather than adding
alert-local bordered or `overflow-x-auto` wrappers inside the history section.
@@ -3136,21 +3136,22 @@ issue sequential route writes. That reset removes all three query parameters
in one navigation and clears any transient chart-bucket selection, so a
search-only result set remains visibly resettable and an older URL write
cannot resurrect another filter.
That same history surface now also owns the canonical resource-incident
handoff. `frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx`
must treat the selected incident resource as a unified-resource consumer,
linking back into canonical infrastructure/resource detail first and then into
shared workloads, storage, and recovery surfaces through
`frontend-modern/src/routing/resourceLinks.ts` rather than leaving the panel
as a dead-end investigation card or rebuilding provider-local route strings for
platforms such as TrueNAS.
That same alert handoff must now stay on the shared resolved-resource link
builder. `AlertResourceIncidentsPanel.tsx` must resolve its chip set through
`buildResolvedResourceSurfaceLinks(...)`, which owns exact unified-resource
handoffs plus the infrastructure fallback when alert history still references a
resource ID before the backing unified record has hydrated. Future incident-link
work must not reintroduce local infrastructure-link assembly, local dedupe, or
provider-local route strings inside the alert feature shell.
That same history surface also owns the resource-incident panel, and the panel
links nowhere. `frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx`
names the selected resource through the unified-resource lookup it is given
(`getResource`, falling back to the history state's lookup) and
`getPreferredInfrastructureDisplayName`, and shows the resource name the
selected history row carried while no unified record matches. It imports nothing from
`frontend-modern/src/routing/resourceLinks.ts`, and it offers no resource
cross-jump links: investigation continues in place through the incident
timeline cards or hands off to Pulse Assistant through
`IncidentAssistantHandoffButton`, as the retired cross-jump chip note above
describes. Future incident-link work
must not reintroduce a chip set, local infrastructure-link assembly, or
provider-local route strings inside the alert feature shell;
`frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts` pins the panel
free of `buildResolvedResourceSurfaceLinks`, `buildResourceSurfaceLinksForResource`,
and `buildInfrastructureResourceLink`.
Alert configuration load/save state, notification config reloads, and threshold
override normalization now route through
@@ -1583,9 +1583,10 @@ follow the same canonical home. The hosted bootstrap / activation / recovery
routes (`/pricing`, `/preview/setup-complete`, `/login`, settings panels
under `/settings/...`) are unchanged. Pricing-handoff and hosted-signup
upgrade paths must use platform routes as their canonical "back to product"
destination instead of the retired top-level Infrastructure route; aggregate
Workloads / Storage / Recovery pages are product workspaces, not hosted
acquisition destinations. The Docker / Podman runtime route remains `/docker`
destination instead of the retired top-level Infrastructure route. Platform
pages are product workspaces, not hosted acquisition destinations, and the
retired aggregate Workloads / Storage / Recovery pages are not destinations at
all. The Docker / Podman runtime route remains `/docker`
for search familiarity, but the shell may label the
destination as Docker so it reads as the recognizable container runtime lens
rather than an owning infrastructure platform.
@@ -2635,7 +2635,7 @@ not a replacement status card, CTA band, or page-local nested card.
drifting off-screen from page-local flex behavior. The shared action rail
must align to the trailing edge at wrapped desktop widths and remain
separate from the filter-control wrap zone instead of waiting for a wide
breakpoint, so Recovery events, Workloads, Storage, Infrastructure, and
breakpoint, so Workloads, Storage, Infrastructure, and
future dense toolbars do not strand Filter/Columns/Reset actions as an
isolated second-row fragment. Shared `FilterToolbarPanel` owns
default filter-popover geometry, and `FilterToolbar` owns the shared chart
@@ -4469,10 +4469,13 @@ and `getActiveTabForPath` matcher must not recognize aggregate workspace URLs
as owned shell destinations. New palette commands and shortcut chords must
flow through the same shell owners; do not reintroduce hidden platform
families or retired top-level aggregate routes by reanimating legacy paths.
The shared route-state helpers follow the same boundary: workload, storage,
and recovery helpers in `frontend-modern/src/routing/resourceLinks.ts` may
build query strings for an already-owned platform/runtime route, but must not
export pathname builders for `/workloads`, `/storage`, or `/recovery`.
The shared route-state helpers follow the same boundary: the workload and
storage helpers in `frontend-modern/src/routing/resourceLinks.ts` may build
query strings for an already-owned platform/runtime route, but must not export
pathname builders for `/workloads`, `/storage`, or `/recovery`. The module
exports no recovery query serializer: the Proxmox Backups tab owns its query
through `PROXMOX_BACKUPS_QUERY_PARAMS`, and the TrueNAS Protection tab keeps
its search, status filter, and sort out of the URL.
The shared table chrome now allows `TableCardHeader` to expose a right-aligned
action slot, currently used by the Workloads/Proxmox metric display control.
@@ -5958,8 +5961,8 @@ pages that need workspace tabs or count chips next to search should route that
through the shared `searchLeading` slot instead of recreating a second local
header strip above the control bar.
Pages that filter a list-of-resources surface (Infrastructure, Workloads,
Storage, Recovery Protection coverage, Recovery events) compose the chip-based
Pages that filter a list-of-resources surface (Workloads, Storage, and the
Proxmox Backups tab) compose the chip-based
`frontend-modern/src/components/shared/FilterBar/FilterBar.tsx` shell instead
of `PageControls`. Each page declares a `FilterDef[]` catalog (label, options,
value, defaultValue, group); `FilterBar` renders chips for active filters and
@@ -5975,9 +5978,9 @@ than passing an empty conditional wrapper that leaves a dead View trigger.
Contextual frequent actions may use `leadingControls`, while table counters,
active trend ranges, and other persistent orientation readouts may use
`trailingControls`. Platform tables inherit the same ownership through
`PlatformTableToolbar`. Recovery is event-first and does not use equal
workspace subtabs for protected rollups versus event history; Storage subtabs
(Pools / Physical Disks) sit above the bar as navigation, not filters.
`PlatformTableToolbar`. View switches sit above the bar as navigation, not
filters: the Proxmox Backups tab's `By date` / `Coverage` `PlatformSectionTabs` and
the Storage subtabs (Pools / Physical Disks).
`FilterBar` owns committed infrastructure search terms as removable pills,
separate from its consumer-owned structured `FilterDef` chips. An exact
infrastructure completion or a recognized abbreviated query clears the draft
@@ -6270,7 +6273,7 @@ an absent list as an empty audit history.
That shared filter-option primitive is also the canonical owner for default
`All <scope>` option wording wherever a product surface exposes filter selects
or segmented filter choices. Workloads filters, storage source
filters, recovery history and platform/type filters, Kubernetes namespace
filters, Kubernetes namespace
drawers, resource-change timeline filters, and alert configuration options must
call `frontend-modern/src/components/shared/filterOptionPresentation.ts` through
their nearest presentation/model owner instead of hard-coding page-local `All
@@ -6365,18 +6368,13 @@ landing fallback. Shared desktop and mobile navigation must tolerate a missing
active tab for those paths while still highlighting canonical active routes
such as Proxmox, Docker, Kubernetes, TrueNAS, vSphere, Machines, Alerts,
Patrol, and Settings.
The recovery feature shell now also depends on the shared
`frontend-modern/src/components/shared/Subtabs.tsx` primitive for its primary
protected-items versus recovery-events workspace switch. The recovery lane may
own the active view and route-state semantics, but the top-level tab framing
must stay on the canonical shared subtabs control instead of reviving a
recovery-local switcher pattern. When recovery embeds that switcher inside the
page shell, it should follow the same ordering already used by storage: shared
subtabs row first, shared controls card second, and data card after that. The
contained styling should come from the same canonical subtabs shell, list, and
button class treatment already used by established Pulse surfaces rather than
from a recovery-only variant boundary, adjacent chip row, or recovery-local
filter-row embedding.
Recovery has no workspace switch of its own: the protected-items versus
recovery-events `Subtabs` switch left with the aggregate Recovery page on
2026-05-26. The Proxmox Backups tab switches between `/proxmox/backups/date`
and `/proxmox/backups/coverage` through the shared `PlatformSectionTabs`
row above its `FilterBar`, and the TrueNAS Protection tab is a single table.
A future recovery view switch belongs on those platform-page primitives, not
on a recovery-local switcher, chip row, or filter-row embedding.
The shared table primitives now also need to preserve caller-owned separator
styling. `TableHeader` and `TableBody` may provide canonical default borders
and dividers, but when a caller supplies explicit border or divide classes the
@@ -6397,63 +6395,38 @@ That same shared table boundary now owns CSP-safe fill rendering for metric
bars: `frontend-modern/src/components/shared/ProgressBar.tsx` must render fill
width through DOM attributes rather than inline width styles that break the
public demo CSP.
That same recovery shell boundary now also owns one canonical top-level filter
controller in
`frontend-modern/src/features/recovery/useRecoverySurfaceState.ts`. Route-backed
recovery filters such as the provider-neutral `itemType` selector must be
derived, normalized, and fanned out to inventory, history, activity, facets,
and series consumers from that shared state owner rather than being recreated
as page-local toolbar state inside individual recovery sections.
That same shared recovery filter boundary also owns canonical recovery
item-type derivation through
`frontend-modern/src/utils/recoveryItemTypePresentation.ts`. Recovery shell
state, tables, summaries, and point-detail surfaces must resolve rollup and
point item types through the shared presenter helpers instead of repeating
`display.itemType` / `subjectType` / `subjectRef.type` fallback chains in
page-local consumers.
That same shared recovery decode boundary also owns canonical recovery display
shape. `frontend-modern/src/utils/recoveryPlatformModel.ts`,
`frontend-modern/src/hooks/useRecoveryPoints.ts`, and
`frontend-modern/src/hooks/useRecoveryRollups.ts` must normalize legacy
transport display aliases like `subjectLabel` and `subjectType` into canonical
runtime `itemLabel` and `itemType` fields before recovery presenters consume
the model.
The same shared recovery-column boundary must keep legacy `subject` and
`source` column ids at migration-only scope once
`frontend-modern/src/hooks/useColumnVisibility.ts` owns alias rewrites.
Recovery table runtime helpers and render switches should operate on canonical
`item` and `platform` ids rather than carrying the deleted ids as live cases.
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.
Caller-facing shared recovery route builders must therefore stay
platform-first as well: compatibility `provider` aliases may be accepted while
parsing legacy links, but they should not remain a first-class input on new
recovery link construction helpers.
Recovery frontend decode and derived option builders must treat payload
`platform` / `platforms` as the canonical response fields and only fall back
to legacy `provider` / `providers` aliases for compatibility, so route,
filter, and table state do not keep backend-era vocabulary alive as the
default client model.
That normalization belongs at the shared recovery transport boundary in
`frontend-modern/src/hooks/useRecoveryPoints.ts` and
`frontend-modern/src/hooks/useRecoveryRollups.ts`, not in individual tables,
drawers, or summary cards. Recovery components should receive canonical
platform-first runtime models rather than re-deriving legacy alias fallback
locally.
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`.
That same shared recovery filter owner must also preserve route-owned platform
visibility while transport-backed options are still hydrating. If
`frontend-modern/src/features/recovery/useRecoverySurfaceState.ts` restores a
canonical `platform` selection such as `truenas` from the route before the
rollups, points, or facets payloads arrive, it must keep that selected
platform present in the option set so the shared `LabeledFilterSelect` shows
the owned value immediately instead of flashing back to `All Platforms` until
recovery data warms.
Recovery has no shell, filter controller, route serializer, or column model
left for frontend primitives to govern. The aggregate Recovery page, its
section owners under `frontend-modern/src/components/Recovery/`, and its state
owner `frontend-modern/src/features/recovery/useRecoverySurfaceState.ts` were
deleted on 2026-05-26 together with the rollup, facet, and series hooks
(`useRecoveryRollups.ts`, `useRecoveryPointsFacets.ts`,
`useRecoveryPointsSeries.ts`), and the recovery query serializer and
`recoveryItemTypePresentation.ts` that only it read have been deleted since.
Recovery rows render in two platform-owned tabs built from the shared
platform-page primitives: the Proxmox Backups tab and the TrueNAS Protection
tab.
The Proxmox Backups tab parses `/api/backups/pve` and `/api/backups/pbs`
inside `ProxmoxBackupsTable.tsx` and reads posture through
`frontend-modern/src/hooks/useProtectionPostures.ts`. The TrueNAS Protection
tab reads recovery points, and what binds that path is the decode boundary.
`frontend-modern/src/utils/recoveryPlatformModel.ts` maps legacy transport
aliases onto the canonical runtime model: `provider` to `platform`,
`subjectResourceId` to `itemResourceId`, `subjectRef` to `itemRef`, and
`display.subjectLabel` / `display.subjectType` to `display.itemLabel` /
`display.itemType`. `frontend-modern/src/hooks/useRecoveryPoints.ts`, the
only recovery-points reader, applies `normalizeRecoveryPointsResponse` to
every `/api/recovery/points` response, so the TrueNAS Protection tab receives
platform-first, item-first points. The `display.subjectLabel`,
`display.subjectType`, and `subjectRef` fallbacks that
`TrueNASProtectionTable.tsx` and `truenasPageModel.ts` still read are dead on
that path, because normalized points never carry those fields; new recovery
code reads the canonical fields only. New recovery filters, links, or view
switches belong to the platform tab that owns the rows, on the platform-page primitives, rather than in a revived
cross-platform recovery shell or a shared recovery serializer in
`frontend-modern/src/routing/resourceLinks.ts`. The rollup normalizer
(`normalizeRecoveryRollupsResponse`) has had no production reader since
`useRecoveryRollups.ts` was deleted.
The retired dashboard action queue must not be reintroduced as a compact
Patrol or infrastructure issue panel. Patrol-owned runtime findings remain
governed by `frontend-modern/src/utils/aiFindingPresentation.ts` and their
@@ -7421,20 +7394,19 @@ customer-facing action copy, while
provider labels, empty states, and action/status messaging. Future settings
copy changes in those areas should extend these helpers instead of inlining
panel-local strings inside the shell or reactive state owners.
Shared infrastructure action-link framing now also owns recovery entry wording
for service resources. `frontend-modern/src/components/Infrastructure/serviceDetailLinks.ts`
must keep platform-service recovery links on canonical recovery-events
framing and route state, so upstream service surfaces do not drift back to
PBS-backup wording or inherit the page-default inventory workspace when they
are actually deep-linking into recovery activity.
That same shared primitive boundary also owns resource handoff chip framing for
cross-surface investigation UI. Alerts, Patrol, and similar feature shells may
choose which governed surfaces to show, but they must build those links through
the shared resolved-resource route helpers in
`frontend-modern/src/routing/resourceLinks.ts` instead of freezing raw route
strings, local link dedupe, or provider-specific link chips inside feature
panels. Shared chip styling belongs in the feature shell; canonical href and
label truth belongs in the shared route helper.
Shared infrastructure action-link framing owns service-resource action links.
`frontend-modern/src/components/Infrastructure/serviceDetailLinks.ts` returns
only the PMG thresholds link today: the PBS recovery-events link it used to
build was retired with the platform-first route migration, and PBS backups are
reached through the Proxmox Backups tab. A future service recovery link must
target the platform tab that owns those rows rather than a recovery-events
workspace.
Alerts, Patrol, and similar feature shells render no resource handoff chips
since the cross-jump strips were retired on 2026-05-16 (see the retired-helper
note above). A future cross-surface link must take its href and label from a
route helper in `frontend-modern/src/routing/resourceLinks.ts` instead of
freezing raw route strings, local link dedupe, or provider-specific link chips
inside a feature panel; chip styling belongs in the feature shell.
That same shared primitive boundary now also owns persisted column-identity
migration for governed surfaces. When a v6 surface canonicalizes saved column
IDs, `frontend-modern/src/hooks/useColumnVisibility.ts` must accept explicit
@@ -7822,7 +7822,6 @@
"frontend-modern/src/types/recovery.ts",
"frontend-modern/src/utils/recoveryArtifactModePresentation.ts",
"frontend-modern/src/utils/recoveryDatePresentation.ts",
"frontend-modern/src/utils/recoveryItemTypePresentation.ts",
"frontend-modern/src/utils/recoveryOutcomePresentation.ts",
"frontend-modern/src/utils/recoveryTimelineChartPresentation.ts",
"frontend-modern/src/utils/recoveryTimelinePresentation.ts",
@@ -7867,7 +7866,6 @@
"frontend-modern/src/types/recovery.ts",
"frontend-modern/src/utils/recoveryArtifactModePresentation.ts",
"frontend-modern/src/utils/recoveryDatePresentation.ts",
"frontend-modern/src/utils/recoveryItemTypePresentation.ts",
"frontend-modern/src/utils/recoveryOutcomePresentation.ts",
"frontend-modern/src/utils/recoveryTimelineChartPresentation.ts",
"frontend-modern/src/utils/recoveryTimelinePresentation.ts"
@@ -573,29 +573,28 @@ Compact Coverage age cells use the existing metadata-size text and reclaim numer
9. `frontend-modern/src/types/recovery.ts`
10. `frontend-modern/src/utils/recoveryDatePresentation.ts`
11. `frontend-modern/src/utils/recoveryTimelinePresentation.ts`
12. `frontend-modern/src/utils/recoveryItemTypePresentation.ts`
13. `frontend-modern/src/utils/textPresentation.ts`
14. `frontend-modern/src/utils/storageSummaryCache.ts`
15. `frontend-modern/src/components/Storage/useStorageSummaryCharts.ts`
16. `frontend-modern/src/features/storageBackups/storageCapacityDeltaPresentation.ts`
17. `frontend-modern/src/features/proxmox/BackupActivityChart.tsx`
18. `frontend-modern/src/features/proxmox/proxmoxBackupActivityPresentation.ts`
19. `frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts`
20. `frontend-modern/src/features/proxmox/ProxmoxBackupsCoverageStrip.tsx`
21. `frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx`
22. `frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx`
23. `frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx`
24. `frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx`
25. `frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx`
26. `frontend-modern/src/features/proxmox/proxmoxBackupSourcePresentation.ts`
27. `internal/recovery/model/posture.go`
28. `internal/recovery/posture.go`
29. `internal/recovery/store/store_posture.go`
30. `frontend-modern/src/hooks/useProtectionPostures.ts`
31. `frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx`
32. `internal/mock/recovery_points.go`
33. `frontend-modern/src/features/proxmox/useProxmoxBackupTableWindowing.ts`
34. `frontend-modern/src/components/Storage/useStoragePoolsTableWindowing.ts`
12. `frontend-modern/src/utils/textPresentation.ts`
13. `frontend-modern/src/utils/storageSummaryCache.ts`
14. `frontend-modern/src/components/Storage/useStorageSummaryCharts.ts`
15. `frontend-modern/src/features/storageBackups/storageCapacityDeltaPresentation.ts`
16. `frontend-modern/src/features/proxmox/BackupActivityChart.tsx`
17. `frontend-modern/src/features/proxmox/proxmoxBackupActivityPresentation.ts`
18. `frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts`
19. `frontend-modern/src/features/proxmox/ProxmoxBackupsCoverageStrip.tsx`
20. `frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx`
21. `frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx`
22. `frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx`
23. `frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx`
24. `frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx`
25. `frontend-modern/src/features/proxmox/proxmoxBackupSourcePresentation.ts`
26. `internal/recovery/model/posture.go`
27. `internal/recovery/posture.go`
28. `internal/recovery/store/store_posture.go`
29. `frontend-modern/src/hooks/useProtectionPostures.ts`
30. `frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx`
31. `internal/mock/recovery_points.go`
32. `frontend-modern/src/features/proxmox/useProxmoxBackupTableWindowing.ts`
33. `frontend-modern/src/components/Storage/useStoragePoolsTableWindowing.ts`
## Shared Boundaries
@@ -2184,7 +2183,7 @@ recovery scope, or a storage/recovery-owned secret source.
recovery, SMART, or cleanup action.
22. Keep backend-native platform configuration reads on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app config for Assistant, storage and recovery may use that runtime shape during investigation, but they must not grow a parallel recovery-local config transport or provider-shaped configuration payload.
23. Keep provider-backed poll cadence and settings-runtime health on the adjacent platform-connections contract. When shared `internal/api/` and poller wiring expose TrueNAS last-sync status, failure summaries, discovered contribution counts, manual saved-test status refresh, or platform handoff links in settings, storage and recovery may consume the resulting datasets, apps, disks, and recovery artifacts but must not redefine those settings-runtime health semantics or connection-level handoffs in storage/recovery-local transport or page flows.
24. Keep recovery filter/query state on the shared route-state parsing contract without restoring standalone recovery navigation. When platform pages or other embedded owners expose TrueNAS recovery context, they may reuse the canonical recovery query vocabulary with owned `platform` and `node` fields, but they must land inside an owning platform/runtime route instead of inventing drawer-local recovery URLs, treating PBS services as the only recovery path, or sending operators to the retired Recovery aggregate route.
24. Keep recovery context inside the platform tab that owns it without restoring standalone recovery navigation. TrueNAS recovery context lives on the TrueNAS Protection tab and Proxmox recovery context on the Proxmox Backups tab; no shared recovery query vocabulary exists to reuse. A surface that exposes recovery context must land inside an owning platform/runtime route instead of inventing drawer-local recovery URLs, treating PBS services as the only recovery path, or sending operators to the retired Recovery aggregate route.
That same shared route-helper boundary also owns exact workload handoffs
when storage or recovery surfaces send operators back to node-scoped
workloads for investigation context. Proxmox VM and system-container links
@@ -2196,7 +2195,7 @@ recovery scope, or a storage/recovery-owned secret source.
Patrol control anchor, must stay owned by their product surface and
must not be reused as recovery entry points or storage/recovery navigation
aliases.
25. Keep alert-side recovery drill-ins on that same embedded-owner route-state contract. When alert investigation surfaces such as resource-incident panels expose recovery follow-up links for TrueNAS or future API-backed platforms, they must route through an owning platform/runtime destination using canonical recovery query vocabulary instead of freezing alert-local recovery URLs, reviving the retired Recovery aggregate route, or introducing another provider-shaped recovery handoff vocabulary.
25. Keep alert-side recovery follow-up inside the owning platform tab. The resource-incident panel offers no recovery links today, and no shared recovery query vocabulary exists. A future alert-side recovery follow-up for TrueNAS or another API-backed platform must open the platform tab that owns those rows instead of freezing alert-local recovery URLs or reviving the retired Recovery aggregate route.
26. Keep VMware onboarding runtime and recovery semantics separate on that same adjacent platform-connections contract. When `internal/api/router.go`, `internal/api/router_routes_registration.go`, or `internal/api/vmware_handlers.go` evolve VMware connection CRUD, poller-owned `poll` / `observed` summary payloads, saved-test refresh, or observed datastore/VM snapshot visibility, storage and recovery may consume the resulting shared context but must not treat those onboarding/runtime payloads as canonical recovery artifacts, restore capability, or recovery-local control transport.
A successful VMware connection test with `degraded` optional signal or
performance diagnostics remains connection-readiness context only; storage
@@ -2553,27 +2552,11 @@ take a correctness dependency on its contents.
6. Letting whitespace-padded storage route params hydrate non-canonical page state; shared storage URLs must trim and normalize `tab`, `source`, `status`, `node`, `group`, `sort`, `order`, `query`, and deep-link `resource` before the page model consumes them so pasted or hand-edited links resolve to the same canonical state as UI-authored routes without dropping adjacent unmanaged params
7. Letting storage `source` aliases or case drift survive in canonical route state; shared storage URLs must rewrite pasted values like `PVE`, `pbs`, or `ALL` to the owned source option values (for example `proxmox-pve`) or the canonical unset state so copied links match the same source filter values the storage toolbar presents
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 platform values survive in route or transport state; shared recovery URLs must collapse unsupported or fake `platform` values back to the canonical unset state, and only owned source-platform options or canonical legacy aliases may reach rollups, points, series, and facets transport filters
11c. Letting route-owned recovery platform selections disappear while filter options are still hydrating; the recovery page state owner must keep the current canonical `platform` query value present in the platform option set until transport-backed facets and records arrive so shared filter selects keep the user-visible TrueNAS or other owned platform selection instead of flashing back to `All platforms`
11d. Letting recovery filter default labels drift between protected inventory and recovery events; both recovery filter surfaces must consume the shared `recoveryTablePresentation` labels (`All item types`, `All platforms`, and `Any item`) instead of hard-coding title-case local variants.
11a. Letting adjacent workload route-state changes in shared `frontend-modern/src/routing/resourceLinks.ts` perturb recovery parse/build semantics; expanding canonical workload platform scoping must not alter the owned recovery `platform` and `itemType` vocabulary, legacy alias rewrites, or recovery drill-down workspace selection
11b. Letting adjacent storage route-state additions in shared `frontend-modern/src/routing/resourceLinks.ts` perturb recovery route semantics; expanding canonical storage deep links for unified resources must not reuse recovery-owned query names or alter the owned recovery parse/build contract while those surfaces continue sharing the same route-helper module
11e. Letting adjacent platform-route additions in shared `frontend-modern/src/routing/resourceLinks.ts` perturb storage or recovery route semantics; adding canonical `/standalone/machines`, `/standalone/availability`, or other platform paths must not reuse storage/recovery query names, alter storage or recovery parse/build behavior, or convert agent-platform membership into storage/recovery ownership
11f. Letting adjacent Patrol control starter route state in shared `frontend-modern/src/routing/resourceLinks.ts` perturb storage or recovery route semantics; `patrolControlStarter=patrol_control` is a first-party Patrol control handoff flag only, legacy `operationsLoopStarter=patrol_control`, `operationsLoopStarter=patrol_autonomy`, and `operationsLoopStarter=pulse_pro_activation` are only compatibility aliases, and storage/recovery parse-build contracts must not consume any of them as recovery state, storage focus, platform scope, or proof of backup/recovery posture
11g. Letting adjacent Pulse Intelligence external-agent setup anchors in shared `frontend-modern/src/routing/resourceLinks.ts` perturb storage or recovery route semantics; `/settings/pulse-intelligence/assistant#external-agent-setup` is the canonical external-agent setup hash, `/settings/security/api#external-agent-setup` and `/settings/security/api#pulse-mcp-setup` are legacy compatibility hashes, and storage/recovery parse-build contracts must not consume any of them as recovery state, storage focus, platform scope, or proof of backup/recovery posture
11h. Letting the Kubernetes platform page's route-local `cluster` scope perturb recovery's separate `cluster` filter merely because both constants live in shared `frontend-modern/src/routing/resourceLinks.ts`; `/kubernetes` may serialize `cluster=<clusterId>` for native inventory scoping, while `/recovery` continues to normalize and transport its own protected-item cluster vocabulary through the recovery parser/builders, with neither surface importing the other's option set or state owner
12. Letting protected-inventory protection posture overload recovery-event outcome filtering; the protected inventory protection-state control must drive the route-backed `state` field and local rollup posture filtering, while the recovery events `status` field remains the canonical outcome filter for points, series, and facets transport filters
13. Letting visible protected-item filters fall out of shared recovery links; protected inventory state such as stale, failed, warning, running, unknown, healthy, and never-succeeded must restore from the canonical recovery URL and rewrite to the owned `state=<value>` route form, with legacy `stale=1` accepted only as compatibility input
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 recovery events as the default workspace and protection coverage as an explicit secondary review so Pulse does not collapse back into a single-platform backup screen
15. Letting secondary recovery workspace state drift out of canonical route state; explicit `view=inventory` protection-coverage links must round-trip, while default recovery-events state should serialize without a redundant `view=events` query unless compatibility input is being normalized
16. Treating a selected protected-item rollup as row-click-only or header-only state instead of a canonical history filter; when a protected-item row focuses recovery history, the governed recovery events controls must surface that focus inside the shared filter surface through the same user-creatable item filter control, count it with the rest of the active filters, and let the same filter reset path clear it
17. Letting recovery-event focus leak as hidden state on the protection coverage surface; opening coverage must clear event-only `rollupId` and `day` context, and coverage drill-ins must open recovery events without preserving an invisible day filter that can make valid history look empty.
18. Letting the recovery details panel lead with transport-shaped payloads; operator-facing details must summarize outcome, artifact, target, restore readiness, and readable metadata labels first, while raw JSON and provider-specific keys stay behind an explicitly technical disclosure.
19. Letting protection coverage navigation bypass the canonical recovery workspace and route-state owner; coverage actions may focus stale inventory, attention inventory, or all protected items, but they must not mutate local-only filter state or revive passive posture-counter cards as the navigation owner.
20. Letting storage or recovery surfaces invoke retired self-hosted trial acquisition; `POST /api/license/trial/start` and `/auth/trial-activate` must stay closed on the ordinary self-hosted router, and storage/recovery-adjacent billing or support handoffs must not treat trial activation as recovery identity, restore proof, or backup transport state.
21. Treating an `AvailabilityData` facet on a storage resource as backup, protection, or recovery evidence; an agentless probe attached to a NAS, PBS, or datastore resource is monitoring reachability, not backup success, snapshot health, or restore readiness. Storage and recovery must read protection status from the canonical backup and storage resources, not from the availability probe facet.
9. Reintroducing an aggregate cross-platform Recovery page, a recovery route-state owner, or a shared recovery query serializer in `frontend-modern/src/routing/resourceLinks.ts` without a governed product decision. The aggregate page (`frontend-modern/src/components/Recovery/` with its state owner `frontend-modern/src/features/recovery/useRecoverySurfaceState.ts`) was deleted on 2026-05-26, and `RECOVERY_QUERY_PARAMS`, `parseRecoveryLinkSearch`, and `buildRecoveryRouteSearch` were deleted once nothing read them. Recovery rows render in the platform-owned Proxmox Backups and TrueNAS Protection tabs; the Proxmox Backups tab owns its route state through the `/proxmox/backups/:view` segment and `PROXMOX_BACKUPS_QUERY_PARAMS`, and the TrueNAS Protection tab keeps its search and status filter in component state and its sort in local storage, outside the URL.
10. Letting adjacent route state in shared `frontend-modern/src/routing/resourceLinks.ts` reuse storage or Proxmox Backups query names or alter their parse/build behavior. Workload platform scoping, unified-resource storage deep links, `buildStandalonePath()` platform paths, the Kubernetes page's route-local `KUBERNETES_QUERY_PARAMS.cluster` scope, the Patrol control starter (`patrolControlStarter=patrol_control`, with legacy `operationsLoopStarter=patrol_control`, `operationsLoopStarter=patrol_autonomy`, and `operationsLoopStarter=pulse_pro_activation` as compatibility aliases only), and the Pulse Intelligence external-agent setup anchors (`/settings/pulse-intelligence/assistant#external-agent-setup`, with `/settings/security/api#external-agent-setup` and `/settings/security/api#pulse-mcp-setup` as legacy compatibility hashes) share that module, and none of them is recovery state, storage focus, platform scope, or proof of backup or recovery posture.
11. Letting recovery row details on the Proxmox Backups or TrueNAS Protection tabs lead with transport-shaped payloads; operator-facing details must summarize outcome, artifact, target, and readable metadata labels first, and raw JSON or provider-specific keys must not lead the primary detail.
12. Letting storage or recovery surfaces invoke retired self-hosted trial acquisition; `POST /api/license/trial/start` and `/auth/trial-activate` must stay closed on the ordinary self-hosted router, and storage/recovery-adjacent billing or support handoffs must not treat trial activation as recovery identity, restore proof, or backup transport state.
13. Treating an `AvailabilityData` facet on a storage resource as backup, protection, or recovery evidence; an agentless probe attached to a NAS, PBS, or datastore resource is monitoring reachability, not backup success, snapshot health, or restore readiness. Storage and recovery must read protection status from the canonical backup and storage resources, not from the availability probe facet.
## Completion Obligations
@@ -2715,7 +2698,7 @@ canonical pre-handler authorization proof covering both transfer routes.
is likewise API/AI-owned transport: storage and recovery may consume the
resulting incident context, but must not define storage-local approval
argument keys or bypass the shared `internal/agentcapabilities` helper.
5. Keep recovery history table width budgeting derived from the canonical column specs in `frontend-modern/src/utils/recoveryTablePresentation.ts`, not from raw visible-column counts, so normalized subject labels and optional column sets cannot drift the right-edge badges and controls off-screen. Storage-pool, physical-disk, backup-server, coverage, and recoverable-artifact compact models must follow the same source-owned budgeting rule: expose three to five semantically distinct phone fields, allow a sixth only when measured content remains legible, fix identity at the shared 30-percent track, use explicit compact labels for narrow evidence fields, and normalize the selected tracks to the container without a horizontal rail. A status already conveyed by the identity indicator must not consume a duplicate phone column. Desktop column sets and storage/recovery evidence semantics must remain unchanged.
5. Keep storage and recovery table width budgeting source-owned rather than derived from raw visible-column counts, so long item labels and optional column sets cannot drift the right-edge badges and controls off-screen. Storage-pool, physical-disk, backup-server, coverage, and recoverable-artifact compact models must expose three to five semantically distinct phone fields, allow a sixth only when measured content remains legible, fix identity at the shared 30-percent track, use explicit compact labels for narrow evidence fields, and normalize the selected tracks to the container without a horizontal rail. A status already conveyed by the identity indicator must not consume a duplicate phone column. Desktop column sets and storage/recovery evidence semantics must remain unchanged.
Below 360 pixels of content width, those models must promote identity to 40
percent and may demote the lowest-priority field through the shared
`platform-table-narrow-hidden`/source-owned layout contract rather than
@@ -2894,24 +2877,33 @@ canonical pre-handler authorization proof covering both transfer routes.
Touch users still must not inherit
synthetic hover branches, and storage must not keep a special trailing
expand column once the shared leading action contract exists.
Static subgroup header emphasis for storage group rows and recovery history
day headers must also route through
`frontend-modern/src/components/shared/groupedTableRowPresentation.ts` and
the shared `.grouped-table-row` CSS contract in `frontend-modern/src/index.css`,
rather than storage- or recovery-local background classes or left-accent
marker variants.
Static subgroup header emphasis for storage group rows must also route
through `frontend-modern/src/components/shared/groupedTableRowPresentation.ts`
and the shared `.grouped-table-row` CSS contract in
`frontend-modern/src/index.css`, rather than storage-local background
classes or left-accent marker variants. The recovery history day headers
this rule also named went with the aggregate Recovery page; the Proxmox
Backups date feed's day dividers (`ProxmoxRecoverableTable.tsx`) style
their cell directly because a cell-level background renders reliably
across table layout engines.
Storage pool rows must also keep sizing and alert accents on canonical
class/data-attribute presentation rather than row-local inline style maps,
so the public storage page stays CSP-safe under both normal and
alert-highlighted demo/runtime states.
21. Keep recovery transport refreshes inside the recovery-owned feature state.
`frontend-modern/src/features/recovery/useRecoverySurfaceState.ts` and the
recovery data hooks may retain the last fulfilled rollups, points, facets,
and series while the next request is in flight through
the shared `frontend-modern/src/hooks/createNonSuspendingQuery.ts`, but
that retained-value behavior must stay route-owned and filter-owned through
the canonical recovery state model instead of recreating page-local
suspense escape hatches in `Recovery.tsx` or the recovery sections.
21. Keep recovery transport refreshes on the shared non-suspending query.
Every recovery read retains its last fulfilled response while the next
request is in flight through
`frontend-modern/src/hooks/createNonSuspendingQuery.ts`:
`frontend-modern/src/hooks/useRecoveryPoints.ts` (the TrueNAS Protection
tab's points) and `frontend-modern/src/hooks/useProtectionPostures.ts`
poll every 30 seconds, and `ProxmoxBackupsTable.tsx` reads
`/api/backups/pve` and `/api/backups/pbs` through the same primitive.
The points and posture reads stay keyed by the query or resource IDs
their tab passes, and the PVE and PBS inventory reads by fixed source
keys, instead of recreating page-local suspense escape hatches or a second
refetch state in the platform tables. The aggregate Recovery page's state owner, which
this item used to name, was deleted on 2026-05-26 with the rollup, facet,
and series hooks.
22. Keep storage/recovery-adjacent resource metadata on the shared unified
resource contract. When canonical storage resources expose provider-backed
identity such as Proxmox storage `pool`, storage and recovery consumers
@@ -3487,16 +3479,20 @@ Future cross-surface storage or recovery affordances must compose against
the embedded `StorageSurface` / `RecoverySurface` consumers rather than
reintroducing top-level URL builders.
The remaining storage and recovery route builders in
`frontend-modern/src/routing/resourceLinks.ts` are query-state serializers, not
destination builders: callers must append `buildStorageRouteSearch()` or
`buildRecoveryRouteSearch()` to the current platform-owned pathname such as
`/proxmox/storage`, `/proxmox/backups`, or `/truenas/protection`. They must not
emit `/storage` or `/recovery` as hidden compatibility paths.
`frontend-modern/src/routing/resourceLinks.ts` come down to one query-state
serializer, not a destination builder: `buildStorageRouteSearch()` is appended
to the current platform-owned pathname, today only by the Storage surface's
own filter state on the Proxmox Storage tab, and must not emit `/storage` as a
hidden compatibility path. The recovery serializer (`RECOVERY_QUERY_PARAMS`,
`parseRecoveryLinkSearch`, `buildRecoveryRouteSearch`) was deleted once
nothing read it: the Proxmox Backups tab keeps its view in the
`/proxmox/backups/:view` segment and its filters in
`PROXMOX_BACKUPS_QUERY_PARAMS`, and nothing emits `/recovery`.
Adjacent route-query constants for other platform surfaces, including
`STANDALONE_QUERY_PARAMS`, must remain namespaced and must not be reused as
storage source, node, resource, workspace, rollup, or day state. Adding a
shareable filter query to another surface must not broaden or reinterpret the
storage and recovery serializer contract.
storage serializer contract or the Proxmox Backups query.
Storage and Recovery can now be embedded by a platform page in table-only mode
with a forced platform source/filter. Proxmox uses that embedding for
@@ -3739,11 +3735,15 @@ Storage physical-disk requirements copy now consumes the shared
Infrastructure target label. Disk-health guidance may refer to Proxmox node
requirements, but it must not revive removed nested Pulse settings paths such
as `Settings → Infrastructure → Proxmox`.
Recovery item-type labels now route through
`frontend-modern/src/utils/recoveryItemTypePresentation.ts`. Recovery surfaces
must not render a bare `Cluster` item type for Kubernetes protected subjects;
use the canonical `K8s Cluster` label so protected inventory and event filters
do not confuse Kubernetes clusters with Proxmox clusters or table grouping.
The cross-platform recovery item-type columns and filters went with the
aggregate Recovery page on 2026-05-26, and
`frontend-modern/src/utils/recoveryItemTypePresentation.ts` was deleted once
its last reader, the recovery query serializer, was. The Proxmox Backups
tables label workload types (`VM`, `LXC`, `Host`, or `Guest`) from
`frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts`. A future
cross-platform recovery item-type label must not render a bare `Cluster` for
Kubernetes protected items; it uses `K8s Cluster` so Kubernetes clusters are
not confused with Proxmox clusters or table grouping.
This subsystem now sits under the dedicated storage and recovery lane so the
operator-facing storage page, recovery timeline, and recovery-point persistence
@@ -4321,12 +4321,8 @@ controls, so those headings must not duplicate that interaction with buttons,
their real button and `aria-sort` semantics, but their visible direction marker
must come from the frontend-primitives-owned `tableSortPresentation.ts` helper;
only the active backup sort column displays its direction.
Recovery all-history, all-item-type, and all-platform defaults follow the same
shared filter-option contract through
`frontend-modern/src/utils/recoveryTablePresentation.ts`, so recovery history
and protected-item tables do not invent separate default-filter wording.
Physical-disk role and group filter defaults plus disk-type display labels
must likewise come from `frontend-modern/src/features/storageBackups/diskPresentation.ts`;
must come from `frontend-modern/src/features/storageBackups/diskPresentation.ts`;
storage pages must not reintroduce local `All Roles`, `All Groups`, or
`NVME Disk` strings that drift away from the shared filter-label and hardware
acronym presentation contract.
@@ -4396,9 +4392,9 @@ That same recovery boundary now also assumes mock recovery context is projected
from one canonical mock graph. `internal/mock/recovery_points.go` may synthesize
inventory-only recovery artifacts for supported mock platforms, but those
subjects must derive from the shared `internal/mock/fixture_graph.go` owner
instead of a separate hardcoded recovery cache, so recovery filters, rollups,
and shared route handoffs see the same platform set as settings and
infrastructure.
instead of a separate hardcoded recovery cache, so recovery points, rollups,
and the platform tabs that read them see the same platform set as settings
and infrastructure.
That same graph-owned mock boundary also owns demo-readiness for storage and
recovery surfaces. Mock seeded history and provider-backed storage/recovery
counts must come from the same canonical fixture graph so
@@ -4485,92 +4481,39 @@ That same shared helper layer also now assumes the Pulse Mobile relay runtime
credential reaches only the explicit backend-owned route inventory, so
storage- and recovery-adjacent transport work cannot accidentally widen that
credential into a broader AI access bundle by touching neighboring routes.
The recovery frontend now also separates that ownership more explicitly:
`frontend-modern/src/features/recovery/useRecoverySurfaceState.ts` owns
canonical route parsing, filter/query state, transport hook inputs, and URL
synchronization, while `frontend-modern/src/components/Recovery/Recovery.tsx`
is the composition root for the operator-facing recovery surface and the split
section owners under `frontend-modern/src/components/Recovery/` hold the
protection coverage, activity, and history presentation layers. The history
surface is further split so `RecoveryHistorySection.tsx` owns the toolbar and
controller boundary, `useRecoveryHistorySectionState.ts` owns local section UI
state, and `RecoveryHistoryTable.tsx` owns the row/detail renderer.
That composition root now also owns one primary recovery workspace rather than
stacking protection-coverage and event-history tables on the same desktop page.
The governed default is event-first so operators land on concrete
backup/snapshot/replication history, while the header action and compatibility
`view=inventory` links open the secondary protection coverage review.
That same operator-facing workspace must lead with current protection status
rather than only the latest backup outcome. Protection coverage should surface
stale, never-succeeded, failed, warning, and running rollups as the primary
monitoring status so an item with an old successful point does not scan as
healthy when it still needs operator attention.
That same workspace contract also keeps Pulse's provider-neutral recovery model
explicit in the page language: recovery sections should talk about protected
items, recovery events, and latest points so PBS backups, TrueNAS snapshots,
Kubernetes artifacts, and future providers all fit the same first-class UI
frame without removing the source badges and row-level cues that make Proxmox
operators productive.
No aggregate Recovery page exists. Its composition root
`frontend-modern/src/components/Recovery/Recovery.tsx`, the section owners
beside it (`RecoveryActivitySection.tsx`, `RecoveryHistorySection.tsx`,
`RecoveryHistoryTable.tsx`, `RecoveryProtectedInventorySection.tsx`,
`RecoveryPointDetails.tsx`, `useRecoveryHistorySectionState.ts`), and its
route-state owner `frontend-modern/src/features/recovery/useRecoverySurfaceState.ts`
were deleted on 2026-05-26, and the event-first workspace, the secondary
protection-coverage review, and the `view=inventory` / `view=events` route
state went with them. Recovery rows render only in platform-owned tabs: the
Proxmox Backups tab, whose `/proxmox/backups/date` and
`/proxmox/backups/coverage` views are described under "Proxmox backup views
use canonical workflow routes", and the TrueNAS Protection tab, whose
`TrueNASProtectionTable.tsx` lists the points `useRecoveryPoints` returns with
the `truenas` platform forced. Bringing back a cross-platform recovery page is
a product decision that needs its own governed contract, not an extension of
either tab.
License recovery stays reachable through the direct Plans & Billing route.
`SelfHostedCommercialRecoverySection` inside `ProLicensePanel.tsx` remains
available for explicit activation and recovery even when the ordinary free
self-hosted navigation hides commercial surfaces. Recovery flows must not
acquire proactive commercial prompts of their own.
Operator-facing filter and detail labels should likewise prefer `platform`
wording over implementation-facing `provider` wording, so the recovery surface
describes the monitored platform families Pulse covers rather than exposing
backend transport vocabulary as the primary UI model.
That same operator-facing vocabulary should also prefer `item` over backend
`subject` wording, and `platform` over generic `source` wording, across the
primary recovery headers, tables, filter controls, 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. 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.
Shared recovery link builders should therefore accept canonical `platform`
inputs only; legacy `provider` belongs at parse-time compatibility boundaries,
not in new caller-facing recovery route helpers.
Cross-surface recovery drill-in links must also target the correct primary
workspace without relying on legacy inventory-first defaults. When a platform
service surface such as PBS links into recovery activity, that shared entry
point should land on the default recovery events workspace and describe the
destination as recovery events rather than reverting to PBS-backup wording.
Operator-facing recovery labels prefer `platform` over implementation-facing
`provider` and `item` over backend `subject`; the transport and data model may
keep subject/provider fields only as compatibility aliases. No shared recovery
query state exists any more (the Proxmox Backups tab's own query is its
`PROXMOX_BACKUPS_QUERY_PARAMS`), so a future cross-platform recovery filter or
link must treat `platform` as its canonical query field and accept legacy
`provider` only as parse-time compatibility input.
That same recovery contract should keep response payloads canonical as well:
recovery points and protected rollups should expose `platform` and
`platforms` as the primary transport fields, while any legacy
`provider` / `providers` aliases stay compatibility-only so the page does not
silently drift back to backend-shaped vocabulary during decode.
That same shared recovery table contract should keep its runtime column model
canonical as well. Recovery inventory and event-history columns should use
`item` and `platform` identities rather than preserving `subject` and `source`
as the primary runtime model, and any saved legacy column IDs must migrate at
the shared column-visibility boundary instead of forcing recovery renderers to
carry deleted column identities indefinitely. Once that migration exists,
recovery tables and shared table presenters should not continue accepting
legacy `subject` and `source` ids in the live runtime path.
That same runtime-helper contract should prefer `item` terminology in shared
recovery presenters too. Helper exports that resolve labels or item-type badges
should expose canonical item-facing names, while any retained `subject` aliases
remain compatibility wrappers instead of the primary runtime boundary.
That same shared badge contract applies to table rendering too. Recovery item
type cells should use the same compact monitoring-table badge base that
workloads uses for `VM` and `Container`, rather than copying only the colors
and drifting on padding or visual weight.
The same rule applies inside recovery-owned helpers and selectors. Shared
summary helpers and platform filter renderers should use canonical `item` and
`platform` naming internally once compatibility boundaries already exist,
rather than keeping fresh `subject` or `provider` terminology alive in the
live recovery runtime path.
The same runtime vocabulary rule applies to cross-section recovery props too.
Live page-to-section boundaries should carry item-focused names like
`selectedHistoryItemLabel` instead of preserving `subject` labels after the
shared recovery presenters already expose canonical item terminology.
That same rule applies to recovery detail helpers. Provider-specific helper
names like `isPbsProvider` should become platform-specific helpers like
`isPbsPlatform` once the runtime recovery model is already canonically
platform-first.
`provider` / `providers` aliases stay compatibility-only so frontend decode
does not silently drift back to backend-shaped vocabulary.
The same canonical boundary applies to linked-resource identifiers. Recovery
API payloads, query filters, and normalized frontend runtime models should use
`itemResourceId` as the canonical field while accepting or emitting
@@ -4579,11 +4522,6 @@ That same canonical boundary also applies to external item references. Recovery
API payloads and normalized frontend runtime models should use `itemRef` as
the canonical item-reference field while treating `subjectRef` only as a
compatibility alias during the transition.
That same presenter boundary should also own canonical item-type derivation.
Recovery surfaces must resolve rollup and point item types through one shared
item-type helper instead of repeating `display.itemType` / `subjectType` /
`subjectRef.type` fallback chains across state, summary, details, and table
renderers.
That same recovery-store decode boundary must fail soft on malformed persisted
metadata. If a stored recovery row contains bad `subject_ref_json`,
`repository_ref_json`, or `details_json`, the list endpoints should log and
@@ -4633,60 +4571,33 @@ derivation — sweeping protection-posture rows stranded under retired
subject keys — so a live migration or a canonical-ID era change never forks
one guest's protected item into stale and fresh rows. Rows without a
recomputable guest identity keep their stored keys unchanged.
That same hook-boundary normalization also owns the runtime recovery display
model. Canonical recovery points and rollups must expose `display.itemLabel`
and `display.itemType` to recovery consumers, while legacy transport fields
such as `subjectLabel` and `subjectType` remain decode-only compatibility
aliases in the shared normalization layer instead of leaking into runtime
presenters.
The frontend decode boundary owns the runtime recovery display model:
`frontend-modern/src/utils/recoveryPlatformModel.ts`, which
`frontend-modern/src/hooks/useRecoveryPoints.ts` applies to every response,
exposes `display.itemLabel` and `display.itemType` to recovery consumers, while
legacy transport fields such as `subjectLabel` and `subjectType` remain
decode-only compatibility aliases in that normalization layer instead of
leaking into tables.
That same canonical item-label boundary must prefer recognizable protected-item
names over raw entity IDs. When unresolved Proxmox-backed recovery points only
have a VMID/CTID in the subject ref but still carry a richer backup comment or
notes label, the canonical recovery index and store backfill must promote that
human-readable label into the persisted subject/item label instead of leaving
protected inventory rows to lead with bare numeric IDs.
That same operator-facing row-identity rule should still preserve the governed
entity identifier as secondary context when it exists. Recovery inventory and
event rows should lead with the canonical item name, then show a muted
secondary compact `VMID`/`CTID`/`ID` cue when `display.entityIdLabel` is
available, so operators can disambiguate familiar names without turning the
primary scan path back into raw numeric identifiers or bloating the table with
an extra recovery-only identity row.
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
`Snapshots`, `Local Copies`, and `Remote Copies`, while event rows, filters,
and point details must use the singular operator-facing forms `Snapshot`,
`Local Copy`, and `Remote Copy`. Recovery point detail summaries must also
humanize backend fields like kind, mode, outcome, and boolean state into
operator-facing labels such as `Point Type`, `Method`, `Outcome`, `Verified`,
and `Encrypted` instead of leaking raw transport values like `backup`,
`remote`, or lowercase outcome tokens into the primary drawer surface.
That primary workspace selection now also lives in canonical recovery route
state through `frontend-modern/src/routing/resourceLinks.ts` and
`frontend-modern/src/features/recovery/useRecoverySurfaceState.ts`, so copied
links and browser restores reopen explicit protection-coverage state instead
of silently falling back to page-local UI state. Focused recovery routes with
an active `rollupId` or `day` remain recovery-events-first by default, and
default event routes should omit redundant `view=events` query state.
That same shared route-helper contract now also has to preserve exact storage
and recovery handoffs for unified resources discovered outside the storage or
recovery pages. When alerts, Patrol, or infrastructure drawers route a
TrueNAS-backed disk, app, or system into an owning platform/runtime surface,
the shared helper must keep the owned `source`, `node`, `platform`, `view`,
and exact `resource` semantics intact instead of collapsing those handoffs
back to provider-local URLs, retired aggregate workspace routes, or generic
top-level tabs.
That history table layout now also derives its minimum width from the same
canonical column-width spec that owns the header sizing in
`frontend-modern/src/utils/recoveryTablePresentation.ts`, so longer governed
subject labels do not force the trailing outcome/status columns off-screen by
budget drift.
That same recovery product proof surface now also includes a browser-level
desktop layout guard in `tests/integration/tests/17-recovery-layout.spec.ts`,
which opens the recovery page against deterministic recovery payloads and
fails when the history table needs horizontal scrolling or lets the outcome
column drift outside the visible wrapper at desktop width.
Alerts, Patrol, and resource drawers hand no resources into storage or
recovery surfaces today: their cross-jump chips were retired on 2026-05-16,
and `buildStorageRouteSearch()` is read only by the Storage surface's own
filter state. A future handoff of a TrueNAS-backed disk, app, or system into
an owning platform tab must keep the owned `source`, `node`, `platform`,
`view`, and exact `resource` semantics instead of collapsing to provider-local
URLs, retired aggregate workspace routes, or generic top-level tabs.
The recovery product proof surface's browser-level layout guard is
`tests/integration/tests/17-proxmox-backups-layout.spec.ts`. Against the
mock-mode dataset it checks that selecting an activity day keeps the operator
on `/proxmox/backups` with an `N of M backups` count, and that a one-year
activity range keeps the page inside the horizontal viewport with the PBS
servers table's trailing column inside its wrapper; the activity chart may
scroll inside its own card.
That same shared `internal/api/` dependency now also assumes tenant-scoped
resource handlers seed registries from canonical unified resources only:
recovery- and storage-adjacent API helpers may not fall back to raw tenant
@@ -4872,383 +4783,72 @@ The storage page's readiness now stays route-owned as well:
loading, reconnect, and disconnect presentation from the storage unified-resource
fetch contract before consulting websocket churn so the storage surface does
not present healthy REST-backed data as down or stale.
Meanwhile,
`frontend-modern/src/components/Recovery/` and the recovery hooks define the
event timeline, protection-coverage review, and recovery-history UX. The
governed page frame is event-first: operators land directly on concrete
backup/snapshot/replication history, with `RecoveryActivitySection.tsx` acting
as compact orientation for the visible event table. The former decorative
recovery summary-card strip is retired because workloads already owns the
object-level "has a backup" scan, while Recovery owns concrete recovery events
and an explicit secondary protection-coverage review.
That top recovery frame must rely on solid elevated operator panels and border
hierarchy rather than decorative gradients so the page reads like a monitoring
workspace instead of a marketing-style dashboard shell. The page must stay
compact enough to keep the activity chart and primary event table in the first
scroll window instead of stacking dashboard-like slabs above the work surface.
Protection coverage remains a secondary review, opened through the explicit
header action or canonical compatibility route state when the operator needs to
audit stale, failed, warning, running, unknown, or never-succeeded items. It
must expose a reciprocal page-header action back to Recovery events, so opening
coverage never leaves the operator dependent on the global Recovery nav item or
small table chrome to return to the primary page. It must not return as an
equal-weight subtab or a top-level posture-card action strip without a separate
governed product decision.
Recovery is also intentionally outside the interactive page/group/entity
summary-scope contract that the workloads and storage tables use. The recovery
route must not adopt `summaryCardInteraction.ts`, `summaryTableFocus.ts`,
row-driven summary scope, or a summary card strip simply because adjacent
monitoring pages use those primitives. Recovery may still show
coverage breadth and platform context inside the Protection coverage table and
event filters, but the page must read item-first so the unified recovery model
is not visually anchored to one platform family.
That same item-first rule also applies to the protection coverage table:
`RecoveryProtectedInventorySection.tsx` must surface protected item type as a
first-class column in the main inventory grid rather than leaving platform as
the only structural classifier beside the item label. Platform badges remain
important supporting operator context, especially for Proxmox-heavy fleets,
but the table frame itself must make protected item class explicit.
That same inventory contract must keep the protected-items grid operationally
bounded. The governed desktop recovery surface should not dump the entire
protected estate into one endless slab; it should page or otherwise bound the
primary inventory table so the workspace, filters, and adjacent activity panel
remain readable as one monitoring surface instead of dissolving into a raw list
dump.
That same protected-inventory surface should carry compact operator orientation
inside the table shell itself. `RecoveryProtectedInventorySection.tsx` should
expose the current bounded range, page, and sort state near the primary grid,
and the first column should carry enough secondary item metadata to read as a
monitored inventory row rather than a bare export line.
That same hierarchy rule also applies to the activity timeline. The governed
recovery surface should not append `RecoveryActivitySection.tsx` underneath the
default protected-items view as if trend telemetry were a second page bolted
onto inventory. The timeline owns recovery-event day selection, so it belongs
inside the `Recovery events` workspace and should read as history analysis for
the selected window rather than as a second copy of the page-level posture
summary.
The same owned vocabulary applies to recovery events as well:
`frontend-modern/src/utils/recoveryTablePresentation.ts` must keep the
history-table `type` column labeled as `Item Type` within recovery surfaces so
event history does not fall back to a generic shared `Type` header once the
recovery lane has already established item-first operator vocabulary.
That same item-first vocabulary must carry through the point-details drawer:
when a recovery point includes canonical item-class metadata,
`RecoveryPointDetails.tsx` must surface it as `Item Type` in the summary grid
instead of jumping directly from item identity to platform and point-method
metadata.
That same shared presentation layer also owns recovery placement vocabulary.
Cluster, node, and namespace facets remain valid supporting filters for
Proxmox-heavy and Kubernetes-heavy operators, but the governed recovery
surface must present them through platform-neutral labels such as
`Cluster / Site`, `Host / Agent`, and `Namespace / Group` across advanced
filters, active chips, table headers, column-picker entries, and point
details so the page treats placement as optional context inside a
multi-platform recovery model rather than a Proxmox-native spine. When
normalized display labels are present, the visible history rows must prefer
those labels over raw transport values for the same placement dimensions.
The recovery table presentation helper now owns the canonical subject-type
label fallback for recovery rows and delegates its title-casing to the shared
`frontend-modern/src/utils/textPresentation.ts` helper rather than keeping a
local recovery-only formatter, so subject and outcome labels stay aligned with
the shared frontend label contract. Protected-inventory and recovery-event
filters, table headers, and column-picker labels must use that helper for
artifact fields such as `Item Type`, so the recovery tabs do not drift into
near-identical page-local casing.
That same recovery drill-in surface now also keeps provider-specific metadata
inside a provider-neutral detail shell through
`frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx`, so PBS
datastore and verification enrichments remain available without presenting the
details drawer as a PBS-only surface.
The point-details drawer also owns restore-safe operator guidance. It may
surface restore readiness, verification provenance, and chain coverage for the
selected point, but it must stay read-side until the backend exposes a governed
restore execution contract. The drawer must not present a freestanding restore
runbook or next-action path that reads as an approved restore workflow; target
confirmation and isolated test-restore planning belong in a future governed
action or restore flow, not in the evidence drawer. Chain context must be
derived from the current recovery result set only when at least two concrete
stages are visible, so mixed PVE/PBS/TrueNAS history can explain adjacent local
snapshot, local copy, and remote copy stages without filling the drawer with
missing-only cards. Raw transport IDs, provider refs, provider task IDs, and
raw JSON copy actions belong behind `Technical details`; the primary drawer
should keep human metadata, recorded verification provenance, target health,
and collapsed file lists without repeating the same verification fact in
provider-specific sections or rendering empty verifier/evidence placeholders
when no verification record exists. Verification provenance should translate
provider states such as PBS catalog `ok` into operator language instead of
surfacing raw transport status tokens. Container recovery points should present
container ids with operator vocabulary such as `CTID`, and duplicated placement
or target values should not be repeated under lower-priority metadata labels.
Provider-specific metadata must not recast the event drawer itself as if PBS
were the native recovery model. Provider-owned repository data should sit under
target-oriented wording such as `Target Details`, `Repository owner`, and
`Target Health`; when target-specific technical labels are surfaced, they
should prefer neutral wording such as `Target Ref` and `Target Resource`.
Those transport hooks are direct governed runtime surfaces, not just page
implementation detail: `frontend-modern/src/hooks/useRecoveryPoints.ts`,
`frontend-modern/src/hooks/useRecoveryPointsFacets.ts`,
`frontend-modern/src/hooks/useRecoveryPointsSeries.ts`, and
`frontend-modern/src/hooks/useRecoveryRollups.ts` must stay on the explicit
`recovery-product-surface` proof path instead of inheriting release-control
coverage only through a retired standalone Recovery page shell.
Those same hooks now also own recovery transport normalization at the frontend
boundary: raw compatibility fields such as `provider` / `providers` may be
accepted from older `/api/recovery/*` payloads, but the runtime values they
return to the rest of the recovery UI must be canonical `platform` /
`platforms` models.
Recovery has no page frame of its own. The event timeline, protection-coverage
review, recovery-history table, point-details drawer, activity strip, and the
recovery table, filter-chip, status, record, action, issue, and empty-state
presenters that served them were deleted with the aggregate Recovery page on
2026-05-26 (its summary strip had gone on 2026-05-14), along with
`useRecoveryRollups.ts`,
`useRecoveryPointsFacets.ts`, and `useRecoveryPointsSeries.ts`. The rules that
governed that frame's layout, density, workspace order, and default columns
have no surface to bind. Recovery evidence now reads through two
platform-owned tabs built on the shared platform-page table primitives: the
Proxmox Backups tab (`ProxmoxBackupsTable.tsx` and its activity chart, date
feed, coverage, recoverable-artifact, and backup-server tables) and the
TrueNAS Protection tab (`TrueNASProtectionTable.tsx`). Neither adopts the
interactive summary-scope primitives (`summaryCardInteraction.ts`,
`summaryTableFocus.ts`) or a summary card strip.
Recovery row details on those tabs stay read-side until the backend exposes a
governed restore execution contract. They may show verification and target
facts for the selected row, but must not present a freestanding restore
runbook or next-action path that reads as an approved restore workflow.
The Proxmox Backups activity chart keeps long ranges contained:
`BackupActivityChart.tsx` sizes the plot through
`getRecoveryTimelineChartMinWidthPx` (2,560 pixels for a year) and scrolls
inside its own card, so the page itself never overflows horizontally;
`tests/integration/tests/17-proxmox-backups-layout.spec.ts` asserts that at
desktop width.
The recovery transport hooks are direct governed runtime surfaces, not page
implementation detail: `frontend-modern/src/hooks/useRecoveryPoints.ts` and
`frontend-modern/src/hooks/useProtectionPostures.ts` stay on the explicit
`recovery-product-surface` proof path, as do the shared recovery helpers that
survived the page: `frontend-modern/src/types/recovery.ts`,
`frontend-modern/src/utils/recoveryDatePresentation.ts`,
`frontend-modern/src/utils/recoveryOutcomePresentation.ts`,
`frontend-modern/src/utils/recoveryArtifactModePresentation.ts`,
`frontend-modern/src/utils/recoveryTimelinePresentation.ts`, and
`frontend-modern/src/utils/recoveryTimelineChartPresentation.ts`. They must not
inherit release-control coverage only through the platform tabs that render
them.
`useRecoveryPoints.ts` also owns recovery transport normalization at the
frontend boundary: raw compatibility fields such as `provider` may still
arrive from older `/api/recovery/points` payloads, but the points it returns
are canonical `platform` models.
The retired dashboard recovery and storage entry points must stay removed:
`useDashboardRecovery`, `DashboardRecoveryStatusPanel`,
`DashboardStoragePanel`, dashboard storage/recovery presentation helpers, and
dashboard widget orchestration must not return as direct proof surfaces. New
storage or recovery summary proof must attach to the owning Storage,
Recovery, Infrastructure drawer, or shared summary component instead of
borrowing coverage through a broader dashboard shell.
The shared recovery type contract must be pinned the same way:
`frontend-modern/src/types/recovery.ts` must stay on the explicit
`recovery-product-surface` proof path instead of riding indirectly on route or
component coverage.
That same direct proof rule applies to the shared recovery date helper:
`frontend-modern/src/utils/recoveryDatePresentation.ts` must stay on the
explicit `recovery-product-surface` proof path instead of inheriting coverage
only through pages or higher-level recovery components.
That same direct proof rule also applies to the shared recovery status helper:
`frontend-modern/src/utils/recoveryStatusPresentation.ts` must stay on the
explicit `recovery-product-surface` proof path instead of inheriting coverage
only through pages or higher-level recovery components.
The default protected-inventory recovery route must also keep its primary
table shell on class-driven sizing (`table-fixed` plus owned width classes)
instead of inline `table-layout` / `min-width` styles, so the public recovery
surface stays CSP-safe without drifting from the shared table contract.
That same direct proof rule also applies to the shared recovery record helper:
`frontend-modern/src/utils/recoveryRecordPresentation.ts` must stay on the
explicit `recovery-product-surface` proof path instead of inheriting coverage
only through pages or higher-level recovery components.
That shared recovery record contract now also includes rollup-side display
payload continuity: the recovery backend must preserve the latest normalized
subject label on rollups, and recovery UI helpers must prefer that canonical
display label before raw subject ids whenever the live unified-resource map is
missing or only resolves to opaque machine identifiers.
That same direct proof rule also applies to the shared recovery outcome helper:
`frontend-modern/src/utils/recoveryOutcomePresentation.ts` must stay on the
explicit `recovery-product-surface` proof path instead of inheriting coverage
only through pages or higher-level recovery components.
That same direct proof rule also applies to the shared recovery action helper:
`frontend-modern/src/utils/recoveryActionPresentation.ts` must stay on the
explicit `recovery-product-surface` proof path instead of inheriting coverage
only through pages or higher-level recovery components.
That same direct proof rule also applies to the shared recovery artifact mode
helper: `frontend-modern/src/utils/recoveryArtifactModePresentation.ts` must
stay on the explicit `recovery-product-surface` proof path instead of
inheriting coverage only through pages or higher-level recovery components.
That same direct proof rule also applies to the shared recovery empty-state
helper: `frontend-modern/src/utils/recoveryEmptyStatePresentation.ts` must stay
on the explicit `recovery-product-surface` proof path instead of inheriting
coverage only through pages or higher-level recovery components.
That same direct proof rule also applies to the shared recovery filter-chip
helper: `frontend-modern/src/utils/recoveryFilterChipPresentation.ts` must stay
on the explicit `recovery-product-surface` proof path instead of inheriting
coverage only through pages or higher-level recovery components.
That same direct proof rule also applies to the shared recovery issue helper:
`frontend-modern/src/utils/recoveryIssuePresentation.ts` must stay on the
explicit `recovery-product-surface` proof path instead of inheriting coverage
only through pages or higher-level recovery components.
That same direct proof rule also applies to the shared recovery table helper:
`frontend-modern/src/utils/recoveryTablePresentation.ts` must stay on the
explicit `recovery-product-surface` proof path instead of inheriting coverage
only through pages or higher-level recovery components.
That same direct proof rule also applies to the shared recovery timeline-chart
helper: `frontend-modern/src/utils/recoveryTimelineChartPresentation.ts` must
stay on the explicit `recovery-product-surface` proof path instead of
inheriting coverage only through pages or higher-level recovery components.
That same direct proof rule also applies to the shared recovery timeline
helper: `frontend-modern/src/utils/recoveryTimelinePresentation.ts` must stay
on the explicit `recovery-product-surface` proof path instead of inheriting
coverage only through pages or higher-level recovery components.
Those recovery transport surfaces now also share one normalized filter
contract: protection rollups, point history, facets, and chart series must
all honor the same canonical `platform`, canonical `itemType`, cluster, node, namespace,
workload-scope, verification, and route-backed free-text `q` filter so the
protection coverage list cannot drift from the timeline and facet state under the
same active recovery view. That same recovery filter contract now depends on
the canonical recovery index carrying a normalized `itemType` instead of
forcing each UI surface to re-derive protected item classes from raw
storage or recovery summary proof must attach to the owning Storage surface,
platform tab, or shared summary component instead of borrowing coverage
through a broader dashboard shell.
The recovery backend preserves rollup-side display payload continuity: it keeps
the latest normalized subject label on rollups, so a reader can prefer that
canonical display label over raw subject ids whenever the live
unified-resource map is missing or only resolves to opaque machine
identifiers.
The `/api/recovery/*` transports share one normalized filter contract:
protection rollups, point history, facets, and chart series all honor the same
canonical `platform`, canonical `itemType`, cluster, node, namespace,
workload-scope, verification, and free-text `q` filters, so any two of them
read under the same filters describe the same items. That filter contract
depends on the canonical recovery index carrying a normalized `itemType`
instead of forcing each reader to re-derive protected item classes from raw
provider-native `subjectType` values.
That same recovery product surface keeps the primary workspace visually ahead
of secondary analytics: `frontend-modern/src/components/Recovery/Recovery.tsx`
must lead directly into the route-backed recovery events workspace, with
recovery events owning both the activity timeline and the event table together.
Protection coverage opens only through the explicit header action or
compatibility route state. The activity timeline remains required even when
point-history loading fails, but it belongs to the events workspace rather than
hanging beside coverage as a competing page-level peer.
That same recovery product surface must not reintroduce a four-card posture,
freshness, coverage, or activity strip as decorative orientation. Workloads is
the better object-level place to scan whether a resource has backup coverage;
Recovery should reserve its first viewport for concrete recovery history and
only expose coverage rollups when the operator asks for the secondary review.
That same page-level ownership applies to the recovery time window. The
canonical range selector now lives inside `RecoveryActivitySection.tsx` because
it controls the event timeline and the event query window, not a page-level KPI
strip. Range changes must clear selected-day focus and return the events table
to page one through the route-state owner so the chart and table remain aligned.
That same activity-section contract should stay compact and scan-first:
recovery-point volume, active-day count, stale count, and average rate may
appear as concise inline readouts beside the chart controls, but not as separate
top-level metric cards or nested mini-panels. The activity strip must also avoid
interpreted anomaly callouts such as "lowest active day" unless the recovery
model has canonical schedule expectations that make the signal actionable.
Item-type labels should continue to render through canonical workload/resource
badge classes in the event and coverage tables instead of adding recovery-only
wrapper chrome around VM, container, or other resource badges.
That same event-first shell rule should hand straight from the activity section
into the active events workspace without an extra page-local spacer band,
default tab row, or duplicate status strip that makes the surface softer than
storage or workloads.
That same scan-first rule applies to the secondary coverage surface. Recovery
should not show equal workspace tab labels and then repeat the same workspace
count as standalone text; protection coverage cues should focus on issues,
drill-in context, and active filters.
That same coverage surface should also follow the established monitoring-table
scan pattern in its first column. Coverage rows should lead with a clear
status cue, the primary item name, and compact badge-backed item/platform
context instead of relying on recovery-only rails or plain-text metadata lines
that make the table read like a report instead of an operational grid.
That same triage rule applies to the coverage sort. Protection coverage should
open with attention-state rollups first instead of defaulting to
newest-successful backups, so operators land on failed, never-succeeded, stale,
warning, and running items before the healthy catalog.
That same row contract should avoid duplicating context that already has a
dedicated column. When `Item Type` and `Platform` columns are visible, the
primary item cell should not restate those same badges on desktop; duplicate
context belongs only as a small-screen fallback when those columns collapse.
That same scan rule applies to the supporting columns themselves. Recovery
tables should keep one dominant identity cue and one canonical platform cue.
`Item Type` should use the same shared workload/resource badge treatment that
other Pulse tables use for `VM` and `Container`, while `Method` and similar
supporting fields stay on restrained metadata text instead of turning every
adjacent column into another colored badge.
That same item-identity contract also applies to synthetic Proxmox task
recovery points. When the persisted subject label is just a raw
`pve-task:*`/`UPID:*` identifier or `vmid=0`, the canonical recovery index
should derive a readable task label and `task` item type from point details so
recovery tables scan by operator meaning instead of transport IDs.
That same coverage surface should stay on the flat monitoring-table pattern
already used elsewhere in Pulse. Protection coverage should surface posture through
row-level status cues, outcome pills, and filters rather than inserting extra
`Needs Attention` / `Healthy Coverage` section rows that add height and turn
the table into a recovery-only grouped report.
That same protection-coverage table should also avoid recovery-local pagination
chrome. The workspace already holds the filtered rollups client-side, so it
should read as one continuous monitoring table with a simple coverage-item
count instead of introducing `Prev` / `Next` buttons and page counters that do
not match the canonical Pulse scan pattern.
That same table-shell contract must avoid duplicate framing once the summary
action has established the secondary coverage workspace.
`RecoveryProtectedInventorySection.tsx` should keep page/count/sort
orientation inside a slim table-shell status row and let the filter strip lead
directly into the grid instead of reintroducing a second large inventory header
card above the same table.
That same shell rule should also avoid low-signal bookkeeping above the grid.
The protected-items status row should surface the active workspace, protected
item count, and issue cues, but page-number and sort-direction bookkeeping
belongs in the table chrome itself rather than competing with the primary scan
path before operators even reach the rows.
That same table density rule also applies to recovery table chrome and filter
rows. Recovery inventory and event tables should use the same restrained
title-case header typography, compact control heights, and thin row density as
the established Pulse monitoring tables instead of drifting into report-style
uppercase headers or oversized filter chrome.
That same protected-items table contract should stay on the canonical shared
table separator treatment used by the rest of Pulse. Recovery inventory should
use the standard shared header/body dividers and avoid both local suppression
of those separators and local duplicate row or header borders that make the
lines read heavier than other monitoring tables.
That same workspace-shell rule should also avoid a dedicated recovery-only
status strip above the control bar. Recovery should use an event-first handoff:
activity context, a shared controls card, then a data card. Protection coverage
may use its own shared controls card and table when explicitly opened, but
Recovery should not collapse controls and content back into one fused workspace
slab or bury secondary workspace navigation inside the filter row.
That same strip should not repeat page-level counts or posture cues as a
replacement for the retired summary strip. Protection coverage controls should
stay focused on drill-in context and active filters instead of echoing page-wide
posture pills above the same table.
That same workspace handoff should stay on shared primitive styling too.
Recovery events and protection coverage should keep using shared `FilterBar`,
`TableCard`, and `TableCardHeader` primitives instead of inventing a
recovery-only variant or recovery-only class stack.
That same canonical-row rule also means the subtabs row should stand on its own
full-width shell instead of sharing a flex line with recovery-only chips or
adjacent badges that break the storage-style border and spacing treatment.
That same shared page-controls contract applies to recovery search width too.
The protected-items and recovery-events workspaces should keep the search field
on the standard full-width shared search row, and any counts or utility cues
should live in the toolbar actions instead of narrowing the search row through
recovery-local grid overrides or width hacks. Protected-items controls should
also use the same shared `Reset all` page-controls action pattern as storage
and workloads when visible filters are active, instead of forcing operators to
clear each inventory filter manually.
That same handoff should keep Recovery free of a first-viewport summary card
strip unless a new governed product decision reintroduces a first-viewport
summary owner. The current route should spend its top-level density budget on
the activity section, compact controls, and one primary data card.
That same shell rule applies to the recovery-events workspace.
`RecoveryHistorySection.tsx` should use the same slim status-row-plus-filter-row
pattern as the protected inventory surface, not a separate large titled header
bar plus another full toolbar slab. Event filter labels should also stay on the
canonical short Pulse vocabulary like `Platform` and `Status` instead of
recovery-only variants such as `History platform` or `History status`. Both
recovery toolbars should also stay on compact shared select sizing instead of
inflating the row with recovery-local min-width overrides that make the
controls denser and wider than storage for the same amount of operator input.
That same events-workspace rule should keep the activity strip as orientation
for the event list rather than burying it at the bottom. The events workspace
should move from the subtabs row to `RecoveryActivitySection.tsx`, then shared
controls, then the recovery history table as sibling sections, so the timeline
frames the event list without turning the page back into stacked primary
tables or embedding the activity strip inside the history card.
That same events-shell contract should avoid repeating page-state bookkeeping
ahead of the history grid. Recovery events should keep the toolbar utility area
focused on actual controls like advanced filters and column visibility instead
of passive `day groups` narration; day grouping should stay legible through the
history surface itself, while current page and other table bookkeeping remain
in the table footer instead of competing with the scan path above the filters.
That same activity panel should stay compact and analytical rather than
becoming a second dashboard header. `RecoveryActivitySection.tsx` should keep a
single slim telemetry header, compact active-filter chips, a shorter chart
frame, reduced vertical insets, and a smaller legend footprint so the events
workspace hands off quickly from activity context to the history table instead
of spending a disproportionate slice of the screen on chart chrome. The range
picker and legend should share one compact control row, and the activity strip
should not burn a separate descriptive subtitle row once the headline metrics
already explain the chart context.
That same timeline contract must keep long-range activity fully constrained to
the card width. Extended ranges such as `365d` should compress their day
columns to fit the available plot width instead of carrying per-column minimum
widths that make the chart overflow its containing card.
That telemetry header should also avoid derivative pace rows once the chart
already carries the rhythm. Total points, active days, and issue cues can
stay, but average-per-day style readouts should not re-expand the strip into a
second mini report above the event table.
That same events-table contract should also keep the default column set on a
monitoring-style scan path rather than a report-export path. Recovery events
should default to the concise columns operators need to triage quickly, while
secondary fields such as verification, size, target, and details remain
available through the shared column picker instead of crowding the baseline
desktop view. When the responsive event table collapses columns on mobile, the
primary item cell must retain enough method, platform, target, and verification
context to preserve restore-readiness meaning rather than hiding all secondary
evidence behind desktop-only columns in the desktop grid.
That same event-row scan rule should mirror the protected-items table in the
primary identity cell. `RecoveryHistoryTable.tsx` should lead each event row
with a compact outcome status cue plus the canonical item name, so operators
can scan event health by row without relying only on the far-right outcome
column.
That same density rule should also keep history grouping and badges restrained.
Day-group headers should read as slim dividers instead of banner rows, and
platform/method/outcome pills should stay compact enough that the event grid
still scans like a monitoring table rather than a report export.
The same item-identity contract applies to synthetic Proxmox task recovery
points. When the persisted subject label is just a raw `pve-task:*`/`UPID:*`
identifier or `vmid=0`, the canonical recovery index should derive a readable
task label and `task` item type from point details so recovery rows scan by
operator meaning instead of transport IDs.
That shared unified-resource dependency now also includes policy-governed
resource metadata on the frontend decode path: storage and recovery surfaces
that route through `frontend-modern/src/hooks/useUnifiedResources.ts` must
@@ -5804,29 +5404,12 @@ and proof surface describe one /api/auto-register path instead of a fake
/api/auto-register/secure sibling, so adjacent transport and governed
evidence do not drift onto a route split that the runtime does not actually
expose.
That same filter contract applies to the advanced history facets transport as a
whole: changing node or namespace filters must narrow the facets request too,
so node and namespace option sets cannot drift back to the broader chart window
while the visible history table is already scoped to a smaller recovery slice.
That same narrowing rule now also applies when a timeline day is selected: the
facets request must use the same narrowed day window as the points request so
node and namespace option sets stay coherent with the visible history slice
instead of showing options from the full chart range while the table is already
scoped to a single day.
The recovery timeline drill-down now also treats day selection as a real
history transport boundary: choosing a day in the "Backups By Date" chart must
narrow the point-history request window to that selected local day rather than
only updating local selection chrome while the table remains on the broader
chart window.
That selected-day boundary must also be durable route state: the recovery URL
must preserve the active timeline day so reload, navigation, and shared links
reconstruct the same point-history window instead of silently widening back to
the broader chart range.
That same route continuity rule also applies to the selected chart window
itself: changing the recovery timeline range to `7d`, `90d`, or `1y` must stay
in canonical recovery route state so reload, navigation, and shared links
reconstruct the same rollup and series transport window instead of widening
back to the default `30d` range.
The facet, point-window, and range route-state rules that served the deleted
Recovery page's history table no longer have a reader. Day and range selection
now belong to the Proxmox Backups activity chart: choosing a day filters the
date feed in place and writes `PROXMOX_BACKUPS_QUERY_PARAMS.day`, so reload and
shared links reopen the same day, while the chart range (`7d`, `30d`, `90d`,
`1y`) is component state that starts at 30 days and is not written to the URL.
This lane intentionally depends on other governed boundaries instead of
overreaching into them. API transport and payload contract ownership remain in
@@ -6084,10 +5667,9 @@ collector, agent-id/device fallback stream, or separate "real-time" history
store once monitoring and `/api/metrics-store/history` already own the disk
timeline.
The same shell/runtime split now applies to websocket consumers:
`frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx` and
`frontend-modern/src/components/Storage/useStoragePageResources.ts` may consume
websocket state only through `frontend-modern/src/contexts/appRuntime.ts`.
They must not import `@/App` or create storage/recovery-local shell coupling,
It must not import `@/App` or create storage/recovery-local shell coupling,
because provider placement remains app-shell-owned and storage/recovery
surfaces must stay lazy-load safe.
The Storage page also keeps large-estate hydration and rendering bounded at
@@ -3200,9 +3200,10 @@ in the same pass. The K8s Namespaces and Deployments drawer "Open Pods" /
route to `/kubernetes/workloads` rather than building
retired aggregate workload URLs; the legacy context/namespace query filter
does not carry forward because the new Kubernetes Workloads tab does not
consume those parameters. Workloads, storage, and recovery route-state helpers
in `frontend-modern/src/routing/resourceLinks.ts` now serialize query state
only; the owning platform/runtime route supplies the pathname. New drawer or
consume those parameters. The workloads and storage route-state helpers in
`frontend-modern/src/routing/resourceLinks.ts` serialize query state only, and
the owning platform/runtime route supplies the pathname; the recovery
serializer was deleted once nothing read it. New drawer or
correlation surfaces must anchor on platform routes (or stay-in-place drawer
expansion) instead of resurrecting the retired top-level paths.
The Proxmox Backups route-state vocabulary includes the opaque `location`
@@ -3224,8 +3225,9 @@ recreate raw labelled `<select>` controls for those filters locally.
The Proxmox platform page is a route-level consumer of canonical unified
resources, not a new resource source. It filters the existing resource snapshot
to Proxmox VE, Backup Server, Mail Gateway, storage, disk, and workload rows,
then composes the existing Workloads, Storage, Recovery, and infrastructure
table owners. Proxmox host row version, uptime, temperature, CPU, memory, disk,
then composes the existing Workloads and Storage surfaces, its own Backups
tab tables, and the infrastructure table owners; no Recovery surface exists to
compose. Proxmox host row version, uptime, temperature, CPU, memory, disk,
network I/O, and disk I/O presentation must derive from canonical resource
facets and the shared `nodeFromResource` adapter; platform pages must not
rebuild resource identity, merge policy, or metric-target inference locally.
@@ -4783,32 +4785,30 @@ canonical workload identity (`<instance>:<node>:<vmid>`) in the shared
`resource` query rather than an opaque unified-resource id, so direct route
loads and cross-surface drill-downs reopen the correct workload drawer instead
of landing on an unselected table state.
That same routing contract now also owns recovery route-state vocabulary for
unified resources without restoring the retired `/recovery` top-level route.
Infrastructure drawers and other cross-surface consumers must carry canonical
`platform` and `node` query state into an owning platform/runtime route instead
of rebuilding drawer-local recovery links, assuming only PBS services can
expose recovery handoffs from infrastructure, or sending operators to a
standalone aggregate workspace URL.
When shared recovery links include protected-inventory posture, they must use
the recovery-owned `state` query instead of overloading event `status`. Event
outcome `status` remains recovery-history state, and compatibility input such
as legacy `stale=1` must be normalized by the recovery route owner before
cross-surface links are rebuilt.
That same shared routing boundary now also owns alert-investigation handoffs.
Resource-incident panels and other alert-side resource drill-down consumers
must route operators back through canonical infrastructure resource detail and
then into platform-owned workload, storage, and recovery surfaces via
`frontend-modern/src/routing/resourceLinks.ts` route-state vocabulary, instead
of treating alert investigation as a provider-local dead end, freezing
per-surface route strings outside the unified-resource contract, or reviving
Unified resources carry no recovery route-state vocabulary. The recovery
query serializer (`RECOVERY_QUERY_PARAMS`, `parseRecoveryLinkSearch`,
`buildRecoveryRouteSearch`) was deleted from
`frontend-modern/src/routing/resourceLinks.ts` after the aggregate Recovery page
that read it went on 2026-05-26, and the protected-inventory `state`, event
`status`, and legacy `stale=1` rules went with it. Infrastructure drawers and
other cross-surface consumers that expose recovery context must land in the
owning platform tab (the Proxmox Backups tab or the TrueNAS Protection tab)
instead of rebuilding drawer-local recovery links, assuming only PBS services
can expose recovery from infrastructure, or sending operators to a standalone
aggregate workspace URL.
Alert investigation offers no resource cross-jump links. The
resource-incident panel names its resource through the unified-resource lookup
and links to no other surface (its Pulse Assistant handoff aside): its
cross-jump chips were retired on 2026-05-16, and an alert card's single
resource link opens the owning platform page. Alert-side consumers must not freeze
per-surface route strings outside the unified-resource contract or revive
retired aggregate workspace URLs.
That same routing contract also owns Patrol finding handoffs. Expanded
Patrol-finding rows and scoped-run finding snapshots must resolve the backing
unified resource and surface the same platform-owned workload/storage/recovery
route-state vocabulary there, including exact workload and physical-disk route
state when the selected resource is itself the workload or disk, instead of
stopping at finding text, rebuilding patrol-local route strings, or reviving
Patrol findings stay in place the same way: expanded Patrol-finding rows
carry the finding's own primary action and manual controls rather than
surface-link chips, and
`frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts` keeps
`buildResolvedResourceSurfaceLinks` and `useResources()` out of the panel. A
future finding handoff must not rebuild patrol-local route strings or revive
retired aggregate workspace URLs.
That drawer shell now routes its canonical timeline filter, facet-bundle, and
resource-intelligence state through
@@ -5672,50 +5672,14 @@ contract for reporting/ignored infrastructure state; future settings-row
grouping or reporting-surface scope changes must be routed through that backend
projection instead of teaching the frontend to reinterpret raw resource facets
or removed-runtime arrays locally.
Canonical route-state helpers must also preserve recovery-specific drill-down
state when they serialize governed resource views. Recovery timeline day
selection is part of the durable route-state contract, so recovery query state
must round-trip the selected day inside an owning platform/runtime route
instead of dropping it as transient local UI state or restoring `/recovery`.
The same recovery route-state contract also applies to the selected timeline
range: canonical recovery query state must preserve explicit non-default chart
windows such as `7d`, `90d`, and `1y` so recovery drill-down transport does
not widen back to the default `30d` window on reload or shared navigation.
That same shared recovery route-state contract also owns the primary recovery
workspace selection. When an operator explicitly switches between protected
items and recovery events, canonical recovery query state must round-trip that
`view` selection unless the active `rollupId` or selected day already implies
the default recovery-events workspace.
That same shared recovery route helper contract now also owns canonical
protected-inventory posture encoding. Visible recovery inventory filters must
round-trip through the owned `state=<value>` query form instead of leaking ad
hoc booleans, overloading event `status`, or disappearing from shared links on
reload. Legacy `stale=1` may be parsed only as compatibility input that
rewrites to canonical inventory state.
That same route-state contract now also owns the canonical recovery `itemType`
query. Recovery query state must round-trip a provider-neutral item category
such as `vm`, `dataset`, or `pvc`, and
`frontend-modern/src/routing/resourceLinks.ts` may canonicalize provider-native
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-state contract also owns the canonical recovery `platform`
query. Recovery query state must emit `platform=<owned-source-key>` as the
shared operator-facing shape, while accepted legacy `provider` aliases may be
parsed only as compatibility input that rewrites back to canonical platform
route state. `frontend-modern/src/routing/resourceLinks.ts` must not keep
legacy `provider` as a first-class build option once parse-time compatibility
has converted it back to canonical recovery route state.
Recovery-linked consumers that decode `/api/recovery/*` payloads must likewise
prefer canonical `platform` / `platforms` response fields over legacy
`provider` aliases, so unified resource drill-downs and shared recovery links
carry the same platform vocabulary on both route and payload boundaries.
That same shared drill-down contract also owns which primary recovery workspace
an upstream surface is targeting. Infrastructure service links that open
platform-level recovery activity must emit canonical recovery route state with
`view=events` inside an owning platform/runtime destination instead of
inheriting the inventory default, and those entry links should describe the
destination as recovery events rather than platform-specific backup wording.
No shared helper serializes recovery drill-down state any more: the
recovery timeline day, chart range, workspace `view`, inventory `state`,
`itemType`, and `platform` route-state rules served the deleted aggregate
Recovery page. The Proxmox Backups tab owns its own day selection through
`PROXMOX_BACKUPS_QUERY_PARAMS.day`; its chart range is component state. Readers
of `/api/recovery/*` payloads must still prefer canonical `platform` /
`platforms` response fields over legacy `provider` aliases, which
`frontend-modern/src/utils/recoveryPlatformModel.ts` rewrites at decode time.
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
@@ -0,0 +1,49 @@
{
"version": 1,
"base_sha": "1a094646d7f21217eda051af6ba0252e208f283e",
"verified_at": "2026-10-07T22:32:43Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/routing/resourceLinks.ts",
"frontend-modern/src/utils/recoveryItemTypePresentation.ts"
],
"content_sha256": {
"frontend-modern/src/routing/resourceLinks.ts": "420b501bf1d305bc4287ef50655764cbff4c19db4823e8258e34ca9097edaec4",
"frontend-modern/src/utils/recoveryItemTypePresentation.ts": "deleted"
},
"routes": [
"/proxmox/backups/date",
"/proxmox/backups/coverage",
"/truenas/protection"
],
"viewports": [
{
"width": 1280,
"height": 800
},
{
"width": 390,
"height": 844
}
],
"states": [
"Private Vite dev server from this change's tree (verified on origin/main 15322e1573 plus the change, then rebased onto 1a094646d7, whose intervening frontend changes touch summary focus, Patrol and workload guest presentation, not the Proxmox Backups or TrueNAS Protection code) against two backends: the shared non-mock :7655 backend with real PBS/PVE backup history and no TrueNAS system, and a peer's mock-mode backend on :7891 with TrueNAS replication and snapshot recovery points",
"The change only deletes exports no production module reads (RECOVERY_QUERY_PARAMS, parseRecoveryLinkSearch, buildRecoveryRouteSearch, normalizeQueryBooleanFlag) and the unreachable recoveryItemTypePresentation.ts; no rendered component imports any of them",
"/proxmox/backups/date: backup servers table, backup health posture strip, Backups per day chart and the date feed render; By date / Coverage tabs sit above the filter bar at both widths",
"/proxmox/backups/date?day=2026-09-19 (real data): feed narrowed in place to one 7-backup day group; after reload the day is kept and the range control is back on 30d",
"1y range: 364+ day columns, chart scrolls inside its card, document scrollWidth equals clientWidth (no page overflow); URL unchanged by range changes",
"/proxmox/backups/coverage: coverage table with posture filter and Node / Backup location / Type filters; the date-only day facet is cleared on the switch",
"/truenas/protection on the non-mock backend: 'No TrueNAS systems' empty state",
"/truenas/protection on the mock backend: 10 snapshot and replication events with canonical dataset labels (display.itemLabel); Failed status filter leaves one row with the URL unchanged; expanded detail leads with Kind, Outcome, Dataset, Host, then replication and target sections, no raw JSON",
"390x844: /proxmox/backups/date and /truenas/protection have no horizontal page overflow",
"Console: only WebSocket proxy failures to the peer mock backend's /ws (its origin check, REST data unaffected); no other errors"
],
"interactions": [
"Clicked an activity day column (Sat 19 Sept on real data, Wed 7 Oct on mock) and read the URL and day group headers",
"Clicked the 1y range button and measured page overflow, then reloaded the day URL",
"Clicked the Coverage tab",
"Clicked the Failed status filter on the TrueNAS Protection table",
"Expanded the details row for archive/backups",
"Resized to 390x844 and 1280x800 and re-measured overflow and tab/filter order"
]
}
@@ -1,10 +1,34 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { createRoot } from 'solid-js';
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
import { useRecoveryPoints } from '@/hooks/useRecoveryPoints';
import {
normalizeRecoveryPointsResponse,
normalizeRecoveryRollupsResponse,
} from '@/utils/recoveryPlatformModel';
const apiFetchJSONMock = vi.hoisted(() => vi.fn());
vi.mock('@/utils/apiClient', () => ({ apiFetchJSON: apiFetchJSONMock }));
afterEach(() => {
apiFetchJSONMock.mockReset();
resetCreateNonSuspendingQueryCacheForTest();
});
const productionSources = (dir: string): string[] =>
readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
return entry.name === '__tests__' || entry.name === '__fixtures__'
? []
: productionSources(path);
}
return /\.(ts|tsx)$/.test(entry.name) && !/\.(test|spec)\.tsx?$/.test(entry.name) ? [path] : [];
});
describe('recovery transport', () => {
it('normalizes legacy subject recovery fields onto canonical item fields', () => {
expect(
@@ -63,4 +87,81 @@ describe('recovery transport', () => {
meta: { page: 1, limit: 100, total: 1, totalPages: 1 },
});
});
it('normalizes legacy display aliases onto the canonical item labels', () => {
const [point] = normalizeRecoveryPointsResponse({
data: [
{
id: 'point-2',
platform: 'truenas',
kind: 'snapshot',
mode: 'snapshot',
outcome: 'success',
display: { subjectLabel: ' tank/apps ', subjectType: 'dataset' },
},
],
meta: { page: 1, limit: 100, total: 1, totalPages: 1 },
}).data;
expect(point.display).toEqual({ itemLabel: 'tank/apps', itemType: 'dataset' });
});
it('hands the TrueNAS Protection tab normalized recovery points', async () => {
apiFetchJSONMock.mockResolvedValue({
data: [
{
id: 'point-3',
provider: 'truenas',
kind: 'snapshot',
mode: 'snapshot',
outcome: 'success',
subjectRef: { type: 'truenas-dataset', name: 'tank/apps' },
display: { subjectLabel: 'tank/apps', subjectType: 'dataset' },
},
],
meta: { page: 1, limit: 200, total: 1, totalPages: 1 },
});
let dispose = () => {};
const points = createRoot((rootDispose) => {
dispose = rootDispose;
return useRecoveryPoints(() => ({ platform: 'truenas', page: 1, limit: 200 })).points;
});
try {
await vi.waitFor(() => expect(points()).toHaveLength(1));
expect(apiFetchJSONMock).toHaveBeenCalledWith(
'/api/recovery/points?page=1&limit=200&platform=truenas',
);
expect(points()[0]).toEqual({
id: 'point-3',
platform: 'truenas',
kind: 'snapshot',
mode: 'snapshot',
outcome: 'success',
itemRef: { type: 'truenas-dataset', name: 'tank/apps' },
display: { itemLabel: 'tank/apps', itemType: 'dataset' },
});
} finally {
dispose();
}
});
it('keeps useRecoveryPoints the only recovery-points reader', () => {
// The rollup, facet and series hooks went with the aggregate Recovery
// page, and the item-type helper with the recovery query serializer that
// was its last reader. The scan matches literal /api/recovery/ paths only.
const recoveryReaders = productionSources('src')
.filter((path) => /['"`]\/api\/recovery\//.test(readFileSync(path, 'utf8')))
.sort();
expect(recoveryReaders).toEqual([
join('src', 'hooks', 'useProtectionPostures.ts'),
join('src', 'hooks', 'useRecoveryPoints.ts'),
]);
for (const retired of [
'src/hooks/useRecoveryRollups.ts',
'src/hooks/useRecoveryPointsFacets.ts',
'src/hooks/useRecoveryPointsSeries.ts',
'src/utils/recoveryItemTypePresentation.ts',
]) {
expect(existsSync(retired), retired).toBe(false);
}
});
});
@@ -35,6 +35,7 @@ import kubernetesStorageTableSource from '@/features/kubernetes/KubernetesStorag
// The backups page owns the scope controls and delegates dense table rendering
// to current per-view components; the shared-primitive guardrail follows those
// current table owners.
import proxmoxBackupsTableSource from '@/features/proxmox/ProxmoxBackupsTable.tsx?raw';
import proxmoxBackupServersTableSource from '@/features/proxmox/ProxmoxBackupServersTable.tsx?raw';
import proxmoxCoverageTableSource from '@/features/proxmox/ProxmoxCoverageTable.tsx?raw';
import proxmoxRecoverableTableSource from '@/features/proxmox/ProxmoxRecoverableTable.tsx?raw';
@@ -743,4 +744,30 @@ describe('platform overview layout guardrails', () => {
/id:\s*'disk'[\s\S]{0,80}?label:\s*'Disk'[\s\S]{0,80}?kind:\s*'metric-bar'/,
);
});
it('keeps recovery view and filter state with the platform tab that owns the rows', () => {
// No aggregate Recovery page or shared recovery serializer exists, so each
// platform tab owns its recovery state (storage-recovery forbidden path 9).
// The Proxmox Backups view switch is navigation above the filter bar.
const sectionTabsAt = proxmoxBackupsTableSource.indexOf('<PlatformSectionTabs');
const filterBarAt = proxmoxBackupsTableSource.indexOf('<FilterBar');
expect(sectionTabsAt).toBeGreaterThan(-1);
expect(filterBarAt).toBeGreaterThan(sectionTabsAt);
// Its activity day is route state; its chart range is component state.
expect(proxmoxBackupsTableSource).toMatch(
/setSearchParams\(\{\s*\[PROXMOX_BACKUPS_QUERY_PARAMS\.day\]: value \|\| null \}/,
);
expect(proxmoxBackupsTableSource).toContain('createSignal<BackupActivityRangeDays>(30)');
expect(proxmoxBackupsTableSource).not.toMatch(/PROXMOX_BACKUPS_QUERY_PARAMS\.range\b/);
for (const call of proxmoxBackupsTableSource.match(/setSearchParams\([^;]*/g) ?? []) {
expect(call).not.toMatch(/\brange\b|chartRange/);
}
// The TrueNAS Protection table keeps search, status and sort out of the URL.
expect(truenasProtectionTableSource).not.toContain('useSearchParams');
expect(truenasProtectionTableSource).not.toContain('@/routing/resourceLinks');
expect(truenasProtectionTableSource).toContain('createPlatformTableFilterState({');
expect(truenasProtectionTableSource).toMatch(
/createPlatformTableSortState\(\{\s*storageKey: 'truenasProtection'/,
);
});
});
@@ -501,6 +501,7 @@ describe('tab path helpers', () => {
expect(alertResourceIncidentsPanelSource).not.toContain('allowInfrastructureFallback');
expect(alertResourceIncidentsPanelSource).not.toContain('buildInfrastructureResourceLink');
expect(alertResourceIncidentsPanelSource).not.toContain('buildResourceSurfaceLinksForResource');
expect(alertResourceIncidentsPanelSource).not.toMatch(/from ['"][^'"]*resourceLinks['"]/);
expect(alertResourceIncidentsPanelSource).not.toContain('{link.compactLabel}');
expect(alertResourceIncidentsPanelSource).toContain('IncidentAssistantHandoffButton');
expect(incidentTimelinePanelSource).toContain('IncidentAssistantHandoffButton');
@@ -1,13 +1,11 @@
import { describe, expect, it } from 'vitest';
import {
buildKubernetesPath,
buildRecoveryRouteSearch,
buildStorageRouteSearch,
buildTrueNASPath,
buildVmwarePath,
buildWorkloadsRouteSearch,
KUBERNETES_PATH,
RECOVERY_QUERY_PARAMS,
STORAGE_QUERY_PARAMS,
TRUENAS_PATH,
VMWARE_PATH,
@@ -228,136 +226,3 @@ describe('buildStorageRouteSearch branch coverage', () => {
);
});
});
describe('buildRecoveryRouteSearch branch coverage', () => {
it('returns an empty string when no options are supplied', () => {
expect(buildRecoveryRouteSearch()).toBe('');
expect(buildRecoveryRouteSearch({})).toBe('');
});
it('emits the rollupId param the sibling suite omits', () => {
expect(buildRecoveryRouteSearch({ rollupId: 'rollup-42' })).toBe(
`?${RECOVERY_QUERY_PARAMS.rollupId}=rollup-42`,
);
expect(RECOVERY_QUERY_PARAMS.rollupId).toBe('rollupId');
});
it('treats the affirmative stale synonyms "yes" and "on" as the "1" flag', () => {
expect(buildRecoveryRouteSearch({ stale: 'yes', platform: 'proxmox-pve' })).toBe(
'?platform=proxmox-pve&stale=1',
);
expect(buildRecoveryRouteSearch({ stale: 'on', platform: 'proxmox-pve' })).toBe(
'?platform=proxmox-pve&stale=1',
);
});
it('drops the stale flag entirely for non-affirmative values (falsey boolean arm + falsy if-arm)', () => {
expect(buildRecoveryRouteSearch({ stale: 'no', platform: 'proxmox-pve' })).toBe(
'?platform=proxmox-pve',
);
expect(buildRecoveryRouteSearch({ stale: 'off', platform: 'proxmox-pve' })).toBe(
'?platform=proxmox-pve',
);
expect(buildRecoveryRouteSearch({ stale: '0', platform: 'proxmox-pve' })).toBe(
'?platform=proxmox-pve',
);
expect(buildRecoveryRouteSearch({ stale: 'false', platform: 'proxmox-pve' })).toBe(
'?platform=proxmox-pve',
);
});
it('canonicalizes system-container item-type aliases through the switch arms', () => {
expect(buildRecoveryRouteSearch({ itemType: 'lxc' })).toBe('?itemType=system-container');
expect(buildRecoveryRouteSearch({ itemType: 'ct' })).toBe('?itemType=system-container');
expect(buildRecoveryRouteSearch({ itemType: 'container' })).toBe('?itemType=system-container');
expect(buildRecoveryRouteSearch({ itemType: 'oci-container' })).toBe(
'?itemType=system-container',
);
});
it('canonicalizes the app-container item-type aliases through the switch arms', () => {
expect(buildRecoveryRouteSearch({ itemType: 'docker' })).toBe('?itemType=app-container');
expect(buildRecoveryRouteSearch({ itemType: 'docker-container' })).toBe(
'?itemType=app-container',
);
expect(buildRecoveryRouteSearch({ itemType: 'app-container' })).toBe('?itemType=app-container');
});
it('canonicalizes the remaining item-type switch arms (pod, pvc, cluster, dataset, velero, guest)', () => {
expect(buildRecoveryRouteSearch({ itemType: 'k8s-pod' })).toBe('?itemType=pod');
expect(buildRecoveryRouteSearch({ itemType: 'pvc' })).toBe('?itemType=pvc');
expect(buildRecoveryRouteSearch({ itemType: 'kubernetes-cluster' })).toBe('?itemType=cluster');
expect(buildRecoveryRouteSearch({ itemType: 'truenas-dataset' })).toBe('?itemType=dataset');
expect(buildRecoveryRouteSearch({ itemType: 'velero-backup' })).toBe('?itemType=velero-backup');
expect(buildRecoveryRouteSearch({ itemType: 'proxmox-guest' })).toBe('?itemType=guest');
});
it('strips the proxmox-/truenas-/k8s- prefixes via the default-arm branches', () => {
expect(buildRecoveryRouteSearch({ itemType: 'proxmox-storage' })).toBe('?itemType=storage');
expect(buildRecoveryRouteSearch({ itemType: 'truenas-share' })).toBe('?itemType=share');
expect(buildRecoveryRouteSearch({ itemType: 'k8s-node' })).toBe('?itemType=node');
});
it('maps itemType="all" to an empty value so the param is dropped', () => {
expect(buildRecoveryRouteSearch({ itemType: 'all', platform: 'proxmox-pve' })).toBe(
'?platform=proxmox-pve',
);
expect(buildRecoveryRouteSearch({ itemType: '', platform: 'proxmox-pve' })).toBe(
'?platform=proxmox-pve',
);
});
it('passes an unrecognized itemType through the default arm unchanged', () => {
expect(buildRecoveryRouteSearch({ itemType: 'machine-learning-job' })).toBe(
'?itemType=machine-learning-job',
);
});
it('serializes every supported option in canonical insertion order, including rollupId', () => {
expect(
buildRecoveryRouteSearch({
rollupId: 'r-1',
view: 'events',
platform: 'proxmox-pve',
state: 'stale',
stale: '1',
range: '7',
cluster: 'c1',
day: '2026-02-13',
namespace: 'ns1',
mode: 'remote',
itemType: 'vm',
scope: 'workload',
status: 'failed',
verification: 'verified',
node: 'n1',
query: 'q1',
}),
).toBe(
'?rollupId=r-1&view=events&platform=proxmox-pve&state=stale&stale=1&range=7&cluster=c1&day=2026-02-13&namespace=ns1&mode=remote&itemType=vm&scope=workload&status=failed&verification=verified&node=n1&q=q1',
);
});
it('drops fields whose normalized value is empty (per-field falsy arms across every option)', () => {
expect(
buildRecoveryRouteSearch({
rollupId: ' ',
view: '',
platform: null,
state: undefined,
stale: 'no',
range: ' ',
cluster: '',
day: null,
namespace: undefined,
mode: ' ',
itemType: 'all',
scope: '',
status: null,
verification: undefined,
node: ' ',
query: '',
}),
).toBe('');
});
});
@@ -39,7 +39,6 @@ import {
PROXMOX_BACKUPS_DEFAULT_VIEW,
PROXMOX_DEFAULT_TAB,
PROXMOX_PATH,
RECOVERY_QUERY_PARAMS,
SETTINGS_API_ACCESS_PATH,
STANDALONE_DEFAULT_TAB,
STANDALONE_PATH,
@@ -51,7 +50,6 @@ import {
buildDockerPath,
buildDockerRouteSearch,
buildKubernetesPath,
buildRecoveryRouteSearch,
buildProxmoxPath,
buildProxmoxBackupsPath,
buildStandalonePath,
@@ -59,7 +57,6 @@ import {
buildTrueNASPath,
buildVmwarePath,
buildWorkloadsRouteSearch,
parseRecoveryLinkSearch,
parsePatrolAttentionItemId,
parsePatrolControlStarter,
parsePatrolOperationsLoopStarter,
@@ -68,6 +65,7 @@ import {
STORAGE_QUERY_PARAMS,
WORKLOADS_QUERY_PARAMS,
} from '@/routing/resourceLinks';
import * as resourceLinks from '@/routing/resourceLinks';
describe('resource link routing contract', () => {
it('keeps Patrol links on the canonical Patrol route', () => {
@@ -276,14 +274,20 @@ describe('resource link routing contract', () => {
});
it('does not expose retired aggregate route builders', () => {
const linkExports = {
buildWorkloadsRouteSearch,
buildStorageRouteSearch,
buildRecoveryRouteSearch,
};
expect(linkExports).not.toHaveProperty('buildWorkloadsPath');
expect(linkExports).not.toHaveProperty('buildStoragePath');
expect(linkExports).not.toHaveProperty('buildRecoveryPath');
expect(resourceLinks).not.toHaveProperty('buildWorkloadsPath');
expect(resourceLinks).not.toHaveProperty('buildStoragePath');
expect(resourceLinks).not.toHaveProperty('buildRecoveryPath');
});
it('keeps no recovery query serializer after the aggregate Recovery page', () => {
// The aggregate Recovery page and its route-state owner were deleted on
// 2026-05-26. The Proxmox Backups tab owns its query through
// PROXMOX_BACKUPS_QUERY_PARAMS and the TrueNAS Protection tab keeps its
// filters out of the URL, so a shared recovery serializer has no reader.
expect(resourceLinks).not.toHaveProperty('RECOVERY_QUERY_PARAMS');
expect(resourceLinks).not.toHaveProperty('buildRecoveryRouteSearch');
expect(resourceLinks).not.toHaveProperty('parseRecoveryLinkSearch');
expect(Object.keys(resourceLinks).filter((name) => /recovery/i.test(name))).toEqual([]);
});
it('builds and parses storage query params', () => {
@@ -336,110 +340,10 @@ describe('resource link routing contract', () => {
expect(parseStorageLinkSearch('?source=proxmox')).toMatchObject({ source: 'proxmox-pve' });
});
it('builds and parses recovery query params', () => {
const search = buildRecoveryRouteSearch({
view: 'events',
platform: 'proxmox-pbs',
state: 'stale',
stale: '1',
range: '7',
cluster: 'cluster-main',
day: '2026-02-13',
namespace: 'tenant-a',
mode: 'remote',
itemType: 'vm',
status: 'failed',
verification: 'verified',
scope: 'workload',
node: 'cluster-main-pve1',
query: 'node:pve1',
});
const url = new URL(search, 'http://localhost/truenas/protection');
expect(url.pathname).toBe('/truenas/protection');
expect(url.searchParams.get('view')).toBe('events');
expect(url.searchParams.get('platform')).toBe('proxmox-pbs');
expect(url.searchParams.get('state')).toBe('stale');
expect(url.searchParams.get('stale')).toBe('1');
expect(url.searchParams.get('range')).toBe('7');
expect(url.searchParams.get('cluster')).toBe('cluster-main');
expect(url.searchParams.get('day')).toBe('2026-02-13');
expect(url.searchParams.get('namespace')).toBe('tenant-a');
expect(url.searchParams.get('mode')).toBe('remote');
expect(url.searchParams.get('itemType')).toBe('vm');
expect(url.searchParams.get('scope')).toBe('workload');
expect(url.searchParams.get('status')).toBe('failed');
expect(url.searchParams.get('verification')).toBe('verified');
expect(url.searchParams.get('node')).toBe('cluster-main-pve1');
expect(url.searchParams.get('q')).toBe('node:pve1');
const parsed = parseRecoveryLinkSearch(search);
expect(parsed).toEqual({
rollupId: '',
view: 'events',
platform: 'proxmox-pbs',
state: 'stale',
stale: '1',
range: '7',
cluster: 'cluster-main',
day: '2026-02-13',
namespace: 'tenant-a',
mode: 'remote',
itemType: 'vm',
scope: 'workload',
status: 'failed',
verification: 'verified',
node: 'cluster-main-pve1',
query: 'node:pve1',
});
expect(RECOVERY_QUERY_PARAMS.platform).toBe('platform');
expect(RECOVERY_QUERY_PARAMS.view).toBe('view');
expect(RECOVERY_QUERY_PARAMS.state).toBe('state');
expect(RECOVERY_QUERY_PARAMS.stale).toBe('stale');
expect(RECOVERY_QUERY_PARAMS.range).toBe('range');
expect(RECOVERY_QUERY_PARAMS.cluster).toBe('cluster');
expect(RECOVERY_QUERY_PARAMS.day).toBe('day');
expect(RECOVERY_QUERY_PARAMS.namespace).toBe('namespace');
expect(RECOVERY_QUERY_PARAMS.mode).toBe('mode');
expect(RECOVERY_QUERY_PARAMS.itemType).toBe('itemType');
expect(RECOVERY_QUERY_PARAMS.scope).toBe('scope');
expect(RECOVERY_QUERY_PARAMS.verification).toBe('verification');
expect(RECOVERY_QUERY_PARAMS.query).toBe('q');
it('exposes the mail gateway thresholds path', () => {
expect(PMG_THRESHOLDS_PATH).toBe('/alerts/thresholds/mail-gateway');
});
it('canonicalizes recovery platform aliases when building and parsing links', () => {
expect(buildRecoveryRouteSearch({ platform: 'pbs', mode: 'remote' })).toBe(
'?platform=proxmox-pbs&mode=remote',
);
const parsed = parseRecoveryLinkSearch('?provider=proxmox&mode=local');
expect(parsed).toMatchObject({
platform: 'proxmox-pve',
mode: 'local',
});
expect(buildRecoveryRouteSearch(parsed)).toBe('?platform=proxmox-pve&mode=local');
expect(parseRecoveryLinkSearch('?itemType=proxmox-vm')).toMatchObject({
itemType: 'vm',
});
});
it('canonicalizes stale-only recovery route flags to the owned query shape', () => {
expect(buildRecoveryRouteSearch({ stale: 'true', platform: 'proxmox-pve' })).toBe(
'?platform=proxmox-pve&stale=1',
);
expect(parseRecoveryLinkSearch('?stale=%201%20')).toMatchObject({ stale: '1' });
});
it('preserves explicit recovery chart range values in route state', () => {
const search = buildRecoveryRouteSearch({ range: '30', platform: 'proxmox-pve' });
const url = new URL(search, 'http://localhost/proxmox/backups');
expect(url.pathname).toBe('/proxmox/backups');
expect(url.searchParams.get('platform')).toBe('proxmox-pve');
expect(url.searchParams.get('range')).toBe('30');
expect(parseRecoveryLinkSearch('?range=90')).toMatchObject({ range: '90' });
});
it('deep-links one stable Patrol attention item without changing the route owner', () => {
expect(buildPatrolAttentionPath('record/one')).toBe('/patrol?attention=record%2Fone');
expect(buildPatrolAttentionPath('')).toBe('/patrol');
@@ -1,6 +1,5 @@
import { normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms';
import { normalizeStorageSourceKey } from '@/utils/storageSources';
import { normalizeRecoveryItemTypeQueryValue } from '@/utils/recoveryItemTypePresentation';
import { canonicalizeWorkloadFilterType } from '@/utils/workloads';
export const WORKLOADS_QUERY_PARAMS = {
@@ -108,32 +107,7 @@ export const STORAGE_QUERY_PARAMS = {
summaryGroup: 'summaryGroup',
} as const;
export const RECOVERY_QUERY_PARAMS = {
rollupId: 'rollupId',
view: 'view',
platform: 'platform',
state: 'state',
stale: 'stale',
range: 'range',
cluster: 'cluster',
day: 'day',
namespace: 'namespace',
mode: 'mode',
itemType: 'itemType',
scope: 'scope',
status: 'status',
verification: 'verification',
node: 'node',
query: 'q',
} as const;
const normalizeQueryValue = (value: string | null | undefined): string => (value || '').trim();
const normalizeQueryBooleanFlag = (value: string | null | undefined): string => {
const normalized = normalizeQueryValue(value).toLowerCase();
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'
? '1'
: '';
};
const normalizeWorkloadsType = (value: string | null | undefined): string =>
canonicalizeWorkloadFilterType(normalizeQueryValue(value));
@@ -178,27 +152,6 @@ type StorageLinkOptions = {
summaryGroup?: string | null;
};
type RecoveryLinkOptions = {
rollupId?: string | null;
view?: string | null;
platform?: string | null;
state?: string | null;
stale?: string | null;
range?: string | null;
cluster?: string | null;
day?: string | null;
namespace?: string | null;
mode?: string | null;
itemType?: string | null;
scope?: string | null;
status?: string | null;
verification?: string | null;
node?: string | null;
query?: string | null;
};
const RECOVERY_LEGACY_PLATFORM_QUERY_PARAM = 'provider';
export const parseWorkloadsLinkSearch = (search: string) => {
const params = new URLSearchParams(search);
return {
@@ -400,70 +353,3 @@ export const buildStorageRouteSearch = (options: StorageLinkOptions = {}): strin
return serializedRouteSearch(params);
};
export const parseRecoveryLinkSearch = (search: string) => {
const params = new URLSearchParams(search);
return {
rollupId: normalizeQueryValue(params.get(RECOVERY_QUERY_PARAMS.rollupId)),
view: normalizeQueryValue(params.get(RECOVERY_QUERY_PARAMS.view)),
platform: normalizeSourcePlatformQueryValue(
firstNonEmpty([
params.get(RECOVERY_QUERY_PARAMS.platform),
params.get(RECOVERY_LEGACY_PLATFORM_QUERY_PARAM),
]),
),
state: normalizeQueryValue(params.get(RECOVERY_QUERY_PARAMS.state)),
stale: normalizeQueryBooleanFlag(params.get(RECOVERY_QUERY_PARAMS.stale)),
range: normalizeQueryValue(params.get(RECOVERY_QUERY_PARAMS.range)),
cluster: normalizeQueryValue(params.get(RECOVERY_QUERY_PARAMS.cluster)),
day: normalizeQueryValue(params.get(RECOVERY_QUERY_PARAMS.day)),
namespace: normalizeQueryValue(params.get(RECOVERY_QUERY_PARAMS.namespace)),
mode: normalizeQueryValue(params.get(RECOVERY_QUERY_PARAMS.mode)),
itemType: normalizeRecoveryItemTypeQueryValue(params.get(RECOVERY_QUERY_PARAMS.itemType)),
scope: normalizeQueryValue(params.get(RECOVERY_QUERY_PARAMS.scope)),
status: normalizeQueryValue(params.get(RECOVERY_QUERY_PARAMS.status)),
verification: normalizeQueryValue(params.get(RECOVERY_QUERY_PARAMS.verification)),
node: normalizeQueryValue(params.get(RECOVERY_QUERY_PARAMS.node)),
query: normalizeQueryValue(params.get(RECOVERY_QUERY_PARAMS.query)),
};
};
export const buildRecoveryRouteSearch = (options: RecoveryLinkOptions = {}): string => {
const params = new URLSearchParams();
const rollupId = normalizeQueryValue(options.rollupId);
const view = normalizeQueryValue(options.view);
const platform = normalizeSourcePlatformQueryValue(options.platform);
const state = normalizeQueryValue(options.state);
const stale = normalizeQueryBooleanFlag(options.stale);
const range = normalizeQueryValue(options.range);
const cluster = normalizeQueryValue(options.cluster);
const day = normalizeQueryValue(options.day);
const namespace = normalizeQueryValue(options.namespace);
const mode = normalizeQueryValue(options.mode);
const itemType = normalizeRecoveryItemTypeQueryValue(options.itemType);
const scope = normalizeQueryValue(options.scope);
const status = normalizeQueryValue(options.status);
const verification = normalizeQueryValue(options.verification);
const node = normalizeQueryValue(options.node);
const query = normalizeQueryValue(options.query);
if (rollupId) params.set(RECOVERY_QUERY_PARAMS.rollupId, rollupId);
if (view) params.set(RECOVERY_QUERY_PARAMS.view, view);
if (platform) params.set(RECOVERY_QUERY_PARAMS.platform, platform);
if (state) params.set(RECOVERY_QUERY_PARAMS.state, state);
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);
if (day) params.set(RECOVERY_QUERY_PARAMS.day, day);
if (namespace) params.set(RECOVERY_QUERY_PARAMS.namespace, namespace);
if (mode) params.set(RECOVERY_QUERY_PARAMS.mode, mode);
if (itemType) params.set(RECOVERY_QUERY_PARAMS.itemType, itemType);
if (scope) params.set(RECOVERY_QUERY_PARAMS.scope, scope);
if (status) params.set(RECOVERY_QUERY_PARAMS.status, status);
if (verification) params.set(RECOVERY_QUERY_PARAMS.verification, verification);
if (node) params.set(RECOVERY_QUERY_PARAMS.node, node);
if (query) params.set(RECOVERY_QUERY_PARAMS.query, query);
return serializedRouteSearch(params);
};
@@ -1,191 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
getRecoveryItemTypeBadgeClass,
getRecoveryItemTypeLabel,
getRecoveryItemTypePresentation,
getRecoveryPointItemTypeKey,
getRecoveryRollupItemTypeKey,
normalizeRecoveryItemTypeQueryValue,
} from '@/utils/recoveryItemTypePresentation';
// These class-string constants are mirrored from the source module so assertions
// describe the documented badge composition rather than echoing the function
// under test. They are the building blocks the module concatenates.
const BADGE_BASE_CLASSES =
'inline-flex items-center rounded-sm px-2 py-0.5 text-[10px] font-medium whitespace-nowrap';
const TABLE_BADGE_BASE_CLASSES =
'inline-flex items-center px-1 py-0.5 text-[10px] font-medium rounded-sm whitespace-nowrap';
const DEFAULT_BADGE_TONE_CLASSES = 'bg-surface-alt text-base-content';
describe('recoveryItemTypePresentation branch coverage', () => {
describe('normalizeRecoveryItemTypeQueryValue', () => {
it('collapses nullish, empty, whitespace-only, and the "all" alias to an empty sentinel', () => {
expect(normalizeRecoveryItemTypeQueryValue(null)).toBe('');
expect(normalizeRecoveryItemTypeQueryValue(undefined)).toBe('');
expect(normalizeRecoveryItemTypeQueryValue('')).toBe('');
expect(normalizeRecoveryItemTypeQueryValue(' ')).toBe('');
// Case-insensitive: "ALL" lowercases into the explicit 'all' alias arm.
expect(normalizeRecoveryItemTypeQueryValue('ALL')).toBe('');
expect(normalizeRecoveryItemTypeQueryValue('all')).toBe('');
});
it('maps every vm-family alias onto the canonical "vm" key', () => {
expect(normalizeRecoveryItemTypeQueryValue('vm')).toBe('vm');
expect(normalizeRecoveryItemTypeQueryValue('vm-backup')).toBe('vm');
// 'proxmox-vm' / 'proxmox-vm-backup' already covered by the sibling test.
});
it('maps every system-container alias onto "system-container"', () => {
expect(normalizeRecoveryItemTypeQueryValue('proxmox-lxc')).toBe('system-container');
expect(normalizeRecoveryItemTypeQueryValue('lxc')).toBe('system-container');
expect(normalizeRecoveryItemTypeQueryValue('ct')).toBe('system-container');
expect(normalizeRecoveryItemTypeQueryValue('container')).toBe('system-container');
expect(normalizeRecoveryItemTypeQueryValue('system-container')).toBe('system-container');
expect(normalizeRecoveryItemTypeQueryValue('oci-container')).toBe('system-container');
});
it('maps the app-container family and bare aliases', () => {
expect(normalizeRecoveryItemTypeQueryValue('docker')).toBe('app-container');
expect(normalizeRecoveryItemTypeQueryValue('app-container')).toBe('app-container');
// 'docker-container' already covered by the sibling test.
});
it('maps k8s pod, pvc, and cluster bare aliases', () => {
expect(normalizeRecoveryItemTypeQueryValue('k8s-pod')).toBe('pod');
expect(normalizeRecoveryItemTypeQueryValue('pod')).toBe('pod');
expect(normalizeRecoveryItemTypeQueryValue('pvc')).toBe('pvc');
expect(normalizeRecoveryItemTypeQueryValue('cluster')).toBe('cluster');
// 'k8s-pvc', 'k8s-cluster', 'kubernetes-cluster' already covered.
});
it('maps dataset, velero, and guest aliases', () => {
expect(normalizeRecoveryItemTypeQueryValue('dataset')).toBe('dataset');
expect(normalizeRecoveryItemTypeQueryValue('velero-backup')).toBe('velero-backup');
expect(normalizeRecoveryItemTypeQueryValue('proxmox-guest')).toBe('guest');
expect(normalizeRecoveryItemTypeQueryValue('guest')).toBe('guest');
// 'truenas-dataset' already covered.
});
it('strips the proxmox-/truenas-/k8s- prefixes for otherwise-unknown values', () => {
// Each prefix arm of the default branch.
expect(normalizeRecoveryItemTypeQueryValue('proxmox-storage')).toBe('storage');
expect(normalizeRecoveryItemTypeQueryValue('truenas-replication-task')).toBe(
'replication-task',
);
// 'k8s-node' is not an explicit case here, so it hits the k8s- prefix arm.
expect(normalizeRecoveryItemTypeQueryValue('k8s-node')).toBe('node');
});
it('passes through unknown values verbatim (final default return)', () => {
// Whitespace is trimmed and casing is lowered before the passthrough.
expect(normalizeRecoveryItemTypeQueryValue(' Widget-Thing ')).toBe('widget-thing');
});
});
describe('getRecoveryItemTypePresentation', () => {
it('returns null when the normalized key is empty', () => {
expect(getRecoveryItemTypePresentation(null)).toBeNull();
expect(getRecoveryItemTypePresentation(undefined)).toBeNull();
expect(getRecoveryItemTypePresentation('')).toBeNull();
// 'all' normalizes to '' so it must also collapse to null.
expect(getRecoveryItemTypePresentation('all')).toBeNull();
expect(getRecoveryItemTypePresentation(' ')).toBeNull();
});
it('renders the pod case via the workload presentation path', () => {
const podTone = 'bg-amber-100 text-amber-700 dark:bg-amber-900/25 dark:text-amber-300';
const presentation = getRecoveryItemTypePresentation('pod');
expect(presentation).not.toBeNull();
expect(presentation).toMatchObject({
key: 'pod',
label: 'Pod',
badgeClasses: `${BADGE_BASE_CLASSES} ${podTone}`,
tableBadgeClasses: `${TABLE_BADGE_BASE_CLASSES} ${podTone}`,
});
});
it('renders the pvc case via the resource presentation path with its PVC fallback label', () => {
const presentation = getRecoveryItemTypePresentation('pvc');
expect(presentation).not.toBeNull();
expect(presentation).toMatchObject({
key: 'pvc',
label: 'PVC',
badgeClasses: `${BADGE_BASE_CLASSES} ${DEFAULT_BADGE_TONE_CLASSES}`,
tableBadgeClasses: `${TABLE_BADGE_BASE_CLASSES} ${DEFAULT_BADGE_TONE_CLASSES}`,
});
});
it('uses the resource presentation label verbatim when it differs from the key (default arm B)', () => {
// 'jail' is not a recovery-specific case, so it falls through to the
// default branch. getResourceTypePresentation('jail') returns a label
// ('Jail') that differs from the key ('jail'), exercising the arm that
// keeps presentation.label instead of re-titleCasing the key.
const presentation = getRecoveryItemTypePresentation('jail');
expect(presentation).not.toBeNull();
expect(presentation).toMatchObject({
key: 'jail',
label: 'Jail',
badgeClasses: `${BADGE_BASE_CLASSES} ${DEFAULT_BADGE_TONE_CLASSES}`,
tableBadgeClasses: `${TABLE_BADGE_BASE_CLASSES} ${DEFAULT_BADGE_TONE_CLASSES}`,
});
});
});
describe('getRecoveryItemTypeValue (covered transitively via the rollup/point keys)', () => {
// getRecoveryItemTypeValue is not exported; it is reached through the
// exported getRecoveryRollupItemTypeKey / getRecoveryPointItemTypeKey
// wrappers. These cases exercise the value-resolution arms the sibling
// test does not: the itemRef.type fallback and the all-empty '' fallback.
it('falls back to itemRef.type when display and subjectRef carry nothing', () => {
expect(
getRecoveryRollupItemTypeKey({
display: {},
itemRef: { type: 'proxmox-vm' },
}),
).toBe('vm');
});
it('returns the empty sentinel (and thus an empty key) when every source is absent', () => {
expect(getRecoveryRollupItemTypeKey(null)).toBe('');
expect(getRecoveryPointItemTypeKey(undefined)).toBe('');
expect(getRecoveryPointItemTypeKey({})).toBe('');
// Explicitly-null nested refs still resolve to ''.
expect(getRecoveryPointItemTypeKey({ display: null, itemRef: null, subjectRef: null })).toBe(
'',
);
});
});
describe('getRecoveryItemTypeLabel', () => {
it('returns an empty string when no presentation exists', () => {
expect(getRecoveryItemTypeLabel(null)).toBe('');
expect(getRecoveryItemTypeLabel(undefined)).toBe('');
expect(getRecoveryItemTypeLabel('')).toBe('');
expect(getRecoveryItemTypeLabel('all')).toBe('');
});
it('returns the resolved presentation label for known keys', () => {
expect(getRecoveryItemTypeLabel('pod')).toBe('Pod');
expect(getRecoveryItemTypeLabel('pvc')).toBe('PVC');
});
});
describe('getRecoveryItemTypeBadgeClass', () => {
it('returns the default table badge classes when no presentation exists', () => {
const expectedDefault = `${TABLE_BADGE_BASE_CLASSES} ${DEFAULT_BADGE_TONE_CLASSES}`;
expect(getRecoveryItemTypeBadgeClass(null)).toBe(expectedDefault);
expect(getRecoveryItemTypeBadgeClass(undefined)).toBe(expectedDefault);
expect(getRecoveryItemTypeBadgeClass('')).toBe(expectedDefault);
expect(getRecoveryItemTypeBadgeClass('all')).toBe(expectedDefault);
});
it('returns the table badge classes for known keys', () => {
const podTone = 'bg-amber-100 text-amber-700 dark:bg-amber-900/25 dark:text-amber-300';
expect(getRecoveryItemTypeBadgeClass('pod')).toBe(`${TABLE_BADGE_BASE_CLASSES} ${podTone}`);
expect(getRecoveryItemTypeBadgeClass('pvc')).toBe(
`${TABLE_BADGE_BASE_CLASSES} ${DEFAULT_BADGE_TONE_CLASSES}`,
);
});
});
});
@@ -1,129 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { getRecoveryItemTypePresentation } from '@/utils/recoveryItemTypePresentation';
// vitest hoists vi.mock above the imports. We wrap getResourceTypePresentation
// so that a single sentinel key returns null — reaching the otherwise-dead
// defensive `else` branch in the default case of getRecoveryItemTypePresentation
// — while delegating every other call to the real implementation. The factory
// cannot reference outer consts (it is hoisted), so the sentinel literal is
// duplicated below in FORCE_NULL_KEY.
vi.mock('@/utils/resourceTypePresentation', async () => {
const actual = await vi.importActual<typeof import('@/utils/resourceTypePresentation')>(
'@/utils/resourceTypePresentation',
);
return {
...actual,
getResourceTypePresentation: (
...args: Parameters<typeof actual.getResourceTypePresentation>
): ReturnType<typeof actual.getResourceTypePresentation> =>
args[0] === '__force_null__' ? null : actual.getResourceTypePresentation(...args),
};
});
// Must match the literal baked into the hoisted mock factory above.
const FORCE_NULL_KEY = '__force_null__';
// Badge-class building blocks mirrored from the source module so the assertions
// describe the documented badge composition rather than echoing the unit under
// test. These are the primitives the module concatenates.
const BADGE_BASE_CLASSES =
'inline-flex items-center rounded-sm px-2 py-0.5 text-[10px] font-medium whitespace-nowrap';
const TABLE_BADGE_BASE_CLASSES =
'inline-flex items-center px-1 py-0.5 text-[10px] font-medium rounded-sm whitespace-nowrap';
const DEFAULT_BADGE_TONE_CLASSES = 'bg-surface-alt text-base-content';
const DEFAULT_BADGE_CLASSES = `${BADGE_BASE_CLASSES} ${DEFAULT_BADGE_TONE_CLASSES}`;
const DEFAULT_TABLE_BADGE_CLASSES = `${TABLE_BADGE_BASE_CLASSES} ${DEFAULT_BADGE_TONE_CLASSES}`;
// Workload tones mirrored from workloadTypePresentation's PRESENTATION_MAP; the
// vm / system-container / app-container arms thread these through verbatim.
const VM_TONE = 'bg-blue-100 text-blue-700 dark:bg-blue-900/25 dark:text-blue-300';
const SYSTEM_CONTAINER_TONE =
'bg-green-100 text-green-700 dark:bg-green-900/25 dark:text-green-300';
const APP_CONTAINER_TONE = 'bg-sky-100 text-sky-700 dark:bg-sky-900/25 dark:text-sky-300';
describe('getRecoveryItemTypePresentation — branch coverage (branchcov2)', () => {
it('composes the full vm presentation (workload-map badge + table badge composition)', () => {
// Existing tests only assert key/label for this arm; this newly exercises
// the badgeClasses / tableBadgeClasses concatenation lines for case 'vm'.
expect(getRecoveryItemTypePresentation('vm')).toStrictEqual({
key: 'vm',
label: 'VM',
badgeClasses: `${BADGE_BASE_CLASSES} ${VM_TONE}`,
tableBadgeClasses: `${TABLE_BADGE_BASE_CLASSES} ${VM_TONE}`,
});
});
it('composes the full system-container presentation from the workload map', () => {
// case 'system-container' badge composition; the workload label is 'LXC'.
expect(getRecoveryItemTypePresentation('system-container')).toStrictEqual({
key: 'system-container',
label: 'LXC',
badgeClasses: `${BADGE_BASE_CLASSES} ${SYSTEM_CONTAINER_TONE}`,
tableBadgeClasses: `${TABLE_BADGE_BASE_CLASSES} ${SYSTEM_CONTAINER_TONE}`,
});
});
it('applies the App Container label override while keeping the app-container tone', () => {
// case 'app-container' is the only arm that forwards label/title overrides
// into getWorkloadTypePresentation; the override wins for `label` while the
// className still comes from the app-container workload entry.
expect(getRecoveryItemTypePresentation('app-container')).toStrictEqual({
key: 'app-container',
label: 'App Container',
badgeClasses: `${BADGE_BASE_CLASSES} ${APP_CONTAINER_TONE}`,
tableBadgeClasses: `${TABLE_BADGE_BASE_CLASSES} ${APP_CONTAINER_TONE}`,
});
});
it('composes the full cluster presentation from the k8s-cluster resource entry', () => {
// case 'cluster' badge composition; getResourceTypePresentation('k8s-cluster')
// yields the default tone, so the || DEFAULT_BADGE_TONE_CLASSES arm is NOT
// taken (presentation?.badgeClasses is truthy).
expect(getRecoveryItemTypePresentation('cluster')).toStrictEqual({
key: 'cluster',
label: 'K8s Cluster',
badgeClasses: `${BADGE_BASE_CLASSES} ${DEFAULT_BADGE_TONE_CLASSES}`,
tableBadgeClasses: `${TABLE_BADGE_BASE_CLASSES} ${DEFAULT_BADGE_TONE_CLASSES}`,
});
});
it('re-title-cases the key when the resource label equals the key (default, label === key arm)', () => {
// 'custom-thing' is unknown to both recovery and resource maps, so
// getResourceTypePresentation returns { label: 'custom-thing' } — equal to the
// key — driving the ternary onto its titleCaseDelimitedLabel arm and using
// presentation.badgeClasses for the badge strings.
expect(getRecoveryItemTypePresentation('custom-thing')).toStrictEqual({
key: 'custom-thing',
label: 'Custom Thing',
badgeClasses: `${BADGE_BASE_CLASSES} ${DEFAULT_BADGE_TONE_CLASSES}`,
tableBadgeClasses: `${TABLE_BADGE_BASE_CLASSES} ${DEFAULT_BADGE_TONE_CLASSES}`,
});
});
it('title-cases a hyphenated multi-token key whose first token is short (documents inert preserveShortAllCaps)', () => {
// NOTE: source passes preserveShortAllCaps:true to titleCaseDelimitedLabel,
// but normalizeRecoveryItemTypeQueryValue lowercases the key first, so the
// 'nfs' token is already 'nfs' by the time title-casing runs and the option
// cannot rescue it. The real, concrete output is therefore 'Nfs Share' —
// asserting it pins this (suspected) inert-option behaviour in place.
expect(getRecoveryItemTypePresentation('nfs-share')).toStrictEqual({
key: 'nfs-share',
label: 'Nfs Share',
badgeClasses: `${BADGE_BASE_CLASSES} ${DEFAULT_BADGE_TONE_CLASSES}`,
tableBadgeClasses: `${TABLE_BADGE_BASE_CLASSES} ${DEFAULT_BADGE_TONE_CLASSES}`,
});
});
it('falls back to default badges when the resource presentation is null (default, else arm)', () => {
// The sentinel key is force-mocked so getResourceTypePresentation returns
// null, reaching the defensive else-branch that yields DEFAULT_*_BADGE_CLASSES.
// '__force_null__' normalizes to itself and title-cases to 'Force Null'.
expect(getRecoveryItemTypePresentation(FORCE_NULL_KEY)).toStrictEqual({
key: FORCE_NULL_KEY,
label: 'Force Null',
badgeClasses: DEFAULT_BADGE_CLASSES,
tableBadgeClasses: DEFAULT_TABLE_BADGE_CLASSES,
});
});
});
@@ -1,82 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
getRecoveryPointItemTypeKey,
getRecoveryItemTypeBadgeClass,
getRecoveryItemTypeLabel,
getRecoveryItemTypePresentation,
getRecoveryRollupItemTypeKey,
normalizeRecoveryItemTypeQueryValue,
} from '@/utils/recoveryItemTypePresentation';
describe('recoveryItemTypePresentation', () => {
it('normalizes canonical recovery item type query values', () => {
expect(normalizeRecoveryItemTypeQueryValue('proxmox-vm')).toBe('vm');
expect(normalizeRecoveryItemTypeQueryValue('proxmox-vm-backup')).toBe('vm');
expect(normalizeRecoveryItemTypeQueryValue('k8s-pvc')).toBe('pvc');
expect(normalizeRecoveryItemTypeQueryValue('k8s-cluster')).toBe('cluster');
expect(normalizeRecoveryItemTypeQueryValue('kubernetes-cluster')).toBe('cluster');
expect(normalizeRecoveryItemTypeQueryValue('truenas-dataset')).toBe('dataset');
expect(normalizeRecoveryItemTypeQueryValue('docker-container')).toBe('app-container');
expect(normalizeRecoveryItemTypeQueryValue(' custom-thing ')).toBe('custom-thing');
expect(normalizeRecoveryItemTypeQueryValue('all')).toBe('');
});
it('returns canonical item type presentation for workload and storage subjects', () => {
expect(getRecoveryItemTypePresentation('vm')).toMatchObject({
key: 'vm',
label: 'VM',
});
expect(getRecoveryItemTypePresentation('system-container')).toMatchObject({
key: 'system-container',
label: 'LXC',
});
expect(getRecoveryItemTypePresentation('app-container')).toMatchObject({
key: 'app-container',
label: 'App Container',
});
expect(getRecoveryItemTypePresentation('dataset')).toMatchObject({
key: 'dataset',
label: 'Dataset',
});
expect(getRecoveryItemTypePresentation('task')).toMatchObject({
key: 'task',
label: 'Task',
});
expect(getRecoveryItemTypePresentation('cluster')).toMatchObject({
key: 'cluster',
label: 'K8s Cluster',
});
expect(getRecoveryItemTypePresentation('k8s-cluster')).toMatchObject({
key: 'cluster',
label: 'K8s Cluster',
});
});
it('falls back cleanly for unknown item types', () => {
expect(getRecoveryItemTypeLabel('custom-thing')).toBe('Custom Thing');
expect(getRecoveryItemTypeBadgeClass('custom-thing')).toContain(
'bg-surface-alt text-base-content',
);
expect(getRecoveryItemTypeBadgeClass('custom-thing')).toContain('inline-flex items-center');
expect(getRecoveryItemTypeBadgeClass('custom-thing')).toContain('px-1 py-0.5');
});
it('derives canonical item type keys from recovery rollups and points', () => {
expect(
getRecoveryRollupItemTypeKey({
display: { subjectType: 'proxmox-vm' },
subjectRef: { type: 'proxmox-vm' },
}),
).toBe('vm');
expect(
getRecoveryPointItemTypeKey({
display: { itemType: 'dataset' },
subjectRef: { type: 'truenas-dataset' },
}),
).toBe('dataset');
expect(getRecoveryPointItemTypeKey({ display: {}, subjectRef: { type: 'custom-thing' } })).toBe(
'custom-thing',
);
});
});
@@ -1,196 +0,0 @@
import type { RecoveryExternalRef } from '@/types/recovery';
import { getResourceTypePresentation } from '@/utils/resourceTypePresentation';
import { getWorkloadTypePresentation } from '@/utils/workloadTypePresentation';
import { titleCaseDelimitedLabel } from '@/utils/textPresentation';
export interface RecoveryItemTypePresentation {
key: string;
label: string;
badgeClasses: string;
tableBadgeClasses: string;
}
const BADGE_BASE_CLASSES =
'inline-flex items-center rounded-sm px-2 py-0.5 text-[10px] font-medium whitespace-nowrap';
const TABLE_BADGE_BASE_CLASSES =
'inline-flex items-center px-1 py-0.5 text-[10px] font-medium rounded-sm whitespace-nowrap';
const DEFAULT_BADGE_TONE_CLASSES = 'bg-surface-alt text-base-content';
const DEFAULT_BADGE_CLASSES = `${BADGE_BASE_CLASSES} ${DEFAULT_BADGE_TONE_CLASSES}`;
const DEFAULT_TABLE_BADGE_CLASSES = `${TABLE_BADGE_BASE_CLASSES} ${DEFAULT_BADGE_TONE_CLASSES}`;
interface RecoveryItemTypeLike {
display?: {
itemType?: string | null;
subjectType?: string | null;
} | null;
itemRef?: RecoveryExternalRef | null;
subjectRef?: RecoveryExternalRef | null;
}
const getRecoveryItemTypeValue = (value: RecoveryItemTypeLike | null | undefined): string =>
String(
value?.display?.itemType ||
value?.display?.subjectType ||
value?.itemRef?.type ||
value?.subjectRef?.type ||
'',
);
export const normalizeRecoveryItemTypeQueryValue = (value: string | null | undefined): string => {
const normalized = String(value || '')
.trim()
.toLowerCase();
switch (normalized) {
case '':
case 'all':
return '';
case 'proxmox-vm':
case 'proxmox-vm-backup':
case 'vm':
case 'vm-backup':
return 'vm';
case 'proxmox-lxc':
case 'lxc':
case 'ct':
case 'container':
case 'system-container':
return 'system-container';
case 'docker-container':
case 'docker':
case 'app-container':
return 'app-container';
case 'oci-container':
return 'system-container';
case 'k8s-pod':
case 'pod':
return 'pod';
case 'k8s-pvc':
case 'pvc':
return 'pvc';
case 'k8s-cluster':
case 'kubernetes-cluster':
case 'cluster':
return 'cluster';
case 'truenas-dataset':
case 'dataset':
return 'dataset';
case 'velero-backup':
return 'velero-backup';
case 'proxmox-guest':
case 'guest':
return 'guest';
default:
if (normalized.startsWith('proxmox-')) return normalized.slice('proxmox-'.length);
if (normalized.startsWith('truenas-')) return normalized.slice('truenas-'.length);
if (normalized.startsWith('k8s-')) return normalized.slice('k8s-'.length);
return normalized;
}
};
export const getRecoveryItemTypePresentation = (
value: string | null | undefined,
): RecoveryItemTypePresentation | null => {
const key = normalizeRecoveryItemTypeQueryValue(value);
if (!key) return null;
switch (key) {
case 'vm': {
const presentation = getWorkloadTypePresentation('vm');
return {
key,
label: presentation.label,
badgeClasses: `${BADGE_BASE_CLASSES} ${presentation.className}`,
tableBadgeClasses: `${TABLE_BADGE_BASE_CLASSES} ${presentation.className}`,
};
}
case 'system-container': {
const presentation = getWorkloadTypePresentation('system-container');
return {
key,
label: presentation.label,
badgeClasses: `${BADGE_BASE_CLASSES} ${presentation.className}`,
tableBadgeClasses: `${TABLE_BADGE_BASE_CLASSES} ${presentation.className}`,
};
}
case 'app-container': {
const presentation = getWorkloadTypePresentation('app-container', {
label: 'App Container',
title: 'Application Container',
});
return {
key,
label: presentation.label,
badgeClasses: `${BADGE_BASE_CLASSES} ${presentation.className}`,
tableBadgeClasses: `${TABLE_BADGE_BASE_CLASSES} ${presentation.className}`,
};
}
case 'pod': {
const presentation = getWorkloadTypePresentation('pod');
return {
key,
label: presentation.label,
badgeClasses: `${BADGE_BASE_CLASSES} ${presentation.className}`,
tableBadgeClasses: `${TABLE_BADGE_BASE_CLASSES} ${presentation.className}`,
};
}
case 'pvc': {
const presentation = getResourceTypePresentation('k8s-pvc');
return {
key,
label: presentation?.label || 'PVC',
badgeClasses: `${BADGE_BASE_CLASSES} ${presentation?.badgeClasses || DEFAULT_BADGE_TONE_CLASSES}`,
tableBadgeClasses: `${TABLE_BADGE_BASE_CLASSES} ${presentation?.badgeClasses || DEFAULT_BADGE_TONE_CLASSES}`,
};
}
case 'cluster': {
const presentation = getResourceTypePresentation('k8s-cluster');
return {
key,
label: presentation?.label || 'K8s Cluster',
badgeClasses: `${BADGE_BASE_CLASSES} ${presentation?.badgeClasses || DEFAULT_BADGE_TONE_CLASSES}`,
tableBadgeClasses: `${TABLE_BADGE_BASE_CLASSES} ${presentation?.badgeClasses || DEFAULT_BADGE_TONE_CLASSES}`,
};
}
default: {
const presentation = getResourceTypePresentation(key);
if (presentation) {
const label =
presentation.label === key
? titleCaseDelimitedLabel(key, {
fallback: 'Unknown',
preserveShortAllCaps: true,
})
: presentation.label;
return {
key,
label,
badgeClasses: `${BADGE_BASE_CLASSES} ${presentation.badgeClasses}`,
tableBadgeClasses: `${TABLE_BADGE_BASE_CLASSES} ${presentation.badgeClasses}`,
};
}
return {
key,
label: titleCaseDelimitedLabel(key, {
fallback: 'Unknown',
preserveShortAllCaps: true,
}),
badgeClasses: DEFAULT_BADGE_CLASSES,
tableBadgeClasses: DEFAULT_TABLE_BADGE_CLASSES,
};
}
}
};
export const getRecoveryItemTypeLabel = (value: string | null | undefined): string =>
getRecoveryItemTypePresentation(value)?.label || '';
export const getRecoveryItemTypeBadgeClass = (value: string | null | undefined): string =>
getRecoveryItemTypePresentation(value)?.tableBadgeClasses || DEFAULT_TABLE_BADGE_CLASSES;
export const getRecoveryRollupItemTypeKey = (
rollup: RecoveryItemTypeLike | null | undefined,
): string => normalizeRecoveryItemTypeQueryValue(getRecoveryItemTypeValue(rollup));
export const getRecoveryPointItemTypeKey = (
point: RecoveryItemTypeLike | null | undefined,
): string => normalizeRecoveryItemTypeQueryValue(getRecoveryItemTypeValue(point));