Add platform support manifest

This commit is contained in:
rcourtman 2026-04-10 16:38:06 +01:00
parent 85e8ea6e78
commit ab3e028359
13 changed files with 733 additions and 292 deletions

1
.gitignore vendored
View file

@ -209,6 +209,7 @@ docs/release-control/v6/*
!docs/release-control/v6/internal/
docs/release-control/v6/internal/*
!docs/release-control/v6/internal/*.md
!docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json
!docs/release-control/v6/internal/subsystems/
!docs/release-control/v6/internal/subsystems/*.json
!docs/release-control/v6/internal/subsystems/*.schema.json

View file

@ -0,0 +1,140 @@
{
"schema_version": 1,
"default_infrastructure_source_order": [
"proxmox-pve",
"agent",
"docker",
"proxmox-pbs",
"proxmox-pmg",
"kubernetes",
"truenas"
],
"platforms": [
{
"id": "agent",
"governance_state": "supported",
"ui_label": "Agent",
"ui_tone": "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-400",
"aliases": [],
"display_tokens": ["Agent"],
"storage_family": "onprem"
},
{
"id": "docker",
"governance_state": "supported",
"ui_label": "Containers",
"ui_tone": "bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-400",
"aliases": [],
"display_tokens": ["Containers", "Docker"],
"storage_family": "container"
},
{
"id": "kubernetes",
"governance_state": "supported",
"ui_label": "K8s",
"ui_tone": "bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-400",
"aliases": ["k8s"],
"display_tokens": ["K8s", "Kubernetes"],
"storage_family": "container"
},
{
"id": "proxmox-pve",
"governance_state": "supported",
"ui_label": "PVE",
"ui_tone": "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-400",
"aliases": ["pve", "proxmox"],
"display_tokens": ["PVE", "Proxmox VE"],
"storage_family": "virtualization"
},
{
"id": "proxmox-pbs",
"governance_state": "supported",
"ui_label": "PBS",
"ui_tone": "bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-400",
"aliases": ["pbs"],
"display_tokens": ["PBS", "Proxmox Backup Server"],
"storage_family": "virtualization"
},
{
"id": "proxmox-pmg",
"governance_state": "supported",
"ui_label": "PMG",
"ui_tone": "bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-400",
"aliases": ["pmg"],
"display_tokens": ["PMG", "Proxmox Mail Gateway"],
"storage_family": "virtualization"
},
{
"id": "truenas",
"governance_state": "supported",
"ui_label": "TrueNAS",
"ui_tone": "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-400",
"aliases": [],
"display_tokens": ["TrueNAS"],
"storage_family": "onprem"
},
{
"id": "vmware-vsphere",
"governance_state": "admitted",
"ui_label": "vSphere",
"ui_tone": "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
"aliases": ["vmware"],
"display_tokens": ["vSphere", "VMware vSphere"],
"storage_family": "virtualization"
},
{
"id": "unraid",
"governance_state": "presentation-only",
"ui_label": "Unraid",
"ui_tone": "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300",
"aliases": [],
"display_tokens": ["Unraid"],
"storage_family": "onprem"
},
{
"id": "synology-dsm",
"governance_state": "presentation-only",
"ui_label": "Synology",
"ui_tone": "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
"aliases": [],
"display_tokens": ["Synology", "DSM"],
"storage_family": "onprem"
},
{
"id": "microsoft-hyperv",
"governance_state": "presentation-only",
"ui_label": "Hyper-V",
"ui_tone": "bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300",
"aliases": ["hyper-v"],
"display_tokens": ["Hyper-V"],
"storage_family": "virtualization"
},
{
"id": "aws",
"governance_state": "presentation-only",
"ui_label": "AWS",
"ui_tone": "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
"aliases": [],
"display_tokens": ["AWS"],
"storage_family": "cloud"
},
{
"id": "azure",
"governance_state": "presentation-only",
"ui_label": "Azure",
"ui_tone": "bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-300",
"aliases": [],
"display_tokens": ["Azure"],
"storage_family": "cloud"
},
{
"id": "gcp",
"governance_state": "presentation-only",
"ui_label": "GCP",
"ui_tone": "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
"aliases": [],
"display_tokens": ["GCP"],
"storage_family": "cloud"
}
]
}

View file

@ -208,6 +208,27 @@ Rules:
6. `proxmox-pmg`
7. `truenas`
### Admitted platforms (not yet supported)
1. `vmware-vsphere`
### Presentation-only platform vocabulary
1. `unraid`
2. `synology-dsm`
3. `microsoft-hyperv`
4. `aws`
5. `azure`
6. `gcp`
### Machine-readable projection
`PLATFORM_SUPPORT_MANIFEST.json` is the machine-readable projection of the
supported, admitted, and presentation-only platform vocabulary declared here.
Tests and shared frontend vocabulary may consume that manifest, but it must not
introduce platform ids or governance states that are not declared in this
document.
### Runtime variants
1. `podman` is a runtime variant inside `docker`, surfaced through runtime
@ -234,32 +255,33 @@ Rules:
## Current Support Matrix
| Platform | Family | Primary mode | Optional augmentation | Canonical projections |
| --- | --- | --- | --- | --- |
| `agent` | Pulse-managed host | agent-backed | none | `agent`, `storage`, `physical-disk` |
| `docker` | container runtime | agent-backed | none | `agent`, `app-container`, `docker-service` |
| `kubernetes` | cluster runtime | agent-backed | none | `k8s-cluster`, `k8s-node`, `pod`, `k8s-deployment` |
| `proxmox-pve` | Proxmox | api-backed | host agent may augment into hybrid | `agent`, `vm`, `system-container`, `storage`, `ceph`, `physical-disk` |
| `proxmox-pbs` | Proxmox | api-backed | host agent may augment into hybrid | `pbs`, `storage` |
| `proxmox-pmg` | Proxmox | api-backed | none today | `pmg` |
| `truenas` | TrueNAS | api-backed | host agent may augment into hybrid | `agent`, `app-container`, `storage`, `physical-disk` |
| Platform | Family | Primary mode | Optional augmentation | Canonical projections |
| ------------- | ------------------ | ------------ | ---------------------------------- | --------------------------------------------------------------------- |
| `agent` | Pulse-managed host | agent-backed | none | `agent`, `storage`, `physical-disk` |
| `docker` | container runtime | agent-backed | none | `agent`, `app-container`, `docker-service` |
| `kubernetes` | cluster runtime | agent-backed | none | `k8s-cluster`, `k8s-node`, `pod`, `k8s-deployment` |
| `proxmox-pve` | Proxmox | api-backed | host agent may augment into hybrid | `agent`, `vm`, `system-container`, `storage`, `ceph`, `physical-disk` |
| `proxmox-pbs` | Proxmox | api-backed | host agent may augment into hybrid | `pbs`, `storage` |
| `proxmox-pmg` | Proxmox | api-backed | none today | `pmg` |
| `truenas` | TrueNAS | api-backed | host agent may augment into hybrid | `agent`, `app-container`, `storage`, `physical-disk` |
| Platform | Setup | Visibility | Workloads | Storage | Recovery | Alerts | Assistant read | Assistant control |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `agent` | install workspace | supported | `n/a` | supported | `n/a` | supported | supported | supported |
| `docker` | install workspace / runtime enablement | supported | supported | `n/a` | `n/a` | supported | supported | supported |
| `kubernetes` | install workspace / runtime enablement | supported | supported | `n/a` | supported | supported | supported | supported |
| `proxmox-pve` | platform connections | supported | supported | supported | supported | supported | supported | augmentation-only |
| `proxmox-pbs` | platform connections | supported | `n/a` | supported | supported | supported | supported | read-only |
| `proxmox-pmg` | platform connections | supported | `n/a` | `n/a` | `n/a` | supported | supported | read-only |
| `truenas` | platform connections | supported | supported | supported | supported | supported | supported | supported |
| Platform | Setup | Visibility | Workloads | Storage | Recovery | Alerts | Assistant read | Assistant control |
| ------------- | -------------------------------------- | ---------- | --------- | --------- | --------- | --------- | -------------- | ----------------- |
| `agent` | install workspace | supported | `n/a` | supported | `n/a` | supported | supported | supported |
| `docker` | install workspace / runtime enablement | supported | supported | `n/a` | `n/a` | supported | supported | supported |
| `kubernetes` | install workspace / runtime enablement | supported | supported | `n/a` | supported | supported | supported | supported |
| `proxmox-pve` | platform connections | supported | supported | supported | supported | supported | supported | augmentation-only |
| `proxmox-pbs` | platform connections | supported | `n/a` | supported | supported | supported | supported | read-only |
| `proxmox-pmg` | platform connections | supported | `n/a` | `n/a` | `n/a` | supported | supported | read-only |
| `truenas` | platform connections | supported | supported | supported | supported | supported | supported | supported |
## Current Inconsistencies To Treat Explicitly
1. `frontend-modern/src/utils/sourcePlatforms.ts` already carries future labels
such as `vmware-vsphere`, `microsoft-hyperv`, `aws`, `azure`, `gcp`,
`unraid`, and `synology-dsm`. Those are presentation vocabulary only. They
are not admitted first-class platforms until governance says so here.
1. `PLATFORM_SUPPORT_MANIFEST.json` intentionally carries admitted and
presentation-only ids such as `vmware-vsphere`, `microsoft-hyperv`, `aws`,
`azure`, `gcp`, `unraid`, and `synology-dsm` so shared tests and frontend
vocabulary can normalize them consistently. Those ids must not be
interpreted as current support unless the classifications above say so.
2. Recovery provider strings are intentionally forward-compatible and already
include values such as `docker`, `agent`, and `proxmox-pmg`. Those strings
do not mean recovery support exists until the platform matrix above marks
@ -323,8 +345,8 @@ starts.
artifacts out of the phase-1 projection contract unless a later governed
slice proves they belong on the shared path
| Platform | Family | Entry point | Primary mode | Optional augmentation | Canonical projections | Admission state | Readiness stage |
| --- | --- | --- | --- | --- | --- | --- | --- |
| Platform | Family | Entry point | Primary mode | Optional augmentation | Canonical projections | Admission state | Readiness stage |
| ---------------- | ------ | ------------------------- | ------------ | -------------------------------------- | ------------------------ | ---------------------------------------------- | ----------------- |
| `vmware-vsphere` | VMware | `vCenter` only in phase 1 | `api-backed` | host or guest agent later, not phase 1 | `agent`, `vm`, `storage` | architecture locked, not yet in support matrix | `first-lab-ready` |
## VMware vSphere Proposed Phase-1 Floor
@ -332,9 +354,9 @@ starts.
This is the proposed phase-1 support floor once implementation and proof land.
It is not a claim that VMware is currently supported in Pulse.
| Platform | Setup | Visibility | Workloads | Storage | Recovery | Alerts | Assistant read | Assistant control |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `vmware-vsphere` | platform connections to `vCenter` only | supported | supported | supported | `n/a` | supported | supported | read-only |
| Platform | Setup | Visibility | Workloads | Storage | Recovery | Alerts | Assistant read | Assistant control |
| ---------------- | -------------------------------------- | ---------- | --------- | --------- | -------- | --------- | -------------- | ----------------- |
| `vmware-vsphere` | platform connections to `vCenter` only | supported | supported | supported | `n/a` | supported | supported | read-only |
Phase-1 floor details:

View file

@ -9,7 +9,11 @@
"contract_file": "docs/release-control/v6/internal/subsystems/frontend-primitives.md",
"status_file": "docs/release-control/v6/internal/status.json",
"registry_file": "docs/release-control/v6/internal/subsystems/registry.json",
"dependency_subsystem_ids": ["agent-lifecycle", "cloud-paid", "storage-recovery"]
"dependency_subsystem_ids": [
"agent-lifecycle",
"cloud-paid",
"storage-recovery"
]
}
```
@ -171,11 +175,12 @@ work extends shared components instead of creating new local variants.
4. Add guardrail tests when a new shared pattern is introduced
5. Keep shared platform-connections shell state on the reusable settings boundary: `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`, `frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`, and `frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx` must continue to derive provider counts, availability, and shared subtab copy from one infrastructure-settings source instead of creating provider-local summary fetches or VMware-only shell vocabulary.
6. Keep shared storage feature presenters on canonical platform truth. When reusable storage presenters under `frontend-modern/src/features/storageBackups/` classify canonical resources for the shared storage route, API-backed virtualization datastores such as VMware must stay inventory-only datastores instead of inheriting PBS-specific backup-repository or protected-target copy from older fallback branches.
7. Keep top-of-page summary interaction on shared primitives. Infrastructure, workloads, and storage summary cards must route sticky-shell behavior through `frontend-modern/src/components/shared/StickySummarySection.tsx` and route row-hover or focused-series rendering through shared chart primitives such as `frontend-modern/src/components/shared/InteractiveSparkline.tsx` and `frontend-modern/src/components/shared/DensityMap.tsx`, rather than page-local sticky wrappers or metric-card-specific hover logic. The shared summary-card contract must also own stable summary-card geometry for chart-backed cards so row hover, focus, synchronized readouts, or idle header metadata cannot ratchet the sticky summary taller across rerenders.
8. Keep summary chart interaction identity on one shared helper. Summary surfaces that expose row-hover, group-hover, chart-hover, or route-focus-driven chart emphasis must derive page/group/entity scope through `frontend-modern/src/components/shared/summaryCardInteraction.ts` and pass that same resolved scope into card-state, sparkline, and density-map primitives, rather than letting cards read `hovered || focused` while charts listen to a different page-local ID source. Hovering one summary chart must promote that series into the shared active entity so sibling cards highlight the same object instead of keeping chart-local hover islands, and hovering or pinning a workload group header, infrastructure cluster header, or storage pool-group header must scope the matching summary cards through that same shared contract instead of forking a page-local summary filter path. Sibling cards should surface that synchronized hover as one compact header readout through the shared summary-card contract, while the chart under the pointer keeps the only floating tooltip. `frontend-modern/src/components/Recovery/RecoverySummary.tsx` is explicitly outside this interaction dialect: recovery posture cards may share summary framing, but they must not silently grow row/group/chart hover behavior without a separate governed product decision.
9. Keep page summaries page-scoped when table rows enter contextual focus. Route-backed row selection may add a focused label and shared series emphasis, but infrastructure, workloads, and storage summary cards must continue to render the page-level series set instead of collapsing the summary down to the selected row or replacing the global trend view with row-local empty states.
10. Keep contextual row focus on the shared summary primitive. Summary surfaces and same-route table drill-ins must reuse `frontend-modern/src/components/shared/contextualFocus.ts` for interactive-series filtering, focused-name lookup, active-series derivation, local scroll preservation, and deliberate inline-detail reveal instead of rebuilding page-local `Set` filters, focused-label scans, drawer-aware scroll math, or ad hoc scroll restoration in each surface.
11. Keep summary-to-table coordination deliberate, explicit, and reversible. Shared summary hover may highlight the matching table row when it is already visible, but transient chart hover must not auto-filter tables, auto-scroll the page, or reshuffle table ordering. Pinned page/group/entity scope on workloads, infrastructure, or storage must stay row-first: the pinned row or group header is the visible scoped state, not a second strip or search-row widget. Page shells therefore must not reintroduce always-on scope banners, preview bars, page-local chips, breadcrumbs, or search/filter-row scope accessories just to explain pinned state. When the active row is off-screen, page owners must still route through `frontend-modern/src/components/shared/summaryTableFocus.ts` and surface a lightweight `Jump to row` affordance that reveals and scrolls only on explicit user action. That same shared table-focus owner now also owns reversible clearing: pinned scope may clear only from governed neutral interaction-surface space or the shared `Escape` path, with page owners binding a broader clear-surface root separately from the row-lookup table root when needed and supplying one page-level reset callback for filters plus summary-linked selections. Row cells, group headers, inline detail, summary cards, and explicit controls must not accidentally clear pinned scope, while governed table/card clear surfaces must still allow real user clicks on neutral whitespace to clear it. Deliberate row focus may reveal inline detail automatically, but that reveal must be drawer-aware: infrastructure and workload row toggles that already have the row in view must hand the current `.app-scroll-shell` position through `frontend-modern/src/utils/appShellScrollRestoration.ts` so the remounted shell in `frontend-modern/src/App.tsx` can reopen the inline detail without looking like a page refresh, and then still route through the shared reveal helper whenever the opened drawer would otherwise land below the fold. Same-route drawers must therefore scroll only enough to keep the row header plus the top of the inline detail visible, never hard-center the row just because the route state changed.
7. Keep shared source/platform vocabulary on the governed manifest boundary. `frontend-modern/src/utils/platformSupportManifest.ts`, `frontend-modern/src/utils/sourcePlatforms.ts`, `frontend-modern/src/utils/sourcePlatformOptions.ts`, and `frontend-modern/scripts/canonical-platform-audit.mjs` must derive supported, admitted, and presentation-only platform ids from `docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json` instead of embedding divergent future-label lists in frontend helpers, audit rules, or storage presenters.
8. Keep top-of-page summary interaction on shared primitives. Infrastructure, workloads, and storage summary cards must route sticky-shell behavior through `frontend-modern/src/components/shared/StickySummarySection.tsx` and route row-hover or focused-series rendering through shared chart primitives such as `frontend-modern/src/components/shared/InteractiveSparkline.tsx` and `frontend-modern/src/components/shared/DensityMap.tsx`, rather than page-local sticky wrappers or metric-card-specific hover logic. The shared summary-card contract must also own stable summary-card geometry for chart-backed cards so row hover, focus, synchronized readouts, or idle header metadata cannot ratchet the sticky summary taller across rerenders.
9. Keep summary chart interaction identity on one shared helper. Summary surfaces that expose row-hover, group-hover, chart-hover, or route-focus-driven chart emphasis must derive page/group/entity scope through `frontend-modern/src/components/shared/summaryCardInteraction.ts` and pass that same resolved scope into card-state, sparkline, and density-map primitives, rather than letting cards read `hovered || focused` while charts listen to a different page-local ID source. Hovering one summary chart must promote that series into the shared active entity so sibling cards highlight the same object instead of keeping chart-local hover islands, and hovering or pinning a workload group header, infrastructure cluster header, or storage pool-group header must scope the matching summary cards through that same shared contract instead of forking a page-local summary filter path. Sibling cards should surface that synchronized hover as one compact header readout through the shared summary-card contract, while the chart under the pointer keeps the only floating tooltip. `frontend-modern/src/components/Recovery/RecoverySummary.tsx` is explicitly outside this interaction dialect: recovery posture cards may share summary framing, but they must not silently grow row/group/chart hover behavior without a separate governed product decision.
10. Keep page summaries page-scoped when table rows enter contextual focus. Route-backed row selection may add a focused label and shared series emphasis, but infrastructure, workloads, and storage summary cards must continue to render the page-level series set instead of collapsing the summary down to the selected row or replacing the global trend view with row-local empty states.
11. Keep contextual row focus on the shared summary primitive. Summary surfaces and same-route table drill-ins must reuse `frontend-modern/src/components/shared/contextualFocus.ts` for interactive-series filtering, focused-name lookup, active-series derivation, local scroll preservation, and deliberate inline-detail reveal instead of rebuilding page-local `Set` filters, focused-label scans, drawer-aware scroll math, or ad hoc scroll restoration in each surface.
12. Keep summary-to-table coordination deliberate, explicit, and reversible. Shared summary hover may highlight the matching table row when it is already visible, but transient chart hover must not auto-filter tables, auto-scroll the page, or reshuffle table ordering. Pinned page/group/entity scope on workloads, infrastructure, or storage must stay row-first: the pinned row or group header is the visible scoped state, not a second strip or search-row widget. Page shells therefore must not reintroduce always-on scope banners, preview bars, page-local chips, breadcrumbs, or search/filter-row scope accessories just to explain pinned state. When the active row is off-screen, page owners must still route through `frontend-modern/src/components/shared/summaryTableFocus.ts` and surface a lightweight `Jump to row` affordance that reveals and scrolls only on explicit user action. That same shared table-focus owner now also owns reversible clearing: pinned scope may clear only from governed neutral interaction-surface space or the shared `Escape` path, with page owners binding a broader clear-surface root separately from the row-lookup table root when needed and supplying one page-level reset callback for filters plus summary-linked selections. Row cells, group headers, inline detail, summary cards, and explicit controls must not accidentally clear pinned scope, while governed table/card clear surfaces must still allow real user clicks on neutral whitespace to clear it. Deliberate row focus may reveal inline detail automatically, but that reveal must be drawer-aware: infrastructure and workload row toggles that already have the row in view must hand the current `.app-scroll-shell` position through `frontend-modern/src/utils/appShellScrollRestoration.ts` so the remounted shell in `frontend-modern/src/App.tsx` can reopen the inline detail without looking like a page refresh, and then still route through the shared reveal helper whenever the opened drawer would otherwise land below the fold. Same-route drawers must therefore scroll only enough to keep the row header plus the top of the inline detail visible, never hard-center the row just because the route state changed.
Shared summary-linked rows and group headers must also route their preview
semantics through
`frontend-modern/src/components/shared/summaryInteractionA11y.ts`.
@ -196,8 +201,8 @@ work extends shared components instead of creating new local variants.
reset action stays as one compact header-level `Clear` control with an
accessible `Clear selection` label, not a second page-level scope strip,
search-row accessory, or filter-bar badge.
12. Keep summary-linked table row emphasis on the shared primitive contract. Workloads, infrastructure, and storage rows that mirror the active summary entity must expose that state through `data-summary-row-active` and let the shared presentation in `frontend-modern/src/index.css` render the row emphasis, rather than carrying page-local sky or blue fill classes inside each row renderer. Group-scoped preview and pin must use that same shared presentation boundary: child rows that belong to a hovered or pinned summary group should expose `data-summary-group-member-active="preview|pinned"` so the block-level emphasis stays subtle, consistent, and reversible instead of each table inventing its own outline, badge, or full-strength fill treatment.
13. Keep retained-value data loading honest at the ownership boundary. Helpers
13. Keep summary-linked table row emphasis on the shared primitive contract. Workloads, infrastructure, and storage rows that mirror the active summary entity must expose that state through `data-summary-row-active` and let the shared presentation in `frontend-modern/src/index.css` render the row emphasis, rather than carrying page-local sky or blue fill classes inside each row renderer. Group-scoped preview and pin must use that same shared presentation boundary: child rows that belong to a hovered or pinned summary group should expose `data-summary-group-member-active="preview|pinned"` so the block-level emphasis stays subtle, consistent, and reversible instead of each table inventing its own outline, badge, or full-strength fill treatment.
14. Keep retained-value data loading honest at the ownership boundary. Helpers
that prevent a feature surface from falling through the app-level Suspense
boundary during in-flight refresh should stay feature-local until multiple
governed surfaces truly share the behavior. Once that boundary is shared,
@ -205,7 +210,7 @@ work extends shared components instead of creating new local variants.
`frontend-modern/src/hooks/createNonSuspendingQuery.ts` rather than
re-copying suspense-escape logic into each feature area or burying it
inside one feature's private state model.
14. Keep shared commercial warning banners truthful about destination intent.
15. Keep shared commercial warning banners truthful about destination intent.
When a shared banner renders both explanatory and commercial CTAs, those
labels must resolve to distinct owned destinations or section anchors
instead of presenting two different labels that land on the same
@ -216,7 +221,7 @@ work extends shared components instead of creating new local variants.
presentation helper and suppress usage summaries, upgrade pressure, and
upgrade-impression telemetry rather than rendering stale `current/limit`
counts or paid-plan CTAs from banner-local availability checks.
15. Keep assistant availability bootstrap on the shared app-shell boundary.
16. Keep assistant availability bootstrap on the shared app-shell boundary.
`frontend-modern/src/useAppRuntimeState.ts`,
`frontend-modern/src/App.tsx`,
`frontend-modern/src/stores/aiChat.ts`,
@ -293,58 +298,58 @@ work extends shared components instead of creating new local variants.
IDs into setup payloads. The shared settings shell should let the backend
resolve the effective BYOK model and then render that returned state rather
than guessing a model in the modal.
10. Keep shared filter primitives coherent with route-owned option hydration.
11. Keep shared filter primitives coherent with route-owned option hydration.
Feature shells such as `frontend-modern/src/features/infrastructure/`
must keep a route-owned canonical option visible in shared selects like
`LabeledFilterSelect` even when current results do not contain that
option, so provider-scoped handoffs do not flash back to `All`.
11. Keep the first welcome screen in
12. Keep the first welcome screen in
`frontend-modern/src/components/SetupWizard/steps/WelcomeStep.tsx`
explicit about operator context. The shell must explain that the bootstrap
token only unlocks first-run setup, state where the command should run, and
adapt command/help text to detected Docker or containerized deployments
instead of assuming the operator already knows which host or container owns
the Pulse install.
12. Keep the settings-shell infrastructure landing path aligned with that same
13. Keep the settings-shell infrastructure landing path aligned with that same
first-session story. `frontend-modern/src/components/Settings/settingsNavigationModel.ts`
must treat `/settings` and the infrastructure settings tab as the canonical
path to `/settings/infrastructure/install`, not to reporting/control, so
the shell does not send first-time operators to the wrong infrastructure
subview by default.
13. Keep dashboard onboarding copy on the shared presentation owner in
14. Keep dashboard onboarding copy on the shared presentation owner in
`frontend-modern/src/utils/dashboardEmptyStatePresentation.ts`. Both the
infrastructure empty state and the dashboard route's no-resources state
must name the canonical install workspace explicitly, keep `Platform
connections` visible as the API-backed alternative for Proxmox and
TrueNAS, and expose the same first-host next step instead of falling back
to passive “nothing here yet” wording.
14. Keep cross-surface investigation handoffs on shared route ownership.
15. Keep cross-surface investigation handoffs on shared route ownership.
Feature shells such as Alerts and Patrol may decide which governed
destination chips to render, but canonical href, label, dedupe, and
infrastructure-fallback truth must stay in
`frontend-modern/src/routing/resourceLinks.ts` instead of freezing raw
route strings or provider-local link builders inside feature panels.
15. Keep shared summary-card emphasis coherent. When shared summary primitives enter an `inactive` state, `SummaryMetricCard`, `InteractiveSparkline`, and `DensityMap` must all demote background context together so storage, infrastructure, and workloads read as one interaction model instead of mixing page-local opacity, sticky-shell, or highlight rules.
16. Keep density-map summaries overview-first. When a shared summary density map receives row focus or chart-hover emphasis, `frontend-modern/src/components/shared/DensityMap.tsx`, `frontend-modern/src/components/shared/useDensityMapState.ts`, and `frontend-modern/src/components/shared/densityMapModel.ts` must preserve the multi-entity overview rows and keep focused-entity detail in the hover tooltip instead of swapping the card into a single-series chart, dimming the rest of the map into unusable background noise, duplicating cursor-value tooltip copy, or adding persistent card chrome that steals heatmap space. The card body must stay overview-first; the tooltip may carry the active entity identity, current value, and peak, shared tooltip shells must follow semantic surface tokens instead of forcing a dark palette in light mode, the tooltip header must let long entity names consume the available width before truncating rather than clipping against an arbitrary fixed label cap, numeric metric readouts such as `16.9 MB/s` or `37.4 MB/s` must stay single-line instead of wrapping the unit onto a second row, and density-map detail that cannot fit cleanly inside the canonical tooltip shell must be omitted rather than introducing tooltip-specific chrome or a secondary chart inside the hover surface.
17. Keep shared commercial and Patrol quickstart presenters on runtime-backed
16. Keep shared summary-card emphasis coherent. When shared summary primitives enter an `inactive` state, `SummaryMetricCard`, `InteractiveSparkline`, and `DensityMap` must all demote background context together so storage, infrastructure, and workloads read as one interaction model instead of mixing page-local opacity, sticky-shell, or highlight rules.
17. Keep density-map summaries overview-first. When a shared summary density map receives row focus or chart-hover emphasis, `frontend-modern/src/components/shared/DensityMap.tsx`, `frontend-modern/src/components/shared/useDensityMapState.ts`, and `frontend-modern/src/components/shared/densityMapModel.ts` must preserve the multi-entity overview rows and keep focused-entity detail in the hover tooltip instead of swapping the card into a single-series chart, dimming the rest of the map into unusable background noise, duplicating cursor-value tooltip copy, or adding persistent card chrome that steals heatmap space. The card body must stay overview-first; the tooltip may carry the active entity identity, current value, and peak, shared tooltip shells must follow semantic surface tokens instead of forcing a dark palette in light mode, the tooltip header must let long entity names consume the available width before truncating rather than clipping against an arbitrary fixed label cap, numeric metric readouts such as `16.9 MB/s` or `37.4 MB/s` must stay single-line instead of wrapping the unit onto a second row, and density-map detail that cannot fit cleanly inside the canonical tooltip shell must be omitted rather than introducing tooltip-specific chrome or a secondary chart inside the hover surface.
18. Keep shared commercial and Patrol quickstart presenters on runtime-backed
wording. Reusable shells and helper-driven badges must describe quickstart
as Patrol runs or Patrol-only activation support when that is the governed
backend truth, and must not drift back to generic hosted-chat or generic
AI-credit claims.
17. Keep sparkline scrubbing source-local and sibling-sync timestamp-based. The chart a user is actively scrubbing in `frontend-modern/src/components/shared/InteractiveSparkline.tsx` and `frontend-modern/src/components/shared/useInteractiveSparklineState.ts` must keep its dashed hover cursor on the real local mouse `x`, while sibling cards may map the shared hover timestamp onto their own timelines. Shared cursor sync must not snap the source chart back onto the nearest sample timestamp, the rendered SVG/canvas hover cursor must bind to the actual numeric cursor coordinate rather than a boolean guard state, the time cursor must span the chart viewport instead of collapsing to the series height, and the hover tooltip must track the pointer instead of anchoring to the chart top edge while following the active theme rather than a hardcoded dark shell.
18. Keep shared contextual focus canonical after adoption. Once a summary or table surface enters route-backed contextual focus, future additions must extend `frontend-modern/src/components/shared/contextualFocus.ts` and its guardrail tests rather than forking another helper for workload IDs, resource IDs, or scroll-preserving same-route selection.
19. Keep shared infrastructure/resource selectors on the canonical agent-facet
19. Keep sparkline scrubbing source-local and sibling-sync timestamp-based. The chart a user is actively scrubbing in `frontend-modern/src/components/shared/InteractiveSparkline.tsx` and `frontend-modern/src/components/shared/useInteractiveSparklineState.ts` must keep its dashed hover cursor on the real local mouse `x`, while sibling cards may map the shared hover timestamp onto their own timelines. Shared cursor sync must not snap the source chart back onto the nearest sample timestamp, the rendered SVG/canvas hover cursor must bind to the actual numeric cursor coordinate rather than a boolean guard state, the time cursor must span the chart viewport instead of collapsing to the series height, and the hover tooltip must track the pointer instead of anchoring to the chart top edge while following the active theme rather than a hardcoded dark shell.
20. Keep shared contextual focus canonical after adoption. Once a summary or table surface enters route-backed contextual focus, future additions must extend `frontend-modern/src/components/shared/contextualFocus.ts` and its guardrail tests rather than forking another helper for workload IDs, resource IDs, or scroll-preserving same-route selection.
21. Keep shared infrastructure/resource selectors on the canonical agent-facet
truth. Shared primitives and settings-facing selector helpers must treat
top-level TrueNAS appliances as agent-facet infrastructure via shared
helper ownership instead of reviving a direct `resource.type === 'truenas'`
branch inside page shells, selectors, or reporting-resource type helpers.
20. Keep shared feature-shell Patrol run fixtures on the canonical run-record
22. Keep shared feature-shell Patrol run fixtures on the canonical run-record
contract. When `frontend-modern/src/features/patrol/` consumes Patrol run
history, the shared normalized record must preserve provider-backed counts
such as `truenas_checked` instead of letting feature-local fixtures or
fallback objects collapse API-backed TrueNAS systems back into generic
agent-host presentation.
21. Keep the authenticated app root aligned with that same first-session path.
23. Keep the authenticated app root aligned with that same first-session path.
That same shared-primitive ownership now includes contextual row focus.
`frontend-modern/src/components/shared/contextualFocus.ts` is the canonical
owner for interactive-series filtering, focused-label lookup, active-series
@ -357,34 +362,34 @@ connections` visible as the API-backed alternative for Proxmox and
the governed dashboard empty state route first-time operators into
Infrastructure Install, instead of preserving a separate root-only jump to
`/infrastructure` that drifts from the rest of the onboarding contract.
22. Keep relay settings shell copy on the shared presentation owner in
24. Keep relay settings shell copy on the shared presentation owner in
`frontend-modern/src/utils/relayPresentation.ts`. The route metadata in
`settingsHeaderMeta.ts` and the leading `SettingsPanel` in
`RelaySettingsPanel.tsx` must reuse the same description and availability
copy instead of drifting into separate rollout or pairing wording.
23. Keep shared settings-shell legal and docs referrals on
25. Keep shared settings-shell legal and docs referrals on
`frontend-modern/src/utils/docsLinks.ts`. Shared settings surfaces such as
`AIRuntimeControlsSection.tsx` must not hardcode GitHub `main` doc URLs for
privacy, security, proxy-auth, scope-reference, or Terms-of-Service links.
24. Keep shared settings-shell telemetry transparency controls on the governed
26. Keep shared settings-shell telemetry transparency controls on the governed
general settings panel. Preview/reset affordances for anonymous telemetry
must stay rendered inside
`frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`
instead of drifting into route-local modals, hidden dev tools, or shell
chrome that operators would not naturally inspect.
25. Keep the short telemetry/privacy summary copy on that same shared surface
27. Keep the short telemetry/privacy summary copy on that same shared surface
accurate to the governed privacy doc. If the trust boundary depends on a
specific retention window or on “IP addresses are not stored” rather than
“IPs are never seen,” the summary copy in
`GeneralSettingsPanel.tsx` must state those facts plainly instead of
reverting to a stronger but inaccurate shorthand.
26. Keep shared storage-route feature presentation on neutral capability truth.
28. Keep shared storage-route feature presentation on neutral capability truth.
Reusable mappers and presenters in `frontend-modern/src/features/storageBackups/`
must distinguish inventory datastores from backup repositories so VMware
rows on the shared storage route stay canonical to the admitted phase-1 floor instead of
reviving backup-target, protected-target, or recovery-local semantics on a
shared page.
27. Keep infrastructure settings-shell API alternatives on the shared shell
29. Keep infrastructure settings-shell API alternatives on the shared shell
contract. `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`,
`frontend-modern/src/components/Settings/settingsHeaderMeta.ts`,
`frontend-modern/src/components/Settings/settingsNavigationModel.ts`, and
@ -392,7 +397,7 @@ connections` visible as the API-backed alternative for Proxmox and
present `Platform connections` as the canonical API-backed alternative for
Proxmox, TrueNAS, and future provider integrations instead of reviving
top-level `Direct Proxmox` wording or shell-local provider routes.
28. Keep the infrastructure settings platform-connections summary and provider
30. Keep the infrastructure settings platform-connections summary and provider
workspaces on one shared state source. `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`,
`frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`,
`frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`,
@ -400,14 +405,14 @@ connections` visible as the API-backed alternative for Proxmox and
derive TrueNAS connection counts and availability from the shared
infrastructure settings state instead of letting the reporting summary and
the provider-specific panel issue separate connection fetches.
29. Keep alert-history feature composition on the current owned state contract.
31. Keep alert-history feature composition on the current owned state contract.
`frontend-modern/src/features/alerts/tabs/HistoryTab.tsx` must react to the
shared `alertData()` history state instead of reviving deleted aliases, and
it must pass unified-resource resolution through to
`frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx` so
the panel can render shared route chips without creating another page-local
resource lookup or provider-specific handoff layer.
30. Keep the alert-thresholds containers surface on the canonical shared owner.
32. Keep the alert-thresholds containers surface on the canonical shared owner.
`alertOverridesModel.ts`, `useAlertOverridesState.ts`, and
`useAlertsConfigurationState.ts` must surface API-backed `app-container`
parents such as TrueNAS as first-class `Container Runtimes`, while
@ -416,14 +421,14 @@ connections` visible as the API-backed alternative for Proxmox and
props that can collapse functions on the live Solid surface. Docker-only
controls in `ThresholdsTableDockerTab.tsx` must remain gated to real
`docker-host` resources instead of leaking onto platform-managed runtimes.
31. Keep shared commercial upgrade navigation typed and destination-aware.
33. Keep shared commercial upgrade navigation typed and destination-aware.
Shared paywall shells and upgrade actions must route internal billing or
cloud destinations through `frontend-modern/src/utils/upgradeNavigation.ts`,
`frontend-modern/src/components/shared/UpgradeLink.tsx`, and
`frontend-modern/src/components/shared/useUpgradeNavigation.ts` instead of
guessing from labels, hardcoding `target="_blank"`, or calling
`window.open(...)` from each feature surface.
32. Keep same-shell infrastructure route transitions on retained shared state.
34. Keep same-shell infrastructure route transitions on retained shared state.
`frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx`
may show its full-page loading shell only before the first compatible
resource snapshot exists; once a fresh canonical snapshot is already

View file

@ -168,6 +168,16 @@ querying, and the operator-facing storage health presentation layer.
29. Keep VMware datastore projection on the shared unified-resource and storage-source contracts. When `frontend-modern/src/hooks/useUnifiedResources.ts` or shared `internal/api/router.go` wiring starts surfacing VMware-backed canonical `storage` resources, storage and recovery may expose those datastores through the owned `vmware-vsphere` source/platform vocabulary for inventory, capacity, and handoff flows only; they must not reinterpret that projection as VMware recovery support, restore semantics, or a provider-local protection surface.
30. Keep VMware placement and guest-detail enrichment descriptive on that same shared unified-resource contract. When `internal/vmware/provider.go`, `internal/unifiedresources/types.go`, and `frontend-modern/src/hooks/useUnifiedResources.ts` project datacenter, cluster, folder, runtime-host, datastore-attachment, guest-hostname, or guest-IP metadata onto canonical VMware `agent` / `vm` / `storage` resources, storage and recovery may use that detail for labeling and navigation only; they must not promote those topology or guest fields into recovery ownership, restore targeting, protection grouping, or a VMware-local recovery taxonomy without a separately governed slice.
31. Keep VMware datastore classification neutral on the shared storage adapter contract. When `frontend-modern/src/features/storageBackups/resourceStorageMapping.ts`, `frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts`, and `frontend-modern/src/features/storageBackups/storageAdapters.ts` evolve canonical storage-record mapping, VMware-backed datastores must continue to land on the shared storage route as inventory-only datastores with neutral protection fallback, not as backup repositories, backup targets, or recovery-protected resources.
That same shared storage adapter boundary also owns canonical platform
family vocabulary through the governed platform manifest.
`frontend-modern/src/features/storageBackups/models.ts`,
`frontend-modern/src/features/storageBackups/storageAdapterCore.ts`, and
adjacent shared storage presenters that need known provider ids or
`onprem` / `container` / `virtualization` / `cloud` family mapping must
derive that truth from
`docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json` through
`frontend-modern/src/utils/platformSupportManifest.ts`, not from
storage-local hard-coded provider arrays.
32. Keep infrastructure summary chart bucketing presentation-only on the adjacent shared API boundary. When `internal/api/router.go` normalizes mixed-cadence infrastructure history into equal-time summary buckets for operator-facing summary cards, storage and recovery may consume the resulting visual context only; they must not reinterpret those normalized chart samples as recovery freshness windows, backup cadence, or restore evidence.
33. Keep workload chart downsampling presentation-only on that same adjacent shared API boundary. When `internal/api/router.go` caps mixed-cadence workload history into equal-time buckets for operator-facing workload cards, storage and recovery may consume the resulting visual context only; they must not reinterpret those shaped chart samples as recovery freshness windows, backup cadence, or restore evidence.
34. Keep storage and recovery websocket reads on the neutral app-runtime boundary. `frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx`, `frontend-modern/src/components/Storage/useStoragePageResources.ts`, and storage/recovery-adjacent dashboard composition may consume live websocket state only through `frontend-modern/src/contexts/appRuntime.ts`, not by importing `frontend-modern/src/App.tsx` or rebuilding shell-local providers.
@ -264,17 +274,17 @@ querying, and the operator-facing storage health presentation layer.
revive per-handler absolute-target acceptance or raw `returnTo`
concatenation.
10. Keep dependent first-session reset behavior honest on the shared `internal/api/`
boundary: when `/api/security/dev/reset-first-run` is used to reopen the
setup wizard in browser proof, the resulting status payload must genuinely
expose unauthenticated setup so storage/recovery-owned empty-state and
dashboard handoff proof does not silently fall back to an authenticated
dashboard path.
boundary: when `/api/security/dev/reset-first-run` is used to reopen the
setup wizard in browser proof, the resulting status payload must genuinely
expose unauthenticated setup so storage/recovery-owned empty-state and
dashboard handoff proof does not silently fall back to an authenticated
dashboard path.
11. Keep recovery support claims aligned with
`docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md`. Forward-
compatible provider strings are not support declarations by themselves, and
a platform should be treated as recovery-capable only when that model marks
recovery as part of its support floor and the owning ingest/projection path
exists in the same governed slice.
`docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md`. Forward-
compatible provider strings are not support declarations by themselves, and
a platform should be treated as recovery-capable only when that model marks
recovery as part of its support floor and the owning ingest/projection path
exists in the same governed slice.
12. Keep runtime mock inventory on the same bounded support contract. When
`/api/system/mock-mode` surfaces mock TrueNAS pools/datasets or mock
VMware datastores through shared storage/recovery-adjacent pages, that
@ -288,14 +298,14 @@ querying, and the operator-facing storage health presentation layer.
snapshot-backed platforms, provider-backed fixtures, unified inventory,
and recovery/storage context stay aligned instead of drifting through
recovery-local fixture assembly or partial mock helper APIs.
12. Keep adjacent shared install-script fallback semantics honest on the
14. Keep adjacent shared install-script fallback semantics honest on the
`internal/api/` boundary. When storage- or recovery-adjacent routes reuse
shared public endpoint or installer helpers, dev prerelease runtime
versions such as `v6.0.0-dev` and build-metadata versions must not be
treated as published GitHub release assets; only stable or explicit RC
tags may back the shared installer fallback that those adjacent surfaces
inherit.
12. Keep storage summary chart identity and sticky-shell behavior on the
15. Keep storage summary chart identity and sticky-shell behavior on the
shared storage path. Pool rows, disk rows, storage summary cards, and
storage detail charts must all address history through the canonical
unified-resource metrics-target IDs, and the storage page must reuse the
@ -305,8 +315,8 @@ querying, and the operator-facing storage health presentation layer.
`/api/storage-charts`, but it must not rebuild storage summary behavior by
fanning out per-pool `/api/metrics-store/history` reads or by inventing a
dashboard-only storage history transport.
13. Keep storage summary interaction scoped through the same canonical IDs.
14. Keep adjacent AI settings persistence vendor-neutral on the shared
16. Keep storage summary interaction scoped through the same canonical IDs.
17. Keep adjacent AI settings persistence vendor-neutral on the shared
`internal/api/` boundary. When storage- or recovery-adjacent hosted flows
load or save AI settings through shared helpers, any historical hosted
quickstart model IDs must be normalized back to the governed alias
@ -329,18 +339,18 @@ querying, and the operator-facing storage health presentation layer.
`data-summary-group-member-active="preview|pinned"` state so the grouped
block reads as one scoped set without adding storage-local outlines, pill
buttons, or heavy full-row fills.
14. Keep storage summary remount caches versioned with the chart contract.
18. Keep storage summary remount caches versioned with the chart contract.
`frontend-modern/src/components/Storage/StorageSummary.tsx` may keep a
bounded in-memory cache for same-tab remounts, but its cache key must carry
an explicit summary contract version so long-lived demo sessions do not
rehydrate stale pool or disk sparkline shapes after the storage summary
chart model changes.
15. Keep cross-surface workload handoffs on canonical IDs too. Shared workload
19. Keep cross-surface workload handoffs on canonical IDs too. Shared workload
chart transport may look up provider-backed VM history through unified
metrics targets, but infrastructure/workloads/storage/recovery navigation
and focus handoffs must stay on canonical workload IDs instead of
provider-native metric keys.
16. Keep storage row emphasis on the shared frontend primitive contract. Pool
20. Keep storage row emphasis on the shared frontend primitive contract. Pool
rows and physical-disk rows that mirror the active summary entity must
expose that state through `data-summary-row-active` and let the shared row
presentation owned by `frontend-modern/src/index.css` render the emphasis,
@ -356,7 +366,7 @@ querying, and the operator-facing storage health presentation layer.
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.
17. Keep recovery transport refreshes inside the recovery-owned feature state.
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
@ -364,20 +374,20 @@ querying, and the operator-facing storage health presentation layer.
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.
18. Keep storage/recovery-adjacent resource metadata on the shared unified
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
must inherit that field through `frontend-modern/src/hooks/useUnifiedResources.ts`
and `frontend-modern/src/types/resource.ts` instead of rebuilding backing
pool identity from labels, paths, or storage-row-local heuristics.
19. Keep storage route writes on the shared route-state scheduler. Storage page
23. Keep storage route writes on the shared route-state scheduler. Storage page
filter and tab updates may still own their query keys locally, but
`frontend-modern/src/components/Storage/useStorageRouteState.ts` must route
same-route replace navigation through the shared
`createRouteStateNavigateScheduler` helper so back-to-back storage filter
changes coalesce against the current location instead of reintroducing a
storage-local timeout queue.
20. Keep storage/recovery-adjacent config-import reload safety on the shared
24. Keep storage/recovery-adjacent config-import reload safety on the shared
`internal/api/` boundary. When storage or recovery setup flows depend on
`internal/api/config_export_import_handlers.go`, post-import reloads must
tolerate absent notification managers and other optional runtime managers

View file

@ -5,6 +5,15 @@ import path from 'node:path';
const ROOT = process.cwd();
const TARGET_DIRS = [path.join(ROOT, 'src')];
const IGNORE_DIRS = new Set(['__tests__']);
const PLATFORM_SUPPORT_MANIFEST_PATH = path.join(
ROOT,
'..',
'docs',
'release-control',
'v6',
'internal',
'PLATFORM_SUPPORT_MANIFEST.json',
);
const ALLOWLIST = new Set([
'src/components/shared/sourcePlatformBadges.ts',
'src/components/shared/workloadTypeBadges.ts',
@ -133,40 +142,28 @@ const ALLOWLIST = new Set([
'src/utils/infrastructureEmptyStatePresentation.ts',
]);
const PLATFORM_TOKENS = [
'pve',
'proxmox',
'pbs',
'pmg',
'k8s',
'kubernetes',
'docker',
'truenas',
'unraid',
'synology-dsm',
'vmware-vsphere',
'microsoft-hyperv',
'aws',
'azure',
'gcp',
];
const platformSupportManifest = JSON.parse(fs.readFileSync(PLATFORM_SUPPORT_MANIFEST_PATH, 'utf8'));
const manifestPlatforms = Array.isArray(platformSupportManifest.platforms)
? platformSupportManifest.platforms
: [];
const DISPLAY_LABEL_TOKENS = [
'PVE',
'PBS',
'PMG',
'K8s',
'Kubernetes',
'Containers',
'TrueNAS',
'Unraid',
'Synology',
'vSphere',
'Hyper-V',
'AWS',
'Azure',
'GCP',
];
const PLATFORM_TOKENS = Array.from(
new Set(
manifestPlatforms.flatMap((platform) => [
platform.id,
...(Array.isArray(platform.aliases) ? platform.aliases : []),
]),
),
);
const DISPLAY_LABEL_TOKENS = Array.from(
new Set(
manifestPlatforms.flatMap((platform) => [
platform.ui_label,
...(Array.isArray(platform.display_tokens) ? platform.display_tokens : []),
]),
),
);
const AI_FINDING_SOURCE_TOKENS = [
'threshold',
@ -355,8 +352,7 @@ const HELPER_RULES = [
},
{
rule: 'canonical-source/no-imports-from-storage-component-shim',
regex:
/import\s*\{[\s\S]*?\}\s*from\s*['"]@\/components\/Storage\/storageSourceOptions['"]/g,
regex: /import\s*\{[\s\S]*?\}\s*from\s*['"]@\/components\/Storage\/storageSourceOptions['"]/g,
message:
'Do not import storage source normalization from the Storage component shim. Use @/utils/storageSources instead.',
},
@ -506,8 +502,7 @@ const HELPER_RULES = [
},
{
rule: 'canonical-dashboard/no-local-trend-range-selected-classes',
regex:
/\bselectedRange\(\)\s*===\s*range\b[\s\S]{0,260}bg-blue-600\s+text-white/g,
regex: /\bselectedRange\(\)\s*===\s*range\b[\s\S]{0,260}bg-blue-600\s+text-white/g,
message:
'Do not define local dashboard trend range selected-button classes in page code. Use the shared segmented button contract instead.',
},
@ -561,8 +556,7 @@ const HELPER_RULES = [
},
{
rule: 'canonical-patrol/no-local-remediation-presentation',
regex:
/\bprops\.result\.success\b[\s\S]{0,900}bg-green-50[\s\S]{0,900}bg-red-50/g,
regex: /\bprops\.result\.success\b[\s\S]{0,900}bg-green-50[\s\S]{0,900}bg-red-50/g,
message:
'Do not define local remediation success/failure presentation in component code. Use @/utils/remediationPresentation instead.',
},
@ -622,8 +616,7 @@ const HELPER_RULES = [
},
{
rule: 'canonical-dashboard/no-local-problem-status-variant',
regex:
/\b(?:const|function)\s+statusVariant\s*\(\s*pr\s*:\s*ProblemResource\s*\)/g,
regex: /\b(?:const|function)\s+statusVariant\s*\(\s*pr\s*:\s*ProblemResource\s*\)/g,
message:
'Do not define local dashboard problem-resource status helpers in page code. Use @/utils/problemResourcePresentation instead.',
},
@ -748,8 +741,7 @@ const HELPER_RULES = [
},
{
rule: 'canonical-alerts/no-local-alert-compact-severity-labels',
regex:
/\balert\.level\s*===\s*['"]critical['"]\s*\?\s*['"]CRIT['"]\s*:\s*['"]WARN['"]/g,
regex: /\balert\.level\s*===\s*['"]critical['"]\s*\?\s*['"]CRIT['"]\s*:\s*['"]WARN['"]/g,
message:
'Do not define local alert compact severity labels in component code. Use @/utils/alertSeverityPresentation instead.',
},
@ -822,7 +814,8 @@ const HELPER_RULES = [
},
{
rule: 'canonical-settings/no-local-agent-profiles-empty-copy',
regex: /No profiles yet\. Create one to get started\.|No agents connected\. Install an agent to assign profiles\./g,
regex:
/No profiles yet\. Create one to get started\.|No agents connected\. Install an agent to assign profiles\./g,
message:
'Do not define local agent profile or assignment empty-state copy in component code. Use @/utils/agentProfilesPresentation instead.',
},
@ -918,15 +911,13 @@ const HELPER_RULES = [
},
{
rule: 'canonical-settings/no-inline-sso-test-result-tone-ternary',
regex:
/testResult\(\)\?\.(?:success)[\s\S]{0,260}bg-green-50[\s\S]{0,260}bg-red-50/g,
regex: /testResult\(\)\?\.(?:success)[\s\S]{0,260}bg-green-50[\s\S]{0,260}bg-red-50/g,
message:
'Do not inline SSO test-result tone ternaries in component code. Use @/utils/ssoProviderPresentation instead.',
},
{
rule: 'canonical-settings/no-inline-sso-certificate-tone-ternary',
regex:
/cert\.isExpired\s*\?[\s\S]{0,220}bg-red-100[\s\S]{0,220}bg-surface-hover/g,
regex: /cert\.isExpired\s*\?[\s\S]{0,220}bg-red-100[\s\S]{0,220}bg-surface-hover/g,
message:
'Do not inline SSO certificate tone ternaries in component code. Use @/utils/ssoProviderPresentation instead.',
},
@ -1202,7 +1193,8 @@ const HELPER_RULES = [
},
{
rule: 'canonical-pmg/no-local-service-health-badge',
regex: /\bconst\s+StatusBadge:\s*Component<\{\s*status:\s*string;\s*health\?:\s*string\s*\}>\b/g,
regex:
/\bconst\s+StatusBadge:\s*Component<\{\s*status:\s*string;\s*health\?:\s*string\s*\}>\b/g,
message:
'Do not define local PMG service health badge components in page code. Use the shared PMG ServiceHealthBadge component instead.',
},
@ -1338,8 +1330,7 @@ const MAP_RULES = [
/\b(?:const|let|var)\s+(?:sourceClasses|platformClasses|sourcePlatformClasses)\s*=\s*\{([\s\S]*?)\n\};?/g,
message:
'Do not define local source/platform style maps in component code. Use shared sourcePlatformBadges helpers.',
validate: (snippet) =>
containsAny(snippet, PLATFORM_TOKENS) && /(?:bg-|text-)/.test(snippet),
validate: (snippet) => containsAny(snippet, PLATFORM_TOKENS) && /(?:bg-|text-)/.test(snippet),
},
{
rule: 'canonical-source/no-local-storage-source-preset-map',
@ -1347,8 +1338,7 @@ const MAP_RULES = [
/\b(?:const|let|var)\s+(?:STORAGE_SOURCE_PRESETS|storageSourcePresets|sourceFilterPresets)\s*=\s*\{([\s\S]*?)\n\};?/g,
message:
'Do not define local storage source presentation maps in component code. Use @/utils/storageSources instead.',
validate: (snippet) =>
containsAny(snippet, PLATFORM_TOKENS) && /tone\s*:/.test(snippet),
validate: (snippet) => containsAny(snippet, PLATFORM_TOKENS) && /tone\s*:/.test(snippet),
},
{
rule: 'canonical-storage/no-local-health-presentation-map',
@ -1462,8 +1452,7 @@ const MAP_RULES = [
},
{
rule: 'canonical-storage/no-local-storage-deeplink-helpers',
regex:
/\bconst\s+\{\s*resource\s*\}\s*=\s*parseStorageLinkSearch\(/g,
regex: /\bconst\s+\{\s*resource\s*\}\s*=\s*parseStorageLinkSearch\(/g,
message:
'Do not define storage deep-link highlight state inline. Use ./useStorageResourceHighlight instead.',
validate: () => true,
@ -1478,8 +1467,7 @@ const MAP_RULES = [
},
{
rule: 'canonical-source/no-local-source-option-array',
regex:
/\b(?:const|let|var)\s+(?:sourceOptions|providerOptions)\s*=\s*\[([\s\S]*?)\];?/g,
regex: /\b(?:const|let|var)\s+(?:sourceOptions|providerOptions)\s*=\s*\[([\s\S]*?)\];?/g,
message:
'Do not define local canonical source/provider option arrays in page code. Use @/utils/sourcePlatformOptions instead.',
validate: (snippet) => containsAny(snippet, PLATFORM_TOKENS),
@ -1535,8 +1523,7 @@ const MAP_RULES = [
},
{
rule: 'canonical-ai/no-local-provider-health-presentation-helper',
regex:
/\b(?:const|function)\s+(?:getProviderHealthBadgeClass|getProviderHealthLabel)\b/g,
regex: /\b(?:const|function)\s+(?:getProviderHealthBadgeClass|getProviderHealthLabel)\b/g,
message:
'Do not define local AI provider health presentation helpers in component code. Use @/utils/aiProviderHealthPresentation instead.',
validate: () => true,
@ -1608,16 +1595,14 @@ const MAP_RULES = [
},
{
rule: 'canonical-status/no-inline-offline-degraded-badge-ternary',
regex:
/problem\s*===\s*['"]Offline['"][\s\S]{0,220}problem\s*===\s*['"]Degraded['"]/g,
regex: /problem\s*===\s*['"]Offline['"][\s\S]{0,220}problem\s*===\s*['"]Degraded['"]/g,
message:
'Do not inline Offline/Degraded badge ternaries in component or page code. Use the shared status presentation helpers instead.',
validate: () => true,
},
{
rule: 'canonical-ai/no-inline-finding-severity-count-badge-ternary',
regex:
/criticalFindings\s*>\s*0[\s\S]{0,200}bg-red-100[\s\S]{0,200}bg-amber-100/g,
regex: /criticalFindings\s*>\s*0[\s\S]{0,200}bg-red-100[\s\S]{0,200}bg-amber-100/g,
message:
'Do not inline finding severity count badge ternaries in page code. Use shared AI finding severity presentation helpers instead.',
validate: () => true,
@ -1648,8 +1633,7 @@ const MAP_RULES = [
},
{
rule: 'canonical-license/no-local-subscription-status-presentation',
regex:
/\b(?:const|function)\s+(?:statusLabel|statusTone)\b[\s\S]{0,900}subscriptionState\(\)/g,
regex: /\b(?:const|function)\s+(?:statusLabel|statusTone)\b[\s\S]{0,900}subscriptionState\(\)/g,
message:
'Do not define local subscription status label/tone helpers in component code. Use @/utils/licensePresentation instead.',
validate: () => true,
@ -1664,8 +1648,7 @@ const MAP_RULES = [
},
{
rule: 'canonical-security/no-local-security-score-presentation',
regex:
/\b(?:const|function)\s+(?:scoreTone|scoreLabel)\b[\s\S]{0,1200}securityScore\(\)/g,
regex: /\b(?:const|function)\s+(?:scoreTone|scoreLabel)\b[\s\S]{0,1200}securityScore\(\)/g,
message:
'Do not define local security score tone/label helpers in component code. Use @/utils/securityScorePresentation instead.',
validate: () => true,
@ -1693,7 +1676,8 @@ const MAP_RULES = [
message:
'Do not define local resource or subject type label maps in component or page code. Use shared resourceTypePresentation helpers.',
validate: (snippet) =>
containsAny(snippet, RESOURCE_TYPE_TOKENS) && containsAny(snippet, RESOURCE_TYPE_LABEL_TOKENS),
containsAny(snippet, RESOURCE_TYPE_TOKENS) &&
containsAny(snippet, RESOURCE_TYPE_LABEL_TOKENS),
},
];

View file

@ -1,29 +1,29 @@
import { describe, expect, it } from 'vitest';
import type { State } from '@/types/api';
import type { Resource } from '@/types/resource';
import { resolveStoragePlatformFamily } from '@/features/storageBackups/storageAdapterCore';
import { buildStorageRecords } from '@/features/storageBackups/storageAdapters';
const baseState = (overrides: Partial<State> = {}): State =>
({
connectedInfrastructure: [],
metrics: [],
performance: {
apiCallDuration: {},
lastPollDuration: 0,
pollingStartTime: '',
totalApiCalls: 0,
failedApiCalls: 0,
cacheHits: 0,
cacheMisses: 0,
},
connectionHealth: {},
stats: { startTime: '', uptime: 0, pollingCycles: 0, webSocketClients: 0, version: 'dev' },
activeAlerts: [],
recentlyResolved: [],
lastUpdate: 0,
resources: [],
...overrides,
});
const baseState = (overrides: Partial<State> = {}): State => ({
connectedInfrastructure: [],
metrics: [],
performance: {
apiCallDuration: {},
lastPollDuration: 0,
pollingStartTime: '',
totalApiCalls: 0,
failedApiCalls: 0,
cacheHits: 0,
cacheMisses: 0,
},
connectionHealth: {},
stats: { startTime: '', uptime: 0, pollingCycles: 0, webSocketClients: 0, version: 'dev' },
activeAlerts: [],
recentlyResolved: [],
lastUpdate: 0,
resources: [],
...overrides,
});
const makeResourceStorage = (overrides: Partial<Resource> = {}): Resource =>
({
@ -47,6 +47,12 @@ const makeResourceStorage = (overrides: Partial<Resource> = {}): Resource =>
}) as Resource;
describe('storageAdapters', () => {
it('derives storage platform families from the governed platform manifest', () => {
expect(resolveStoragePlatformFamily('docker')).toBe('container');
expect(resolveStoragePlatformFamily('vmware-vsphere')).toBe('virtualization');
expect(resolveStoragePlatformFamily('aws')).toBe('cloud');
});
it('returns no records when unified resources are absent', () => {
const state = baseState();
const records = buildStorageRecords({ state, resources: [] });

View file

@ -1,25 +1,10 @@
import type { State } from '@/types/api';
import type { Resource } from '@/types/resource';
import { KNOWN_SOURCE_PLATFORM_KEYS, type KnownSourcePlatform } from '@/utils/sourcePlatforms';
export const KNOWN_STORAGE_BACKUP_PLATFORMS = [
'proxmox-pve',
'proxmox-pbs',
'proxmox-pmg',
'kubernetes',
'docker',
'agent',
'truenas',
'unraid',
'synology-dsm',
'vmware-vsphere',
'microsoft-hyperv',
'aws',
'azure',
'gcp',
'generic',
] as const;
export const KNOWN_STORAGE_BACKUP_PLATFORMS = KNOWN_SOURCE_PLATFORM_KEYS;
export type KnownStorageBackupPlatform = (typeof KNOWN_STORAGE_BACKUP_PLATFORMS)[number];
export type KnownStorageBackupPlatform = KnownSourcePlatform;
export type StorageBackupPlatform = KnownStorageBackupPlatform | (string & {});
export type PlatformFamily = 'onprem' | 'container' | 'virtualization' | 'cloud' | 'generic';

View file

@ -1,4 +1,5 @@
import type { Resource } from '@/types/resource';
import { getSourcePlatformStorageFamily } from '@/utils/platformSupportManifest';
import type {
CapacitySnapshot,
NormalizedHealth,
@ -35,10 +36,11 @@ export const canonicalStorageIdentityKey = (record: StorageRecord): string => {
return [platform, location || 'unknown-location', name || 'unknown-name', category].join('|');
};
export const resolveStoragePlatformFamily = (
platform: StorageBackupPlatform,
): PlatformFamily => {
export const resolveStoragePlatformFamily = (platform: StorageBackupPlatform): PlatformFamily => {
const value = String(platform).toLowerCase();
const manifestFamily = getSourcePlatformStorageFamily(value);
if (manifestFamily) return manifestFamily;
if (value.includes('kubernetes') || value.includes('docker')) return 'container';
if (value.includes('cloud') || value === 'aws' || value === 'azure' || value === 'gcp') {
return 'cloud';

View file

@ -0,0 +1,217 @@
import manifestJson from '../../../docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json';
export type PlatformGovernanceState = 'supported' | 'admitted' | 'presentation-only';
export type SourcePlatformStorageFamily = 'onprem' | 'container' | 'virtualization' | 'cloud';
export interface SourcePlatformManifestEntry {
id: string;
governanceState: PlatformGovernanceState;
uiLabel: string;
uiTone: string;
aliases: string[];
displayTokens: string[];
storageFamily: SourcePlatformStorageFamily;
}
export interface PlatformSupportManifest {
schemaVersion: number;
defaultInfrastructureSourceOrder: string[];
platforms: SourcePlatformManifestEntry[];
}
const VALID_GOVERNANCE_STATES = new Set<PlatformGovernanceState>([
'supported',
'admitted',
'presentation-only',
]);
const VALID_STORAGE_FAMILIES = new Set<SourcePlatformStorageFamily>([
'onprem',
'container',
'virtualization',
'cloud',
]);
const requireRecord = (value: unknown, label: string): Record<string, unknown> => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`platform support manifest: expected ${label} to be an object`);
}
return value as Record<string, unknown>;
};
const requireString = (value: unknown, label: string): string => {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new Error(`platform support manifest: expected ${label} to be a non-empty string`);
}
return value.trim();
};
const requireLowercaseIdentifier = (value: unknown, label: string): string => {
const normalized = requireString(value, label);
if (normalized !== normalized.toLowerCase()) {
throw new Error(`platform support manifest: expected ${label} to be lowercase`);
}
return normalized;
};
const requireStringArray = (value: unknown, label: string): string[] => {
if (!Array.isArray(value)) {
throw new Error(`platform support manifest: expected ${label} to be an array`);
}
return value.map((item, index) => requireString(item, `${label}[${index}]`));
};
const uniqueStrings = (values: Iterable<string>): string[] => Array.from(new Set(values));
const parsePlatformEntry = (value: unknown, index: number): SourcePlatformManifestEntry => {
const record = requireRecord(value, `platforms[${index}]`);
const governanceState = requireString(
record.governance_state,
`platforms[${index}].governance_state`,
) as PlatformGovernanceState;
if (!VALID_GOVERNANCE_STATES.has(governanceState)) {
throw new Error(
`platform support manifest: invalid governance_state ${governanceState} at platforms[${index}]`,
);
}
const storageFamily = requireString(
record.storage_family,
`platforms[${index}].storage_family`,
) as SourcePlatformStorageFamily;
if (!VALID_STORAGE_FAMILIES.has(storageFamily)) {
throw new Error(
`platform support manifest: invalid storage_family ${storageFamily} at platforms[${index}]`,
);
}
return {
id: requireLowercaseIdentifier(record.id, `platforms[${index}].id`),
governanceState,
uiLabel: requireString(record.ui_label, `platforms[${index}].ui_label`),
uiTone: requireString(record.ui_tone, `platforms[${index}].ui_tone`),
aliases: uniqueStrings(
requireStringArray(record.aliases, `platforms[${index}].aliases`).map((alias, aliasIndex) =>
requireLowercaseIdentifier(alias, `platforms[${index}].aliases[${aliasIndex}]`),
),
),
displayTokens: uniqueStrings(
requireStringArray(record.display_tokens, `platforms[${index}].display_tokens`),
),
storageFamily,
};
};
const parsePlatformSupportManifest = (): PlatformSupportManifest => {
const raw = requireRecord(manifestJson, 'root');
const schemaVersion = Number(raw.schema_version);
if (!Number.isInteger(schemaVersion) || schemaVersion < 1) {
throw new Error('platform support manifest: expected schema_version to be a positive integer');
}
if (!Array.isArray(raw.platforms)) {
throw new Error('platform support manifest: expected platforms to be an array');
}
const platforms = raw.platforms.map((platform, index) => parsePlatformEntry(platform, index));
const knownIds = new Set(platforms.map((platform) => platform.id));
const platformsById = new Map<string, SourcePlatformManifestEntry>();
const aliases = new Map<string, string>();
for (const platform of platforms) {
if (platformsById.has(platform.id)) {
throw new Error(`platform support manifest: duplicate platform id ${platform.id}`);
}
platformsById.set(platform.id, platform);
for (const alias of platform.aliases) {
if (alias === platform.id) {
throw new Error(`platform support manifest: alias ${alias} duplicates its platform id`);
}
if (knownIds.has(alias) || aliases.has(alias)) {
throw new Error(`platform support manifest: duplicate alias ${alias}`);
}
aliases.set(alias, platform.id);
}
}
const defaultInfrastructureSourceOrder = uniqueStrings(
requireStringArray(
raw.default_infrastructure_source_order,
'default_infrastructure_source_order',
).map((id, index) =>
requireLowercaseIdentifier(id, `default_infrastructure_source_order[${index}]`),
),
);
const supportedIds = new Set(
platforms
.filter((platform) => platform.governanceState === 'supported')
.map((platform) => platform.id),
);
for (const platformId of defaultInfrastructureSourceOrder) {
if (!supportedIds.has(platformId)) {
throw new Error(
`platform support manifest: default infrastructure source order contains non-supported platform ${platformId}`,
);
}
}
return {
schemaVersion,
defaultInfrastructureSourceOrder,
platforms,
};
};
export const PLATFORM_SUPPORT_MANIFEST = parsePlatformSupportManifest();
const entriesById = new Map(
PLATFORM_SUPPORT_MANIFEST.platforms.map((platform) => [platform.id, platform] as const),
);
export const SOURCE_PLATFORM_MANIFEST_ENTRIES = Object.freeze([
...PLATFORM_SUPPORT_MANIFEST.platforms,
]);
export const SOURCE_PLATFORM_ALIAS_MAP = Object.freeze(
Object.fromEntries(
SOURCE_PLATFORM_MANIFEST_ENTRIES.flatMap((platform) =>
platform.aliases.map((alias) => [alias, platform.id]),
),
) as Record<string, string>,
);
export const SOURCE_PLATFORM_AUDIT_TOKENS = Object.freeze(
uniqueStrings(
SOURCE_PLATFORM_MANIFEST_ENTRIES.flatMap((platform) => [platform.id, ...platform.aliases]),
),
);
export const SOURCE_PLATFORM_DISPLAY_TOKENS = Object.freeze(
uniqueStrings(
SOURCE_PLATFORM_MANIFEST_ENTRIES.flatMap((platform) => [
platform.uiLabel,
...platform.displayTokens,
]),
),
);
export const DEFAULT_INFRASTRUCTURE_SOURCE_ORDER = Object.freeze([
...PLATFORM_SUPPORT_MANIFEST.defaultInfrastructureSourceOrder,
]);
export const getSourcePlatformManifestEntry = (
value: string | null | undefined,
): SourcePlatformManifestEntry | null => {
const normalized = (value || '').trim().toLowerCase();
if (!normalized) return null;
const platformId = SOURCE_PLATFORM_ALIAS_MAP[normalized] || normalized;
return entriesById.get(platformId) || null;
};
export const getSourcePlatformStorageFamily = (
value: string | null | undefined,
): SourcePlatformStorageFamily | null =>
getSourcePlatformManifestEntry(value)?.storageFamily || null;

View file

@ -1,3 +1,4 @@
import { DEFAULT_INFRASTRUCTURE_SOURCE_ORDER } from '@/utils/platformSupportManifest';
import { getSourcePlatformLabel, normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms';
export interface SourcePlatformOption {
@ -5,15 +6,7 @@ export interface SourcePlatformOption {
label: string;
}
const DEFAULT_SOURCE_PLATFORM_ORDER = [
'proxmox-pve',
'agent',
'docker',
'proxmox-pbs',
'proxmox-pmg',
'kubernetes',
'truenas',
] as const;
const DEFAULT_SOURCE_PLATFORM_ORDER = DEFAULT_INFRASTRUCTURE_SOURCE_ORDER;
export const orderSourcePlatformKeys = (
keys: Iterable<string>,

View file

@ -1,22 +1,20 @@
import type { PlatformType, SourceType } from '@/types/resource';
import {
getSourcePlatformManifestEntry,
SOURCE_PLATFORM_ALIAS_MAP,
SOURCE_PLATFORM_MANIFEST_ENTRIES,
} from '@/utils/platformSupportManifest';
import { titleCaseDelimitedLabel } from '@/utils/textPresentation';
export type KnownSourcePlatform =
| 'proxmox-pve'
| 'proxmox-pbs'
| 'proxmox-pmg'
| 'docker'
| 'kubernetes'
| 'truenas'
| 'agent'
export type PresentationOnlySourcePlatform =
| 'unraid'
| 'synology-dsm'
| 'vmware-vsphere'
| 'microsoft-hyperv'
| 'aws'
| 'azure'
| 'gcp'
| 'generic';
| 'gcp';
export type KnownSourcePlatform = PlatformType | PresentationOnlySourcePlatform | 'generic';
export interface SourcePlatformPresentation {
label: string;
@ -34,92 +32,62 @@ export interface SourcePlatformFlags {
hasVMware: boolean;
}
const MANIFEST_SOURCE_PLATFORM_PRESENTATION = Object.fromEntries(
SOURCE_PLATFORM_MANIFEST_ENTRIES.map((platform) => [
platform.id,
{
label: platform.uiLabel,
tone: platform.uiTone,
},
]),
) as Record<Exclude<KnownSourcePlatform, 'generic'>, SourcePlatformPresentation>;
export const SOURCE_PLATFORM_PRESENTATION: Record<KnownSourcePlatform, SourcePlatformPresentation> =
{
'proxmox-pve': {
label: 'PVE',
tone: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-400',
},
'proxmox-pbs': {
label: 'PBS',
tone: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-400',
},
'proxmox-pmg': {
label: 'PMG',
tone: 'bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-400',
},
docker: {
label: 'Containers',
tone: 'bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-400',
},
kubernetes: {
label: 'K8s',
tone: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-400',
},
truenas: {
label: 'TrueNAS',
tone: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-400',
},
agent: {
label: 'Agent',
tone: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-400',
},
unraid: {
label: 'Unraid',
tone: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
},
'synology-dsm': {
label: 'Synology',
tone: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300',
},
'vmware-vsphere': {
label: 'vSphere',
tone: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
},
'microsoft-hyperv': {
label: 'Hyper-V',
tone: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300',
},
aws: {
label: 'AWS',
tone: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
},
azure: {
label: 'Azure',
tone: 'bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-300',
},
gcp: {
label: 'GCP',
tone: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
},
...MANIFEST_SOURCE_PLATFORM_PRESENTATION,
generic: {
label: 'Generic',
tone: 'bg-surface-alt text-base-content',
},
};
const PLATFORM_ALIASES: Record<string, KnownSourcePlatform> = {
pve: 'proxmox-pve',
proxmox: 'proxmox-pve',
pbs: 'proxmox-pbs',
pmg: 'proxmox-pmg',
k8s: 'kubernetes',
vmware: 'vmware-vsphere',
};
export const KNOWN_SOURCE_PLATFORM_KEYS = Object.freeze([
...(SOURCE_PLATFORM_MANIFEST_ENTRIES.map((platform) => platform.id) as Exclude<
KnownSourcePlatform,
'generic'
>[]),
'generic',
]) as readonly KnownSourcePlatform[];
const PLATFORM_ALIASES = SOURCE_PLATFORM_ALIAS_MAP as Record<
string,
Exclude<KnownSourcePlatform, 'generic'>
>;
export const normalizeSourcePlatformKey = (
value: string | null | undefined,
): KnownSourcePlatform | null => {
const normalized = (value || '').trim().toLowerCase();
if (!normalized) return null;
if (normalized in SOURCE_PLATFORM_PRESENTATION) return normalized as KnownSourcePlatform;
if (normalized in PLATFORM_ALIASES) return PLATFORM_ALIASES[normalized];
if (Object.prototype.hasOwnProperty.call(SOURCE_PLATFORM_PRESENTATION, normalized)) {
return normalized as KnownSourcePlatform;
}
if (Object.prototype.hasOwnProperty.call(PLATFORM_ALIASES, normalized)) {
return PLATFORM_ALIASES[normalized];
}
return null;
};
export const getSourcePlatformPresentation = (
value: string | null | undefined,
): SourcePlatformPresentation | null => {
const manifestPlatform = getSourcePlatformManifestEntry(value);
if (manifestPlatform) {
return SOURCE_PLATFORM_PRESENTATION[
manifestPlatform.id as Exclude<KnownSourcePlatform, 'generic'>
];
}
const normalized = normalizeSourcePlatformKey(value);
return normalized ? SOURCE_PLATFORM_PRESENTATION[normalized] : null;
};

View file

@ -1,6 +1,7 @@
package mock
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
@ -13,24 +14,59 @@ import (
)
var (
currentFirstClassPlatformRE = regexp.MustCompile("^\\d+\\.\\s+`([^`]+)`")
currentSupportMatrixRowRE = regexp.MustCompile("^\\|\\s+`([^`]+)`\\s+\\|")
numberedPlatformListRE = regexp.MustCompile("^\\d+\\.\\s+`([^`]+)`")
currentSupportMatrixRE = regexp.MustCompile("^\\|\\s+`([^`]+)`\\s+\\|")
)
func TestPlatformSupportModelCurrentClassificationMatchesSupportMatrix(t *testing.T) {
model := loadPlatformSupportModel(t)
type platformSupportManifest struct {
SchemaVersion int `json:"schema_version"`
DefaultInfrastructureSourceOrder []string `json:"default_infrastructure_source_order"`
Platforms []platformSupportManifestEntry `json:"platforms"`
}
classified := parseCurrentFirstClassPlatforms(t, model)
type platformSupportManifestEntry struct {
ID string `json:"id"`
GovernanceState string `json:"governance_state"`
UILabel string `json:"ui_label"`
UITone string `json:"ui_tone"`
Aliases []string `json:"aliases"`
DisplayTokens []string `json:"display_tokens"`
StorageFamily string `json:"storage_family"`
}
func TestPlatformSupportManifestMatchesSupportModel(t *testing.T) {
model := loadPlatformSupportModel(t)
manifest := loadPlatformSupportManifest(t)
classified := parsePlatformListSection(t, model, "### First-class platforms")
admitted := parsePlatformListSection(t, model, "### Admitted platforms (not yet supported)")
presentationOnly := parsePlatformListSection(t, model, "### Presentation-only platform vocabulary")
matrix := parseCurrentSupportMatrixPlatforms(t, model)
if diff := diffPlatformSets(classified, matrix); diff != "" {
t.Fatalf("current supported platform definitions drifted between classification and support matrix:\n%s", diff)
}
if diff := diffPlatformSets(classified, manifestPlatformsByState(t, manifest, "supported")); diff != "" {
t.Fatalf("supported platform manifest drifted from the canonical support model:\n%s", diff)
}
if diff := diffPlatformSets(admitted, manifestPlatformsByState(t, manifest, "admitted")); diff != "" {
t.Fatalf("admitted platform manifest drifted from the canonical support model:\n%s", diff)
}
if diff := diffPlatformSets(
presentationOnly,
manifestPlatformsByState(t, manifest, "presentation-only"),
); diff != "" {
t.Fatalf("presentation-only platform manifest drifted from the canonical support model:\n%s", diff)
}
if diff := diffPlatformSets(classified, manifest.DefaultInfrastructureSourceOrder); diff != "" {
t.Fatalf("default infrastructure source ordering drifted from the canonical supported platform set:\n%s", diff)
}
}
func TestMockCoverageMatchesCurrentSupportedPlatformSet(t *testing.T) {
model := loadPlatformSupportModel(t)
supported := parseCurrentFirstClassPlatforms(t, model)
manifest := loadPlatformSupportManifest(t)
supported := manifestPlatformsByState(t, manifest, "supported")
checkers := map[string]func(*testing.T, FixtureGraph){
"agent": assertAgentMockCoverage,
@ -55,11 +91,20 @@ func TestMockCoverageMatchesCurrentSupportedPlatformSet(t *testing.T) {
func TestVMwareFixturesRemainAdmittedButNotSupported(t *testing.T) {
model := loadPlatformSupportModel(t)
supported := parseCurrentFirstClassPlatforms(t, model)
manifest := loadPlatformSupportManifest(t)
supported := manifestPlatformsByState(t, manifest, "supported")
admitted := manifestPlatformsByState(t, manifest, "admitted")
presentationOnly := manifestPlatformsByState(t, manifest, "presentation-only")
if containsPlatform(supported, "vmware-vsphere") {
t.Fatal("vmware-vsphere must not appear in the current supported platform set before live proof admits it")
}
if !containsPlatform(admitted, "vmware-vsphere") {
t.Fatal("expected vmware-vsphere to remain admitted while it is outside the supported platform set")
}
if containsPlatform(presentationOnly, "vmware-vsphere") {
t.Fatal("vmware-vsphere must not regress into presentation-only vocabulary once admitted")
}
if !strings.Contains(model, "| `vmware-vsphere` |") {
t.Fatal("expected platform support model to keep the vmware-vsphere admission row")
}
@ -210,6 +255,46 @@ func assertTrueNASMockCoverage(t *testing.T, graph FixtureGraph) {
}
}
func loadPlatformSupportManifest(t *testing.T) platformSupportManifest {
t.Helper()
_, currentFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("failed to locate current test file for platform support manifest lookup")
}
manifestPath := filepath.Join(
filepath.Dir(currentFile),
"..",
"..",
"docs",
"release-control",
"v6",
"internal",
"PLATFORM_SUPPORT_MANIFEST.json",
)
manifestBytes, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatalf("read platform support manifest: %v", err)
}
var manifest platformSupportManifest
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
t.Fatalf("unmarshal platform support manifest: %v", err)
}
if manifest.SchemaVersion < 1 {
t.Fatalf("expected positive platform support manifest schema version, got %d", manifest.SchemaVersion)
}
if len(manifest.Platforms) == 0 {
t.Fatal("expected platform support manifest to declare at least one platform")
}
if len(manifest.DefaultInfrastructureSourceOrder) == 0 {
t.Fatal("expected platform support manifest to declare a default infrastructure source order")
}
return manifest
}
func loadPlatformSupportModel(t *testing.T) string {
t.Helper()
@ -235,7 +320,7 @@ func loadPlatformSupportModel(t *testing.T) string {
return string(modelBytes)
}
func parseCurrentFirstClassPlatforms(t *testing.T, model string) []string {
func parsePlatformListSection(t *testing.T, model string, heading string) []string {
t.Helper()
var platforms []string
@ -244,22 +329,22 @@ func parseCurrentFirstClassPlatforms(t *testing.T, model string) []string {
for _, raw := range strings.Split(model, "\n") {
line := strings.TrimSpace(raw)
switch {
case line == "### First-class platforms":
case line == heading:
inSection = true
continue
case !inSection:
continue
case strings.HasPrefix(line, "### ") || strings.HasPrefix(line, "## "):
return requireNonEmptyPlatformList(t, platforms, "current first-class platform section")
return requireNonEmptyPlatformList(t, platforms, heading)
}
matches := currentFirstClassPlatformRE.FindStringSubmatch(line)
matches := numberedPlatformListRE.FindStringSubmatch(line)
if len(matches) == 2 {
platforms = append(platforms, matches[1])
}
}
return requireNonEmptyPlatformList(t, platforms, "current first-class platform section")
return requireNonEmptyPlatformList(t, platforms, heading)
}
func parseCurrentSupportMatrixPlatforms(t *testing.T, model string) []string {
@ -280,7 +365,7 @@ func parseCurrentSupportMatrixPlatforms(t *testing.T, model string) []string {
return requireNonEmptyPlatformList(t, platforms, "current support matrix")
}
matches := currentSupportMatrixRowRE.FindStringSubmatch(line)
matches := currentSupportMatrixRE.FindStringSubmatch(line)
if len(matches) == 2 {
platforms = append(platforms, matches[1])
}
@ -289,6 +374,29 @@ func parseCurrentSupportMatrixPlatforms(t *testing.T, model string) []string {
return requireNonEmptyPlatformList(t, platforms, "current support matrix")
}
func manifestPlatformsByState(
t *testing.T,
manifest platformSupportManifest,
governanceState string,
) []string {
t.Helper()
platforms := make([]string, 0)
for _, platform := range manifest.Platforms {
if strings.TrimSpace(platform.ID) == "" {
t.Fatal("expected platform support manifest platform ids to be non-empty")
}
if strings.TrimSpace(platform.GovernanceState) == "" {
t.Fatalf("expected platform support manifest to classify %s", platform.ID)
}
if platform.GovernanceState == governanceState {
platforms = append(platforms, platform.ID)
}
}
return requireNonEmptyPlatformList(t, platforms, fmt.Sprintf("manifest platforms with state %s", governanceState))
}
func requireNonEmptyPlatformList(t *testing.T, platforms []string, label string) []string {
t.Helper()