mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
Add platform support manifest
This commit is contained in:
parent
85e8ea6e78
commit
ab3e028359
13 changed files with 733 additions and 292 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
140
docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json
Normal file
140
docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [] });
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
217
frontend-modern/src/utils/platformSupportManifest.ts
Normal file
217
frontend-modern/src/utils/platformSupportManifest.ts
Normal 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;
|
||||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue