diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 8945289fb..d16d3310d 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -5788,7 +5788,21 @@ ], "coverage_gaps": [], "candidate_lanes": [], - "work_claims": [], + "work_claims": [ + { + "id": "codex-storage-native-topology-lane-l15", + "agent_id": "codex-storage-native-topology", + "summary": "Make Storage show platform-native storage topology clearly", + "target_id": "v6-product-lane-expansion", + "claimed_at": "2026-05-14T15:57:59Z", + "heartbeat_at": "2026-05-14T15:57:59Z", + "expires_at": "2026-05-14T17:57:59Z", + "work_item": { + "kind": "lane", + "id": "L15" + } + } + ], "open_decisions": [], "source_of_truth_file": "docs/release-control/v6/internal/SOURCE_OF_TRUTH.md", "resolved_decisions": [ diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 73bb6577a..cb388dcd8 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -213,6 +213,15 @@ placeholders rather than failed disks. Assigned disks may use `diskId`/`rdevId` as the serial fallback when Unraid does not expose a separate serial field, so monitoring receives stable disk identity without inventing host-profile or platform state from optional storage probe success. +That Unraid runtime path must also prefer native appliance topology over +generic block-device inference. The Unified Agent should best-effort merge +`/var/local/emhttp/disks.ini` into the `mdcmd status` view and carry disk +device, model, transport, filesystem, size, used/free capacity, temperature, +spin state, read/write counters, and error counters in the report contract. +Failure to read the native file must degrade to the existing mdcmd view without +blocking host reporting, but successful native collection is the canonical +source for Unraid array/cache membership; SMART rows are supplemental hardware +telemetry, not the owner of Unraid storage topology. First-class platform hosts that also run the Pulse Agent must keep the same operator-facing system identity split: a Proxmox VE node may report a Debian runtime platform underneath, but the host-agent OS identity and infrastructure diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 3663e8123..dc0b4e853 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -726,7 +726,7 @@ prompt explain the same operator-facing priority. not be bolted onto the sticky summary shell as an extra frontend primitive; if capacity planning returns, it needs an owner-level model that can keep the summary geometry and visible card set stable across refreshes. -10. 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 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. Recovery is explicitly outside this interaction dialect: its retired posture-card strip must not return with row/group/chart hover behavior without a separate governed product decision. 11. 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. 12. 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. 13. 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. The same-route state scheduler owns the lifecycle for its deferred scroll-restore timers and animation frames; page owners must clean it up on unmount instead of leaving replay work attached to a torn-down route. @@ -1393,10 +1393,9 @@ Shared alert presentation surfaces (`OverviewTab.tsx`, `HistoryTab.tsx`, `hasAIAlertsFeature` or `runtimeCapabilitiesLoading` props. Feature gating for AI alerts flows through the shared entitlements layer; surfaces must not re-introduce per-surface capability fetch props. -`frontend-modern/src/components/Recovery/RecoverySummary.tsx` gained an -aggregate health-state summary row. Per the Extension Points constraint, this -surface must continue to stay outside the shared hover-synchronization dialect; -new additions to `RecoverySummary.tsx` must not introduce row/group/chart hover +Recovery's retired posture-card strip remains outside the shared +hover-synchronization dialect. Per the Extension Points constraint, Recovery +must not introduce a new summary-card component with row/group/chart hover wiring without a separate governed product decision. `frontend-modern/src/components/Storage/useStorageSummaryCharts.ts` now owns the reusable polling/caching state for storage summary history, while diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 30e4301f1..7239a7aed 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -197,6 +197,14 @@ source/facet instead of treating the `storage` resource type as Proxmox by default. Appliance presentation details such as Unraid array identity may remain inside storage metadata, but agent-backed storage must stay canonical `platformType=agent`. +Unraid ingest must preserve the agent's native disk topology fields through the +monitoring model and read-state projection. `internal/monitoring/monitor_agents.go` +and `internal/monitoring/monitor.go` must carry model, transport, filesystem, +native capacity, used/free bytes, temperature, spin state, and read/write/error +counters without requiring a parallel SMART row. Monitoring may normalize legacy +statuses and filter empty slots, but it must not collapse assigned Unraid +array/cache members back to generic host disks or discard native fields before +unified resources builds storage and physical-disk resources. VMware vSphere now also has a locked phase-1 ingestion boundary under this lane. The admitted direction is vCenter-only in phase 1, and monitoring must stay API-first through the diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index bc0ed2581..8ae347e90 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -4865,7 +4865,6 @@ "frontend-modern/src/utils/recoveryOutcomePresentation.ts", "frontend-modern/src/utils/recoveryRecordPresentation.ts", "frontend-modern/src/utils/recoveryStatusPresentation.ts", - "frontend-modern/src/utils/recoverySummaryPresentation.ts", "frontend-modern/src/utils/recoveryTablePresentation.ts", "frontend-modern/src/utils/recoveryTimelineChartPresentation.ts", "frontend-modern/src/utils/recoveryTimelinePresentation.ts" @@ -4875,7 +4874,6 @@ "test_prefixes": [], "exact_files": [ "frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx", - "frontend-modern/src/components/Recovery/RecoverySummary.test.tsx", "frontend-modern/src/components/Storage/code_standards.test.ts", "frontend-modern/src/pages/__tests__/Recovery.test.tsx", "internal/recovery/recovery_test.go" @@ -4925,7 +4923,6 @@ "frontend-modern/src/utils/recoveryOutcomePresentation.ts", "frontend-modern/src/utils/recoveryRecordPresentation.ts", "frontend-modern/src/utils/recoveryStatusPresentation.ts", - "frontend-modern/src/utils/recoverySummaryPresentation.ts", "frontend-modern/src/utils/recoveryTablePresentation.ts", "frontend-modern/src/utils/recoveryTimelineChartPresentation.ts", "frontend-modern/src/utils/recoveryTimelinePresentation.ts" @@ -4934,7 +4931,6 @@ "test_prefixes": [], "exact_files": [ "frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx", - "frontend-modern/src/components/Recovery/RecoverySummary.test.tsx", "frontend-modern/src/pages/__tests__/Recovery.test.tsx", "frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts", "tests/integration/tests/17-recovery-layout.spec.ts" diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index a26d8fe51..608207429 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -34,31 +34,29 @@ state. 5. `frontend-modern/src/features/recovery/useRecoverySurfaceState.ts` 6. `frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx` 7. `frontend-modern/src/components/Recovery/RecoveryActivitySection.tsx` -8. `frontend-modern/src/components/Recovery/RecoverySummary.tsx` -9. `frontend-modern/src/components/Recovery/RecoveryHistorySection.tsx` -10. `frontend-modern/src/components/Recovery/RecoveryHistoryTable.tsx` -11. `frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx` -12. `frontend-modern/src/components/Recovery/useRecoveryHistorySectionState.ts` -13. `frontend-modern/src/pages/Storage.tsx` -14. `frontend-modern/src/pages/Ceph.tsx` -15. `frontend-modern/src/components/Storage/Storage.tsx` -16. `frontend-modern/src/features/storageBackups/storageModelCore.ts` -17. `frontend-modern/src/utils/storageSources.ts` -18. `frontend-modern/src/hooks/useRecoveryPoints.ts` -19. `frontend-modern/src/hooks/useRecoveryRollups.ts` -20. `frontend-modern/src/hooks/useRecoveryPointsFacets.ts` -21. `frontend-modern/src/hooks/useRecoveryPointsSeries.ts` -22. `frontend-modern/src/pages/Recovery.tsx` -23. `frontend-modern/src/routing/resourceLinks.ts` -24. `frontend-modern/src/types/recovery.ts` -25. `frontend-modern/src/utils/recoverySummaryPresentation.ts` -26. `frontend-modern/src/utils/recoveryTablePresentation.ts` -27. `frontend-modern/src/utils/recoveryItemTypePresentation.ts` -28. `frontend-modern/src/utils/textPresentation.ts` -29. `frontend-modern/src/components/Storage/StorageSummary.tsx` -30. `frontend-modern/src/utils/storageSummaryCache.ts` -31. `frontend-modern/src/components/Storage/useStorageSummaryCharts.ts` -32. `frontend-modern/src/features/storageBackups/storageCapacityDeltaPresentation.ts` +8. `frontend-modern/src/components/Recovery/RecoveryHistorySection.tsx` +9. `frontend-modern/src/components/Recovery/RecoveryHistoryTable.tsx` +10. `frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx` +11. `frontend-modern/src/components/Recovery/useRecoveryHistorySectionState.ts` +12. `frontend-modern/src/pages/Storage.tsx` +13. `frontend-modern/src/pages/Ceph.tsx` +14. `frontend-modern/src/components/Storage/Storage.tsx` +15. `frontend-modern/src/features/storageBackups/storageModelCore.ts` +16. `frontend-modern/src/utils/storageSources.ts` +17. `frontend-modern/src/hooks/useRecoveryPoints.ts` +18. `frontend-modern/src/hooks/useRecoveryRollups.ts` +19. `frontend-modern/src/hooks/useRecoveryPointsFacets.ts` +20. `frontend-modern/src/hooks/useRecoveryPointsSeries.ts` +21. `frontend-modern/src/pages/Recovery.tsx` +22. `frontend-modern/src/routing/resourceLinks.ts` +23. `frontend-modern/src/types/recovery.ts` +24. `frontend-modern/src/utils/recoveryTablePresentation.ts` +25. `frontend-modern/src/utils/recoveryItemTypePresentation.ts` +26. `frontend-modern/src/utils/textPresentation.ts` +27. `frontend-modern/src/components/Storage/StorageSummary.tsx` +28. `frontend-modern/src/utils/storageSummaryCache.ts` +29. `frontend-modern/src/components/Storage/useStorageSummaryCharts.ts` +30. `frontend-modern/src/features/storageBackups/storageCapacityDeltaPresentation.ts` ## Shared Boundaries @@ -140,11 +138,17 @@ bypass the API fail-closed execution gate. `overflow-x-auto` div around the shared table. Storage pools and physical disks inherit the surrounding `StorageContentCard` frame and must not add a nested `Card` or second scroll wrapper. - Recovery's Protected items and Recovery events sub-tabs each opt into + Recovery is event-first: the default `/recovery` surface is the recovery + events history, and protected-item rollups are a secondary Protection + coverage review reached from the explicit header action or + `view=inventory` compatibility links. Recovery must not present + protected rollups and recovery events as equal default sub-tabs because + workload backup posture already belongs on Workloads, while Recovery owns + concrete backup/snapshot/replication evidence. Both surfaces still opt into shared saved views (`savedViewsKey="recovery-protected"` and - `"recovery-events"`) so operators can save and recall named filter - combos through the shared `SavedViewsMenu`. The same pattern applies to - Storage (`savedViewsKey="storage"`). + `"recovery-events"`) so operators can save and recall named filter combos + through the shared `SavedViewsMenu`. The same pattern applies to Storage + (`savedViewsKey="storage"`). Recovery event filters now compose the shared chip-based `FilterBar` (`frontend-modern/src/components/shared/FilterBar/FilterBar.tsx`) with a `FilterDef[]` catalog. The legacy "advanced filter popover" retired: scope, @@ -160,7 +164,7 @@ bypass the API fail-closed execution gate. route-backed event filter state and query semantics in its recovery owner rather than introducing a page-local presentation state path. Recovery inventory protection posture and recovery-event outcome filtering - must stay separate in that owner: protected inventory uses the route-backed + must stay separate in that owner: protection coverage uses the route-backed `state` query for health, stale, failed, warning, running, unknown, and never-succeeded states, while recovery events use `status` for event outcomes. Legacy `stale=1` input may hydrate the inventory `state=stale` @@ -181,7 +185,10 @@ bypass the API fail-closed execution gate. bounded chart viewport with sparser labels so first, last, and selected-day context remain visible without overlapping labels. Selecting a timeline column remains route-backed and must not become component-local ephemeral - state. + state. The Recovery activity bar chart must use the frontend-primitives + shared chart slot height and matching plot-area height so it keeps visual + parity with the Workloads, Storage, and Infrastructure summary charts + without copying page-local chart sizing. 3. Add or change storage page UX through `frontend-modern/src/pages/Storage.tsx`, `frontend-modern/src/pages/Ceph.tsx`, `frontend-modern/src/components/Storage/`, `frontend-modern/src/features/storageBackups/`, and the shared storage-source contract in `frontend-modern/src/utils/storageSources.ts` The retired dashboard route must not reintroduce storage or recovery widgets as compatibility panels. Storage capacity, storage health, @@ -220,12 +227,18 @@ bypass the API fail-closed execution gate. must pass resolved thresholds from the alerts activation boundary into the shared metric-color helper instead of carrying storage-local usage color bands. - Storage primary-issue presentation must stay incident/risk-owned. Composite + Storage presentation is topology-first and factual. The Storage page should + show each platform's main storage shape in recognizable platform terms + (arrays, cache pools, pools, datastores, repositories, and physical media) + with capacity and health facts attached to that shape; it must not collapse + non-Proxmox platforms into Proxmox-shaped pool rows or turn factual topology + into prescriptive remediation copy. + Storage state presentation must stay incident/risk-owned. Composite posture summaries from unified resources may include dependency or protected workload impact, so `frontend-modern/src/features/storageBackups/` must not use `storage.postureSummary` or `pbs.postureSummary` as primary issue copy for healthy resources. Dependency impact belongs to impact summaries and - detail context, while `Primary Issue` must derive from explicit incidents, + detail context, while visible state must derive from explicit incidents, storage risk summaries, or storage-risk reasons. Unraid storage posture follows that same rule even when it reaches infrastructure through the agent facet instead of the dedicated Storage @@ -234,6 +247,13 @@ bypass the API fail-closed execution gate. storage-risk fields. Storage/recovery-adjacent consumers may render those summaries, but they must not infer Unraid array health from generic frontend status labels or from empty no-present slots. + Unraid storage presentation must also consume the canonical Unraid + topology published by unified resources. Array disks, cache/pool storage, + native used/free capacity, device model/transport, temperature, spin state, + and error counters belong in `storage`, `physical_disk`, and `agent.unraid` + payloads before they reach Storage. The Storage page must not attempt to + reconstruct Unraid pools from generic SMART-only rows or treat missing SMART + scans on spun-down disks as missing storage topology. 4. Route transport changes for storage and recovery endpoints through `internal/api/` and the owning `api-contracts` proof routes Shared API-token transport helpers may be consumed by storage/recovery- adjacent flows, but `owner_user_id` remains server-authored token identity @@ -816,12 +836,12 @@ bypass the API fail-closed execution gate. 11b. Letting adjacent storage-link additions in shared `frontend-modern/src/routing/resourceLinks.ts` perturb recovery route semantics; expanding canonical `/storage` deep links for unified resources must not reuse recovery-owned query names or alter the owned `/recovery` parse/build contract while those surfaces continue sharing the same route-helper module 12. Letting protected-inventory protection posture overload recovery-event outcome filtering; the protected inventory protection-state control must drive the route-backed `state` field and local rollup posture filtering, while the recovery events `status` field remains the canonical outcome filter for points, series, and facets transport filters 13. Letting visible protected-item filters fall out of shared recovery links; protected inventory state such as stale, failed, warning, running, unknown, healthy, and never-succeeded must restore from the canonical recovery URL and rewrite to the owned `state=` route form, with legacy `stale=1` accepted only as compatibility input -14. Reintroducing stacked full-width recovery tables as the primary desktop layout; the governed recovery surface must expose one primary data region at a time with explicit protected-items versus recovery-events view switching so Pulse stays inventory-first for Proxmox operators without collapsing the page back into a single-platform backup screen -15. Letting the primary recovery workspace tabs drift out of canonical route state; when operators explicitly switch between protected items and recovery events, the shared recovery link builder and page model must preserve that selection in route state unless the active `rollupId` or `day` context already implies the default workspace +14. Reintroducing stacked full-width recovery tables as the primary desktop layout; the governed recovery surface must expose one primary data region at a time with recovery events as the default workspace and protection coverage as an explicit secondary review so Pulse does not collapse back into a single-platform backup screen +15. Letting secondary recovery workspace state drift out of canonical route state; explicit `view=inventory` protection-coverage links must round-trip, while default recovery-events state should serialize without a redundant `view=events` query unless compatibility input is being normalized 16. Treating a selected protected-item rollup as row-click-only or header-only state instead of a canonical history filter; when a protected-item row focuses recovery history, the governed recovery events controls must surface that focus inside the shared filter surface through the same user-creatable item filter control, count it with the rest of the active filters, and let the same filter reset path clear it -17. Letting recovery-event focus leak as hidden state on the protected inventory surface; switching back to protected items must clear event-only `rollupId` and `day` context, and protected-item drill-ins must open recovery events without preserving an invisible day filter that can make valid history look empty. +17. Letting recovery-event focus leak as hidden state on the protection coverage surface; opening coverage must clear event-only `rollupId` and `day` context, and coverage drill-ins must open recovery events without preserving an invisible day filter that can make valid history look empty. 18. Letting the recovery details panel lead with transport-shaped payloads; operator-facing details must summarize outcome, artifact, target, restore readiness, and readable metadata labels first, while raw JSON and provider-specific keys stay behind an explicitly technical disclosure. -19. Letting recovery posture summaries become passive counters only; summary-card actions may focus events, stale inventory, or attention inventory, but those actions must route through the canonical recovery workspace and route-state owner instead of mutating local-only filter state. +19. Letting protection coverage navigation bypass the canonical recovery workspace and route-state owner; coverage actions may focus stale inventory, attention inventory, or all protected items, but they must not mutate local-only filter state or revive passive posture-counter cards as the navigation owner. 20. Letting storage or recovery surfaces invoke retired self-hosted trial acquisition; `POST /api/license/trial/start` and `/auth/trial-activate` must stay closed on the ordinary self-hosted router, and storage/recovery-adjacent billing or support handoffs must not treat trial activation as recovery identity, restore proof, or backup transport state. ## Completion Obligations @@ -1121,9 +1141,9 @@ afterward, instead of being misattributed to Pulse's auto-detection. `StorageSummary.tsx`, `StoragePageSummary.tsx`, and `useStoragePageSummary.ts` now surface `poolsDegraded` and `disksFailing` health indicators alongside -pool/disk counts. `RecoverySummary.tsx` gains an aggregate health-state summary -row. These additions project from existing websocket pool/disk state; they must -not introduce new API polling or widen the storage-fetch boundary. +pool/disk counts. These additions project from existing websocket pool/disk +state; they must not introduce new API polling or widen the storage-fetch +boundary. Agentless availability endpoints are adjacent infrastructure context only, not storage or recovery inventory. Storage/recovery consumers may receive `network-endpoint` resources through shared unified-resource snapshots, but @@ -1510,18 +1530,17 @@ canonical route parsing, filter/query state, transport hook inputs, and URL synchronization, while `frontend-modern/src/components/Recovery/Recovery.tsx` is the composition root for the operator-facing recovery surface and the split section owners under `frontend-modern/src/components/Recovery/` hold the -protected inventory, activity, and history presentation layers. The history +protection coverage, activity, and history presentation layers. The history surface is further split so `RecoveryHistorySection.tsx` owns the toolbar and controller boundary, `useRecoveryHistorySectionState.ts` owns local section UI state, and `RecoveryHistoryTable.tsx` owns the row/detail renderer. That composition root now also owns one primary recovery workspace rather than -stacking protected-inventory and event-history tables on the same desktop page. -The governed default remains inventory-first so Proxmox-oriented operators land -on the familiar protected-items view, but drill-in actions such as selecting a -subject or a timeline day must switch the primary workspace to recovery events -instead of leaving two competing table surfaces visible at once. +stacking protection-coverage and event-history tables on the same desktop page. +The governed default is event-first so operators land on concrete +backup/snapshot/replication history, while the header action and compatibility +`view=inventory` links open the secondary protection coverage review. That same operator-facing workspace must lead with current protection status -rather than only the latest backup outcome. Protected items should surface +rather than only the latest backup outcome. Protection coverage should surface stale, never-succeeded, failed, warning, and running rollups as the primary monitoring status so an item with an old successful point does not scan as healthy when it still needs operator attention. @@ -1547,11 +1566,11 @@ compatibility input that rewrites back to canonical `platform` route state. Shared recovery link builders should therefore accept canonical `platform` inputs only; legacy `provider` belongs at parse-time compatibility boundaries, not in new caller-facing recovery route helpers. -Cross-surface recovery drill-in links must also declare the correct primary -workspace instead of relying on the inventory-first page default. When a -platform service surface such as PBS links into recovery activity, that shared -entry point should land on canonical `view=events` and describe the destination -as recovery events rather than reverting to PBS-backup wording. +Cross-surface recovery drill-in links must also target the correct primary +workspace without relying on legacy inventory-first defaults. When a platform +service surface such as PBS links into recovery activity, that shared entry +point should land on the default recovery events workspace and describe the +destination as recovery events rather than reverting to PBS-backup wording. That same recovery contract should keep response payloads canonical as well: recovery points and protected rollups should expose `platform` and `platforms` as the primary transport fields, while any legacy @@ -1660,12 +1679,10 @@ and `Encrypted` instead of leaking raw transport values like `backup`, That primary workspace selection now also lives in canonical recovery route state through `frontend-modern/src/routing/resourceLinks.ts` and `frontend-modern/src/features/recovery/useRecoverySurfaceState.ts`, so copied -links and browser restores reopen the same protected-items versus -recovery-events workspace an operator explicitly chose instead of silently -falling back to page-local UI state. Focused recovery routes with an active -`rollupId` or `day` remain recovery-events-first by default, but the explicit -workspace selection must still serialize through the owned recovery URL model -whenever an operator overrides that implied default. +links and browser restores reopen explicit protection-coverage state instead +of silently falling back to page-local UI state. Focused recovery routes with +an active `rollupId` or `day` remain recovery-events-first by default, and +default event routes should omit redundant `view=events` query state. That same shared route-helper contract now also has to preserve exact storage and recovery handoffs for unified resources discovered outside the storage or recovery pages. When alerts, Patrol, or infrastructure drawers route a @@ -1861,48 +1878,35 @@ fetch contract before consulting websocket churn so the storage surface does not present healthy REST-backed data as down or stale. Meanwhile, `frontend-modern/src/components/Recovery/` and the recovery hooks define the -timeline, protected-item, and recovery-summary UX. -That governed page frame now starts with the shared `RecoverySummary.tsx` -overview card so operators land on a provider-neutral recovery posture model -before they drill into either protected-item inventory or recovery events. -The summary remains additive to the inventory-first workflow rather than a -replacement for it: Proxmox-heavy operators still land on protected items by -default, but the first visible framing now reflects the whole multi-platform -recovery system instead of a single backup table metaphor. +event timeline, protection-coverage review, and recovery-history UX. The +governed page frame is event-first: operators land directly on concrete +backup/snapshot/replication history, with `RecoveryActivitySection.tsx` acting +as compact orientation for the visible event table. The former decorative +recovery summary-card strip is retired because workloads already owns the +object-level "has a backup" scan, while Recovery owns concrete recovery events +and an explicit secondary protection-coverage review. That top recovery frame must rely on solid elevated operator panels and border hierarchy rather than decorative gradients so the page reads like a monitoring -workspace instead of a marketing-style dashboard shell. -That same framing must stay compact enough to keep the primary workspace in the -first scroll window. `RecoverySummary.tsx` and the workspace shell in -`Recovery.tsx` should compress posture, coverage, and mode framing into concise -operator panels instead of stacking multiple dashboard-like slabs that push the -main inventory or event table too far down the page. -That same summary shell should read as one dominant posture surface with a -supporting rail, not as several equal-weight dashboard cards. The governed -recovery summary should keep the left side focused on posture, attention, and -freshness, while the right side carries compact coverage and recent-history -reference detail instead of competing headline widgets. -That recovery summary rail is intentionally not part of the interactive -page/group/entity summary contract used by workloads, infrastructure, and -storage. `frontend-modern/src/components/Recovery/RecoverySummary.tsx` may -reuse shared summary-card framing, but it must remain a static posture surface: -no synchronized chart hover, no row-driven group scope, and no implicit -adoption of `summaryCardInteraction.ts` without a separate governed product -decision that proves recovery actually wants that interaction model. -That same workspace shell should stay a compact control strip rather than a -second summary card: the protected-items versus recovery-events switcher belongs -close to the active table surface, and it should not restate table counts that -are already carried by the selected tab labels or the summary frame above. -That same summary contract now also includes platform- and item-coverage -framing through `frontend-modern/src/utils/recoverySummaryPresentation.ts`, so -the first visible recovery overview explicitly shows which protected item -types Pulse is covering before it shows supporting platform mix, with -primary-item and primary-platform readouts kept as distinct governed summary -fields instead of one ambiguous "primary" label. The summary may still surface -platform breadth and cross-platform protected items, but the recovery page -must read item-first so the unified recovery model is not visually anchored to -one platform family. -That same item-first rule also applies to the protected inventory table: +workspace instead of a marketing-style dashboard shell. The page must stay +compact enough to keep the activity chart and primary event table in the first +scroll window instead of stacking dashboard-like slabs above the work surface. +Protection coverage remains a secondary review, opened through the explicit +header action or canonical compatibility route state when the operator needs to +audit stale, failed, warning, running, unknown, or never-succeeded items. It +must expose a reciprocal page-header action back to Recovery events, so opening +coverage never leaves the operator dependent on the global Recovery nav item or +small table chrome to return to the primary page. It must not return as an +equal-weight subtab or a top-level posture-card action strip without a separate +governed product decision. +Recovery is also intentionally outside the interactive page/group/entity +summary-card contract used by workloads, infrastructure, and storage. The +recovery route must not adopt `summaryCardInteraction.ts`, synchronized card +hover, row-driven summary scope, or shared `SummaryPanel` card framing simply +because adjacent monitoring pages use those primitives. Recovery may still show +coverage breadth and platform context inside the Protection coverage table and +event filters, but the page must read item-first so the unified recovery model +is not visually anchored to one platform family. +That same item-first rule also applies to the protection coverage table: `RecoveryProtectedInventorySection.tsx` must surface protected item type as a first-class column in the main inventory grid rather than leaving platform as the only structural classifier beside the item label. Platform badges remain @@ -2003,10 +2007,6 @@ That same direct proof rule also applies to the shared recovery status helper: `frontend-modern/src/utils/recoveryStatusPresentation.ts` must stay on the explicit `recovery-product-surface` proof path instead of inheriting coverage only through pages or higher-level recovery components. -That same direct proof rule also applies to the shared recovery summary helper: -`frontend-modern/src/utils/recoverySummaryPresentation.ts` must stay on the -explicit `recovery-product-surface` proof path instead of inheriting coverage -only through pages or higher-level recovery components. The default protected-inventory recovery route must also keep its primary table shell on class-driven sizing (`table-fixed` plus owned width classes) instead of inline `table-layout` / `min-width` styles, so the public recovery @@ -2058,142 +2058,58 @@ on the explicit `recovery-product-surface` proof path instead of inheriting coverage only through pages or higher-level recovery components. Those recovery transport surfaces now also share one normalized filter -contract: protected-item rollups, point history, facets, and chart series must +contract: protection rollups, point history, facets, and chart series must all honor the same canonical `platform`, canonical `itemType`, cluster, node, namespace, workload-scope, verification, and route-backed free-text `q` filter so the -protected-items list cannot drift from the timeline and facet state under the +protection coverage list cannot drift from the timeline and facet state under the same active recovery view. That same recovery filter contract now depends on the canonical recovery index carrying a normalized `itemType` instead of forcing each UI surface to re-derive protected item classes from raw provider-native `subjectType` values. That same recovery product surface keeps the primary workspace visually ahead -of secondary analytics: once the summary frame ends, -`frontend-modern/src/components/Recovery/Recovery.tsx` must lead directly into -the route-backed recovery workspace, with protected-items defaulting straight -to the primary inventory table and recovery-events owning both the activity -timeline and the event table together. The activity timeline remains required -even when point-history loading fails, but it belongs to the events workspace -rather than hanging below the inventory workspace as a competing page-level -peer. -That same recovery product surface must also stay stylistically aligned with -established Pulse monitoring surfaces once shared primitives already exist. -`frontend-modern/src/components/Recovery/RecoverySummary.tsx` should compose -the shared `SummaryPanel` and `SummaryMetricCard` primitives the way -infrastructure and workloads do, and recovery item-type labels should render -through canonical workload/resource badge classes instead of adding -recovery-only wrapper chrome around VM, container, or other resource badges. -That same summary frame should stay compact and scan-first, not turn into a -recovery-specific mini report. Recovery summary cards should prefer concise -counts, bars, and badge-backed coverage cues over tall explanatory copy blocks -so the protected-item table takes over the page at roughly the same visual -density as the infrastructure and workloads monitoring surfaces. -That same summary rule also applies to the panel header: the top-line summary -count strip should stay terse and avoid repeating the full posture telemetry -already rendered inside the cards, so the header acts as orientation rather -than a second miniature dashboard row. -That same header rule should default to the protected-item total only rather -than carrying separate attention, healthy, or running counters; those signals -belong in the cards themselves. -That same summary layout should also stay on the shared monitoring rhythm: -recovery should use the standard four-card `SummaryPanel` grid without letting -one oversized posture card dominate the full strip, so operators can scan -posture, freshness, footprint, and recent history at the same glance depth -they get on infrastructure and workloads. -That same summary rule also applies within individual cards: recovery posture, -freshness, attention, footprint, and history cards should favor compact rows -and metric lists over stacked prose callouts so the summary strip reads like -Pulse monitoring telemetry rather than a page-local narrative panel. -That same orientation-first rule also applies at the page-shell boundary: -recovery should hand straight from the summary strip into the active workspace -card without an extra page-local spacer band that makes the shell feel softer -than storage or workloads. -That same storage-style page rhythm also applies to the workspace tabs: -`Protected items` / `Recovery events` should live as a standalone subtab row -above the active section, not inside the data card itself. -That same card-level scan rule should prefer one dominant metric per card with -short supporting readouts, the same quick-scan rhythm operators already get on -infrastructure and workloads, instead of nested sub-cards that turn recovery -summary into a denser bespoke dashboard than the rest of Pulse. -That same summary-card rule should also avoid inset mini-panels inside the -cards themselves. Recovery summary cards should prefer direct metric rows, -lists, and badge-backed counts over "cards inside cards", which make the strip -feel heavier than the equivalent infrastructure and workloads telemetry. -That same compact-summary rule should also avoid repeating the same attention, -stale, and footprint telemetry in multiple stacked readouts inside one card. -Recovery summary cards should keep one dominant metric with one short support -band so the strip scans at the same speed as infrastructure and workloads -instead of behaving like a recovery-only executive summary. -That same card-headline rule should keep the helper label terse too. Recovery -headline captions should prefer short monitoring terms like `attention`, -`healthy`, `running`, `types`, or `points` over sentence-like helper copy that -makes the strip feel more bespoke than storage or workloads. -That support band should stay genuinely short, ideally one compact support line -and only occasionally two, so the operator can scan the summary as orientation -instead of reading each card like a dense checklist. -When a second support line does not materially change triage, recovery should -prefer a single support line over filling the card just because there is room. -Those support rows should also stay signal-driven. Recovery summary cards -should omit zero-value or low-value support rows like `Primary Item` or `Peak -Day` when they do not materially improve operator triage, so the top strip -stays closer to the fast-scan rhythm used by the infrastructure and workloads -surfaces. -That same summary-scan rule should also trim derivative rows that restate the -headline instead of adding a new operator question. `Freshness` should not add -an extra `<24h` row under a `fresh in 24h` headline, `Coverage` should not -spend a row repeating a primary-platform label when platform count already -frames the footprint, and `Activity` should stay on a short activity readout -rather than carrying a four-line micro report. -That same summary-card rule should also avoid inline distribution bars or -dashboard-style subvisualizations inside the cards when the same signal can be -expressed as short metric rows. Recovery summary cards should stay on the -shared monitoring-card rhythm of one dominant metric plus compact supporting -rows rather than reviving bespoke visual telemetry that makes the strip read -heavier than the rest of Pulse. -That same scan rule should also keep the summary header to orientation rather -than another status rail. The header should carry total protected items only; -the `Posture` card owns the attention-versus-healthy composition so the strip -does not repeat the same posture cue both above and inside the cards. -That same differentiation rule applies across cards too. `Posture` and -`Freshness` should not lead with the same stale/attention headline; the -freshness card should emphasize recent successful coverage such as fresh-in-24h -reach, while posture owns the attention-state headline. -That same page-level ownership applies to the recovery time window. Recovery -should use the shared summary-panel range control as the canonical time-range -selector, the same way infrastructure and workloads do, instead of hiding a -separate range selector inside the activity strip and making operators manage -two competing range affordances for one page. -That same density rule should route through the shared summary primitives -instead of feature-local spacing overrides. Recovery may select the shared -default `SummaryPanel` / `SummaryMetricCard` density mode, and it should not -reintroduce one-off padding hacks or right-side duplicate KPI blocks inside -`RecoverySummary.tsx`. -That same shared summary-card contract must also keep recovery on the -auto-sized card path while chart-backed monitoring summaries use the explicit -chart slot, so recovery cards stay compact and the shared hover geometry fix -does not reintroduce recovery-only whitespace or summary-height drift. -That same one-headline rule applies to the footprint and activity cards too. -`Coverage` should lead with one dominant item-type count and route platform -count through the shared card-header secondary label path instead of another -body row, and `Activity` should route latest-activity context through that same -secondary-label path instead of adding recovery-only support text under the -metric. -That same summary-header rule also applies to posture and freshness. Recovery -should prefer shared card-header secondary labels such as healthy-count or -stale-count context instead of stacking extra recovery-only support rows under -those headline metrics. -That same scan-first rule applies to the workspace strip. Recovery should not -show tab labels and then repeat the same workspace count as standalone text in -the same strip; the workspace tabs should carry their own counts, while the -remaining strip cues focus on issues or drill-in context. -That same inventory surface should also follow the established monitoring-table -scan pattern in its first column. Protected-item rows should lead with a clear +of secondary analytics: `frontend-modern/src/components/Recovery/Recovery.tsx` +must lead directly into the route-backed recovery events workspace, with +recovery events owning both the activity timeline and the event table together. +Protection coverage opens only through the explicit header action or +compatibility route state. The activity timeline remains required even when +point-history loading fails, but it belongs to the events workspace rather than +hanging beside coverage as a competing page-level peer. +That same recovery product surface must not reintroduce a four-card posture, +freshness, coverage, or activity strip as decorative orientation. Workloads is +the better object-level place to scan whether a resource has backup coverage; +Recovery should reserve its first viewport for concrete recovery history and +only expose coverage rollups when the operator asks for the secondary review. +That same page-level ownership applies to the recovery time window. The +canonical range selector now lives inside `RecoveryActivitySection.tsx` because +it controls the event timeline and the event query window, not a page-level KPI +strip. Range changes must clear selected-day focus and return the events table +to page one through the route-state owner so the chart and table remain aligned. +That same activity-section contract should stay compact and scan-first: +recovery-point volume, active-day count, stale count, and average rate may +appear as concise inline readouts beside the chart controls, but not as separate +top-level metric cards or nested mini-panels. The activity strip must also avoid +interpreted anomaly callouts such as "lowest active day" unless the recovery +model has canonical schedule expectations that make the signal actionable. +Item-type labels should continue to render through canonical workload/resource +badge classes in the event and coverage tables instead of adding recovery-only +wrapper chrome around VM, container, or other resource badges. +That same event-first shell rule should hand straight from the activity section +into the active events workspace without an extra page-local spacer band, +default tab row, or duplicate status strip that makes the surface softer than +storage or workloads. +That same scan-first rule applies to the secondary coverage surface. Recovery +should not show equal workspace tab labels and then repeat the same workspace +count as standalone text; protection coverage cues should focus on issues, +drill-in context, and active filters. +That same coverage surface should also follow the established monitoring-table +scan pattern in its first column. Coverage rows should lead with a clear status cue, the primary item name, and compact badge-backed item/platform context instead of relying on recovery-only rails or plain-text metadata lines that make the table read like a report instead of an operational grid. -That same triage rule applies to the default inventory sort. The protected -items workspace should open with attention-state rollups first instead of -defaulting to newest-successful backups, so operators land on failed, -never-succeeded, stale, warning, and running items before the healthy catalog. +That same triage rule applies to the coverage sort. Protection coverage should +open with attention-state rollups first instead of defaulting to +newest-successful backups, so operators land on failed, never-succeeded, stale, +warning, and running items before the healthy catalog. That same row contract should avoid duplicating context that already has a dedicated column. When `Item Type` and `Platform` columns are visible, the primary item cell should not restate those same badges on desktop; duplicate @@ -2209,21 +2125,22 @@ recovery points. When the persisted subject label is just a raw `pve-task:*`/`UPID:*` identifier or `vmid=0`, the canonical recovery index should derive a readable task label and `task` item type from point details so recovery tables scan by operator meaning instead of transport IDs. -That same inventory surface should stay on the flat monitoring-table pattern -already used elsewhere in Pulse. Protected items should surface posture through +That same coverage surface should stay on the flat monitoring-table pattern +already used elsewhere in Pulse. Protection coverage should surface posture through row-level status cues, outcome pills, and filters rather than inserting extra `Needs Attention` / `Healthy Coverage` section rows that add height and turn -the primary table into a recovery-only grouped report. -That same protected-items table should also avoid recovery-local pagination +the table into a recovery-only grouped report. +That same protection-coverage table should also avoid recovery-local pagination chrome. The workspace already holds the filtered rollups client-side, so it -should read as one continuous monitoring table with a simple protected-item +should read as one continuous monitoring table with a simple coverage-item count instead of introducing `Prev` / `Next` buttons and page counters that do not match the canonical Pulse scan pattern. -That same table-shell contract must avoid duplicate framing once the view tabs -already establish the active workspace. `RecoveryProtectedInventorySection.tsx` -should keep page/count/sort orientation inside a slim table-shell status row -and let the filter strip lead directly into the grid instead of reintroducing a -second large inventory header card above the same table. +That same table-shell contract must avoid duplicate framing once the summary +action has established the secondary coverage workspace. +`RecoveryProtectedInventorySection.tsx` should keep page/count/sort +orientation inside a slim table-shell status row and let the filter strip lead +directly into the grid instead of reintroducing a second large inventory header +card above the same table. That same shell rule should also avoid low-signal bookkeeping above the grid. The protected-items status row should surface the active workspace, protected item count, and issue cues, but page-number and sort-direction bookkeeping @@ -2240,27 +2157,19 @@ use the standard shared header/body dividers and avoid both local suppression of those separators and local duplicate row or header borders that make the lines read heavier than other monitoring tables. That same workspace-shell rule should also avoid a dedicated recovery-only -status strip above the control bar. Recovery should use the same handoff shape -as storage: a standalone shared `Subtabs.tsx` row, then any workspace-specific -context like activity, then a shared controls card, then a data card. Recovery -should not collapse controls and content back into one fused workspace slab or -bury the switcher inside the filter row. -That same strip should not repeat page-level counts or posture cues once the -summary already owns those signals. The recovery workspace tabs should stay on -plain canonical labels like `Protected items` and `Recovery events` rather than -embedding per-view totals in the tab text, and the protected-items control row -should stay focused on navigation, drill-in context, and active filters instead -of echoing page-wide posture pills above the same table. -That same workspace handoff should stay on shared primitive styling too. When -recovery renders its workspace tabs, the switcher should use the same canonical -shared `Subtabs.tsx` shell, list, and button class pattern already used by -storage and other established Pulse tab surfaces instead of inventing a +status strip above the control bar. Recovery should use an event-first handoff: +activity context, a shared controls card, then a data card. Protection coverage +may use its own shared controls card and table when explicitly opened, but +Recovery should not collapse controls and content back into one fused workspace +slab or bury secondary workspace navigation inside the filter row. +That same strip should not repeat page-level counts or posture cues as a +replacement for the retired summary strip. Protection coverage controls should +stay focused on drill-in context and active filters instead of echoing page-wide +posture pills above the same table. +That same workspace handoff should stay on shared primitive styling too. +Recovery events and protection coverage should keep using shared `FilterBar`, +`TableCard`, and `TableCardHeader` primitives instead of inventing a recovery-only variant or recovery-only class stack. -That same workspace rule also means the protected-items versus recovery-events -switcher should live in its own standalone row above the active workspace, -ahead of activity, controls, and data, so the page hands off from summary into -the active recovery surface the same way storage does rather than fusing the -entire workspace into one recovery-only card. That same canonical-row rule also means the subtabs row should stand on its own full-width shell instead of sharing a flex line with recovery-only chips or adjacent badges that break the storage-style border and spacing treatment. @@ -2272,11 +2181,10 @@ recovery-local grid overrides or width hacks. Protected-items controls should also use the same shared `Reset all` page-controls action pattern as storage and workloads when visible filters are active, instead of forcing operators to clear each inventory filter manually. -That same handoff should keep recovery on the standard Pulse summary density. -`RecoverySummary.tsx` should use the shared default `SummaryPanel` / -`SummaryMetricCard` rhythm that infrastructure and workloads use, instead of -opting into a recovery-only compact density that makes the top strip harder to -scan than the rest of the monitoring product. +That same handoff should keep Recovery out of shared summary-card density +tuning unless a new governed product decision reintroduces a first-viewport +summary owner. The current route should spend its top-level density budget on +the activity section, compact controls, and one primary data card. That same shell rule applies to the recovery-events workspace. `RecoveryHistorySection.tsx` should use the same slim status-row-plus-filter-row pattern as the protected inventory surface, not a separate large titled header diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 805bf1c3b..c5f4ecb7e 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -204,6 +204,15 @@ cross-source deduplication. no-parity posture, but those labels must derive from canonical incident or storage-risk summaries already present on the resource rather than local platform heuristics or generic status-only wording. + Agent-backed Unraid topology belongs in the canonical adapter layer, not in + storage-page inference. Assigned Unraid array members must produce physical + disk resources even when SMART telemetry is absent, Unraid cache/pool members + must produce agent-backed storage resources with native capacity metrics, and + SMART rows may enrich or merge with those resources only through normalized + device or serial identity. Generic SMART status must not overwrite stronger + Unraid health/risk, and physical-disk metric IDs must prefer stable serials + while normalizing device tokens such as `/dev/sdd` and `sdd [sat]` to the + same fallback identity. Frontend canonicalization must treat top-level source facets as source evidence when `platformData.sources` is absent, but it must not invent provider facets from generic agent telemetry: flat agent disk payloads stay diff --git a/frontend-modern/src/components/Storage/Storage.tsx b/frontend-modern/src/components/Storage/Storage.tsx index 497f22404..615161fae 100644 --- a/frontend-modern/src/components/Storage/Storage.tsx +++ b/frontend-modern/src/components/Storage/Storage.tsx @@ -88,7 +88,7 @@ const Storage: Component = () => {
diff --git a/frontend-modern/src/components/Storage/StoragePoolDetail.tsx b/frontend-modern/src/components/Storage/StoragePoolDetail.tsx index 7ded48a99..ad1357c39 100644 --- a/frontend-modern/src/components/Storage/StoragePoolDetail.tsx +++ b/frontend-modern/src/components/Storage/StoragePoolDetail.tsx @@ -48,11 +48,18 @@ interface StoragePoolDetailProps { } export const StoragePoolDetail: Component = (props) => { - const { chartRange, setChartRange, chartTarget, configRows, zfsSummary, linkedDisks } = - useStoragePoolDetailModel({ - record: () => props.record, - physicalDisks: () => props.physicalDisks, - }); + const { + chartRange, + setChartRange, + chartTarget, + configRows, + topologyRows, + zfsSummary, + linkedDisks, + } = useStoragePoolDetailModel({ + record: () => props.record, + physicalDisks: () => props.physicalDisks, + }); const rangeOptions = createMemo(() => getUnlockedHistoryRangeOptions(STORAGE_POOL_DETAIL_HISTORY_RANGE_OPTIONS, maxHistoryDays()), ); @@ -106,6 +113,17 @@ export const StoragePoolDetail: Component = (props) => { {/* Right: Configuration & details */}
+ 0}> +
+

Topology

+
+ + {(row) => } + +
+
+
+ {/* Config card */}

Configuration

@@ -154,6 +172,28 @@ export const StoragePoolDetail: Component = (props) => { class={`flex-shrink-0 ${getLinkedDiskHealthDotClass(disk.hasIssue)}`} /> {disk.model} + + + {disk.role} + + + + {disk.sizeLabel} + + + {disk.state} + + + {disk.ioLabel} + + + spun down + + 0}> + + {disk.errorCount.toLocaleString()} errors + + 0}> = (props) => { —} > - {row().compactIssue} + {row().stateLabel} @@ -171,7 +168,6 @@ export const StoragePoolRow: Component = (props) => { {row().capacityDeltaLabel} - = (props) => { })); }); const interactiveSummarySeries = createMemo(() => [ - ...allPoolUsageSeries(), - ...allPoolUsedSeries(), - ...allPoolAvailSeries(), - ...allDiskTempSeries(), + ...allPoolUsageSeries(), + ...allPoolUsedSeries(), + ...allPoolAvailSeries(), + ...allDiskTempSeries(), ]); const summaryFocus = useSummaryContextualFocusState({ chartHoveredSeriesId: () => chartHoverSync()?.seriesId ?? null, @@ -214,6 +210,12 @@ const StorageSummary: Component = (props) => { const formatTemp = (value: number) => `${value.toFixed(0)}°C`; const showComponent = () => props.poolCount > 0 || props.diskCount > 0; + const storageItemCountLabel = () => + `${props.poolCount} storage ${props.poolCount === 1 ? 'item' : 'items'}`; + const storageAttentionLabel = () => { + const count = props.poolsDegraded ?? 0; + return `${count} ${count === 1 ? 'needs' : 'need'} attention`; + }; const getFocusedSeriesName = (series: InteractiveSparklineSeries[]): string | null => summaryFocus.getActiveSeriesName(series); const focusedLabel = (series: InteractiveSparklineSeries[]) => { @@ -270,15 +272,13 @@ const StorageSummary: Component = (props) => { testId="storage-summary" headerLeft={ <> - - {props.poolCount} {props.poolCount === 1 ? 'pool' : 'pools'} - + {storageItemCountLabel()} 0 || (props.disksFailing ?? 0) > 0} fallback={ <> 0 || props.diskCount > 0}> - all healthy + all online 0}> @@ -293,17 +293,17 @@ const StorageSummary: Component = (props) => { when={props.onScopeToDegradedPools} fallback={ - {props.poolsDegraded} degraded + {storageAttentionLabel()} } > @@ -312,7 +312,8 @@ const StorageSummary: Component = (props) => { when={props.onScopeToFailingDisks} fallback={ - {props.disksFailing} {props.disksFailing === 1 ? 'disk failing' : 'disks failing'} + {props.disksFailing}{' '} + {props.disksFailing === 1 ? 'disk failing' : 'disks failing'} } > @@ -322,7 +323,8 @@ const StorageSummary: Component = (props) => { title="Show physical disks" class="text-amber-600 hover:text-amber-700 hover:underline focus:underline focus:outline-none dark:text-amber-400 dark:hover:text-amber-300" > - {props.disksFailing} {props.disksFailing === 1 ? 'disk failing' : 'disks failing'} + {props.disksFailing}{' '} + {props.disksFailing === 1 ? 'disk failing' : 'disks failing'} @@ -336,7 +338,7 @@ const StorageSummary: Component = (props) => { onTimeRangeChange={props.onTimeRangeChange} > { }); const poolUsageChart = screen - .getByText('Pool Usage') + .getByText('Storage Usage') .closest('[data-summary-card-state]') ?.querySelector('svg'); expect(poolUsageChart).not.toBeNull(); @@ -691,7 +691,7 @@ describe('Storage', () => { expect(screen.getByRole('columnheader', { name: 'Type' })).toBeInTheDocument(); expect(screen.getByRole('columnheader', { name: 'Host' })).toBeInTheDocument(); expect(screen.getByRole('columnheader', { name: 'Usage' })).toBeInTheDocument(); - expect(screen.getByRole('columnheader', { name: 'Primary Issue' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'State' })).toBeInTheDocument(); expect(screen.getAllByText('PVE').length).toBeGreaterThan(0); expect(screen.getAllByText('pve1').length).toBeGreaterThan(0); expect(screen.getAllByText('Protection Reduced').length).toBeGreaterThan(0); @@ -760,7 +760,7 @@ describe('Storage', () => { expect((screen.getByLabelText('Sort by') as HTMLSelectElement).value).toBe('usage'); // Grouping controls are only shown on the Pools view. - fireEvent.click(screen.getByRole('tab', { name: 'Pools' })); + fireEvent.click(screen.getByRole('tab', { name: 'Storage' })); expect(queryStorageChip('Group by')).toHaveTextContent('Group by:By status'); expect(queryStorageChip('Source')).toHaveTextContent('Source:PVE'); expect(queryStorageChip('Status')).toHaveTextContent('Status:Warning'); @@ -1226,7 +1226,7 @@ describe('Storage', () => { ).toBe(false); expect( screen - .getByText('Pool Usage') + .getByText('Storage Usage') .closest('[data-summary-card-state]') ?.getAttribute('data-summary-card-state'), ).toBe('active'); @@ -1815,7 +1815,7 @@ describe('Storage', () => { render(() => ); expect(screen.getAllByRole('tablist', { name: 'Storage view' })[0]).toBeInTheDocument(); - expect(screen.getAllByRole('tab', { name: 'Pools' })[0]).toHaveAttribute( + expect(screen.getAllByRole('tab', { name: 'Storage' })[0]).toHaveAttribute( 'aria-selected', 'true', ); diff --git a/frontend-modern/src/components/Storage/__tests__/StorageGroupRow.test.tsx b/frontend-modern/src/components/Storage/__tests__/StorageGroupRow.test.tsx index 7c6ac1451..2ffe2fbd6 100644 --- a/frontend-modern/src/components/Storage/__tests__/StorageGroupRow.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/StorageGroupRow.test.tsx @@ -49,7 +49,7 @@ describe('StorageGroupRow', () => { expect(screen.getByText('tower')).toBeInTheDocument(); expect(screen.getByText('tower').closest('tr')).toHaveClass('grouped-table-row'); - expect(screen.getByText('2 pools')).toBeInTheDocument(); + expect(screen.getByText('2 storage items')).toBeInTheDocument(); expect(screen.getByText('40%')).toBeInTheDocument(); expect(container.querySelector('.bg-green-500')).toBeInTheDocument(); expect(container.querySelector('.bg-yellow-500')).toBeInTheDocument(); @@ -64,7 +64,7 @@ describe('StorageGroupRow', () => { const onHoverChange = vi.fn(); const scope = { id: 'storage:node:tower', - label: 'tower (2 pools)', + label: 'tower (2 storage items)', seriesIds: ['pool-1', 'pool-2'], }; diff --git a/frontend-modern/src/components/Storage/__tests__/StoragePoolDetail.test.tsx b/frontend-modern/src/components/Storage/__tests__/StoragePoolDetail.test.tsx index b3dcb0fa5..230204833 100644 --- a/frontend-modern/src/components/Storage/__tests__/StoragePoolDetail.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/StoragePoolDetail.test.tsx @@ -90,10 +90,7 @@ describe('StoragePoolDetail', () => { const rangeSelector = screen.getByRole('combobox', { name: 'Capacity trend range', }) as HTMLSelectElement; - expect(Array.from(rangeSelector.options).map((option) => option.value)).toEqual([ - '24h', - '7d', - ]); + expect(Array.from(rangeSelector.options).map((option) => option.value)).toEqual(['24h', '7d']); fireEvent.change(rangeSelector, { target: { value: '7d' } }); @@ -133,4 +130,72 @@ describe('StoragePoolDetail', () => { }), ); }); + + it('renders UnRAID topology and linked disk facts plainly', () => { + historyChartSpy.mockClear(); + + render(() => ( + + + + +
+ )); + + expect(screen.getByText('Topology')).toBeInTheDocument(); + expect(screen.getByText('Kind')).toBeInTheDocument(); + expect(screen.getByText('Array')).toBeInTheDocument(); + expect(screen.getByText('Data disks')).toBeInTheDocument(); + expect(screen.getAllByText('1 disk').length).toBeGreaterThan(0); + expect(screen.getByText('Data Disk')).toBeInTheDocument(); + expect(screen.getByText('data')).toBeInTheDocument(); + expect(screen.getByText('R 10 / W 20')).toBeInTheDocument(); + expect(screen.getByText('spun down')).toBeInTheDocument(); + expect(screen.getByText('16 errors')).toBeInTheDocument(); + }); }); diff --git a/frontend-modern/src/components/Storage/__tests__/StorageSummary.test.tsx b/frontend-modern/src/components/Storage/__tests__/StorageSummary.test.tsx index 07be05606..59049dac1 100644 --- a/frontend-modern/src/components/Storage/__tests__/StorageSummary.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/StorageSummary.test.tsx @@ -33,11 +33,13 @@ vi.mock('@/components/shared/InteractiveSparkline', () => ({ activeSeriesDisplay?: string; hoverSourceKey?: string; hoverSync?: { seriesId: string } | null; - onHoverSyncChange?: (value: { - sourceKey: string; - seriesId: string; - timestamp: number; - } | null) => void; + onHoverSyncChange?: ( + value: { + sourceKey: string; + seriesId: string; + timestamp: number; + } | null, + ) => void; }) => { const series = props.series ?? []; const triggerHover = () => { @@ -207,7 +209,7 @@ describe('StorageSummary', () => { const poolIds = ['pool:alpha', 'pool:beta', 'pool:gamma']; const hoveredGroupScope: SummarySeriesGroupScope = { id: 'storage:node:pve1', - label: 'pve1 (2 pools)', + label: 'pve1 (2 storage items)', seriesIds: poolIds.slice(0, 2), }; const data = buildSummaryData({ @@ -257,5 +259,4 @@ describe('StorageSummary', () => { } }); }); - }); diff --git a/frontend-modern/src/components/Storage/__tests__/useStoragePageSummary.test.ts b/frontend-modern/src/components/Storage/__tests__/useStoragePageSummary.test.ts index 13fad1be3..b0c314565 100644 --- a/frontend-modern/src/components/Storage/__tests__/useStoragePageSummary.test.ts +++ b/frontend-modern/src/components/Storage/__tests__/useStoragePageSummary.test.ts @@ -89,4 +89,83 @@ describe('useStoragePageSummary', () => { expect(result.poolsDegraded()).toBe(0); expect(result.disksFailing()).toBe(0); }); + + it('does not count Unraid disks with only missing SMART data as needing attention', () => { + const [filteredRecords] = createSignal([makeRecord('tower-array', 'healthy')]); + const [selectedNodeId] = createSignal('tower'); + const [search] = createSignal(''); + const [sourceFilter] = createSignal('all'); + const [healthFilter] = createSignal('all' as const); + const [diskRoleFilter] = createSignal('all'); + const [diskGroupFilter] = createSignal('all'); + const [nodeOptions] = createSignal([{ id: 'tower', label: 'Tower' }]); + const [physicalDisks] = createSignal([ + { + id: 'tower-disk-spundown', + type: 'physical_disk', + name: 'disk1', + status: 'offline', + platformType: 'agent', + parentId: 'tower-array', + identity: { hostname: 'Tower' }, + canonicalIdentity: { hostname: 'Tower' }, + physicalDisk: { + devPath: '/dev/sdb', + model: 'WDC WD60EFRX', + health: 'UNKNOWN', + storageRole: 'data', + storageGroup: 'unraid-array', + storageState: 'online', + spunDown: true, + temperature: 0, + }, + }, + { + id: 'tower-disk-errors', + type: 'physical_disk', + name: 'disk2', + status: 'warning', + platformType: 'agent', + parentId: 'tower-array', + identity: { hostname: 'Tower' }, + canonicalIdentity: { hostname: 'Tower' }, + physicalDisk: { + devPath: '/dev/sdc', + model: 'WDC WD60EFRX', + health: 'UNKNOWN', + storageRole: 'data', + storageGroup: 'unraid-array', + storageState: 'online', + errorCount: 4, + risk: { + level: 'warning', + reasons: [ + { + code: 'unraid_disk_errors', + severity: 'warning', + summary: 'Unraid disk disk2 reports 4 error(s)', + }, + ], + }, + }, + }, + ] as Resource[]); + + const { result } = renderHook(() => + useStoragePageSummary({ + filteredRecords, + search, + sourceFilter, + healthFilter, + diskRoleFilter, + diskGroupFilter, + selectedNodeId, + nodeOptions, + physicalDisks, + }), + ); + + expect(result.diskCount()).toBe(2); + expect(result.disksFailing()).toBe(1); + }); }); diff --git a/frontend-modern/src/components/Storage/useStoragePoolDetailModel.ts b/frontend-modern/src/components/Storage/useStoragePoolDetailModel.ts index d3b452cb6..bf22c321b 100644 --- a/frontend-modern/src/components/Storage/useStoragePoolDetailModel.ts +++ b/frontend-modern/src/components/Storage/useStoragePoolDetailModel.ts @@ -3,6 +3,7 @@ import type { HistoryTimeRange } from '@/api/charts'; import type { StorageRecord } from '@/features/storageBackups/models'; import { buildStoragePoolDetailConfigRows, + buildStoragePoolDetailTopologyRows, buildStoragePoolDetailZfsSummary, getStoragePoolLinkedDisks, resolveStoragePoolDetailChartTarget, @@ -23,12 +24,16 @@ export const useStoragePoolDetailModel = (options: UseStoragePoolDetailModelOpti const linkedDisks = createMemo(() => getStoragePoolLinkedDisks(options.record(), options.physicalDisks()), ); + const topologyRows = createMemo(() => + buildStoragePoolDetailTopologyRows(options.record(), linkedDisks()), + ); return { chartRange, setChartRange, chartTarget, configRows, + topologyRows, zfsSummary, linkedDisks, }; diff --git a/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts index bebee5940..ae9330151 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts @@ -27,7 +27,9 @@ import { getPhysicalDiskRoleFilterValue, getPhysicalDiskSourceKey, getPhysicalDiskSourceBadgePresentation, + hasUnraidPhysicalDiskFaultSignal, hasPhysicalDiskSmartWarning, + isUnraidPhysicalDisk, matchesPhysicalDiskSearch, normalizePhysicalDiskFacetFilter, type PhysicalDiskPresentationData, @@ -211,6 +213,65 @@ describe('diskPresentation', () => { }); }); + it('keeps Unraid disks with missing SMART data out of attention unless Unraid reports a fault', () => { + const spunDownUnraidDisk = makeDiskData({ + health: 'UNKNOWN', + storageRole: 'data', + storageGroup: 'unraid-array', + storageState: 'online', + spunDown: true, + temperature: 0, + }); + const spunDownResource = { + status: 'offline', + platformType: 'agent', + physicalDisk: { + health: 'UNKNOWN', + storageRole: 'data', + storageGroup: 'unraid-array', + storageState: 'online', + spunDown: true, + }, + } as unknown as Resource; + + expect(isUnraidPhysicalDisk(spunDownUnraidDisk)).toBe(true); + expect(hasUnraidPhysicalDiskFaultSignal(spunDownUnraidDisk)).toBe(false); + expect(getPhysicalDiskHealthStatus(spunDownUnraidDisk)).toMatchObject({ + label: 'Online', + summary: 'No active disk-health issues.', + }); + expect(getPhysicalDiskNormalizedHealth(spunDownResource, spunDownUnraidDisk)).toBe('healthy'); + + const diskWithUnraidErrors = makeDiskData({ + health: 'UNKNOWN', + riskLevel: 'warning', + riskReasons: ['Unraid disk disk2 reports 3 error(s)'], + storageRole: 'data', + storageGroup: 'unraid-array', + storageState: 'online', + errorCount: 3, + }); + expect(hasUnraidPhysicalDiskFaultSignal(diskWithUnraidErrors)).toBe(true); + expect(getPhysicalDiskHealthStatus(diskWithUnraidErrors)).toMatchObject({ + label: 'Needs Attention', + summary: 'Unraid disk disk2 reports 3 error(s)', + }); + + const missingUnraidDisk = makeDiskData({ + health: 'FAILED', + storageRole: 'data', + storageGroup: 'unraid-array', + storageState: 'missing', + riskLevel: 'critical', + riskReasons: ['Unraid disk disk3 is MISSING'], + }); + expect(hasUnraidPhysicalDiskFaultSignal(missingUnraidDisk)).toBe(true); + expect(getPhysicalDiskHealthStatus(missingUnraidDisk)).toMatchObject({ + label: 'Replace Now', + summary: 'Unraid disk disk3 is MISSING', + }); + }); + it('extracts search and sort presentation canonically from resources', () => { const warningDisk = { id: 'disk-warning', diff --git a/frontend-modern/src/features/storageBackups/__tests__/groupPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/groupPresentation.test.ts index d56787d04..daf9aa491 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/groupPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/groupPresentation.test.ts @@ -19,8 +19,8 @@ describe('storage group presentation', () => { expect(STORAGE_GROUP_ROW_HEALTH_WRAP_CLASS).toContain('ml-auto'); expect(STORAGE_GROUP_ROW_CHEVRON_BASE_CLASS).toContain('transition-transform'); expect(STORAGE_GROUP_ROW_HEALTH_DOT_CLASS).toBe('w-2 h-2 rounded-full'); - expect(getStorageGroupPoolCountLabel(1)).toBe('1 pool'); - expect(getStorageGroupPoolCountLabel(2)).toBe('2 pools'); + expect(getStorageGroupPoolCountLabel(1)).toBe('1 storage item'); + expect(getStorageGroupPoolCountLabel(2)).toBe('2 storage items'); }); it('formats usage with the shared percent formatter', () => { @@ -83,7 +83,7 @@ describe('storage group presentation', () => { label: 'tower', showUsage: true, usagePercentLabel: '40%', - poolCountLabel: '2 pools', + poolCountLabel: '2 storage items', }); expect(getStorageGroupChevronClass(true)).toContain('rotate-90'); }); diff --git a/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts index 25d690913..14cd9deeb 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts @@ -206,13 +206,11 @@ describe('resourceStoragePresentation', () => { expect(getResourceStorageProtectionLabel(resource)).toBe('Parity check'); expect(getResourceStorageProtectionSummary(resource)).toBe('Unraid array is running check'); - expect(getResourceStorageIssueLabel(resource)).toBe('No parity protection'); - expect(getResourceStorageIssueSummary(resource)).toBe( - 'Unraid array is running without parity protection', - ); + expect(getResourceStorageIssueLabel(resource)).toBe('Healthy'); + expect(getResourceStorageIssueSummary(resource)).toBe(''); }); - it('marks an Unraid array without parity as Unprotected when no rebuild is running', () => { + it('marks an Unraid array without parity as a factual protection state', () => { const resource = makeResource({ type: 'storage', name: 'Tower Array', @@ -237,8 +235,8 @@ describe('resourceStoragePresentation', () => { }, }); - expect(getResourceStorageProtectionLabel(resource)).toBe('Unprotected'); - expect(getResourceStorageIssueLabel(resource)).toBe('No parity protection'); + expect(getResourceStorageProtectionLabel(resource)).toBe('No parity'); + expect(getResourceStorageIssueLabel(resource)).toBe('Healthy'); }); it('appends sync progress to Unraid parity-rebuild labels when known', () => { diff --git a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.test.ts index 3fb271582..7c79cd03e 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.test.ts @@ -8,6 +8,8 @@ import { getCompactStoragePoolProtectionTitle, getStoragePoolIssueTextClass, getStoragePoolProtectionTextClass, + getStoragePoolStateLabel, + getStoragePoolStateTextClass, } from '@/features/storageBackups/rowPresentation'; const baseRecord = (): StorageRecord => @@ -40,6 +42,19 @@ describe('storage row presentation', () => { expect(getStoragePoolProtectionTextClass(record)).toBe('text-red-700 dark:text-red-300'); }); + it('keeps no-parity protection factual instead of rendering it as reduced-health state', () => { + const record = { + ...baseRecord(), + health: 'healthy', + protectionLabel: 'No parity', + protectionReduced: true, + details: { arrayState: 'STARTED' }, + }; + expect(getStoragePoolProtectionTextClass(record)).toBe('text-base-content'); + expect(getStoragePoolStateLabel(record)).toBe('Started'); + expect(getStoragePoolStateTextClass(record)).toBe('text-base-content'); + }); + it('returns warning issue tone for warning storage rows', () => { const record = { ...baseRecord(), incidentSeverity: 'warning' }; expect(getStoragePoolIssueTextClass(record)).toBe('text-amber-700 dark:text-amber-300'); @@ -60,7 +75,7 @@ describe('storage row presentation', () => { ...baseRecord(), issueLabel: 'Protection Reduced', issueSummary: 'Parity lost on array', - protectionLabel: 'Unprotected', + protectionLabel: 'No parity', protectionSummary: 'Parity lost on array', protectionReduced: true, incidentSeverity: 'critical', diff --git a/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts index ef0c0da69..9557e2791 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts @@ -268,6 +268,46 @@ describe('storageAdapters', () => { expect(records[0].details?.isZfs).toBe(true); }); + it('keeps Unraid no-parity as protection metadata instead of degraded storage health', () => { + const records = buildStorageRecords({ + state: baseState(), + resources: [ + makeResourceStorage({ + id: 'tower-array', + name: 'Tower Array', + platformId: 'tower', + platformType: 'unraid', + status: 'degraded', + incidentSeverity: 'warning', + storage: { + type: 'unraid-array', + platform: 'unraid', + topology: 'array', + protection: 'none', + protectionReduced: true, + protectionSummary: 'Unraid array is running without parity protection', + arrayState: 'STARTED', + risk: { + level: 'warning', + reasons: [ + { + code: 'unraid_no_parity', + severity: 'warning', + summary: 'Unraid array is running without parity protection', + }, + ], + }, + }, + } as Partial), + ], + }); + + expect(records[0].health).toBe('healthy'); + expect(records[0].issueLabel).toBe('Healthy'); + expect(records[0].protectionLabel).toBe('No parity'); + expect(records[0].details?.status).toBe('STARTED'); + }); + it('maps a TrueNAS dataset with mounted state and zfs metadata', () => { const truenasDataset = { ...makeResourceStorage({ diff --git a/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts index 05f161e25..483c675bb 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts @@ -76,7 +76,7 @@ describe('storagePagePresentation', () => { expect(STORAGE_POOLS_TABLE_CLASS).toBe('w-full table-fixed text-xs'); expect(STORAGE_POOLS_HEADER_ROW_CLASS).toContain('bg-surface-alt'); expect(STORAGE_POOLS_BODY_CLASS).toBe('divide-y divide-border'); - expect(getStorageTableHeading('pools')).toBe('Storage Pools'); + expect(getStorageTableHeading('pools')).toBe('Storage'); expect(getStorageTableHeading('disks')).toBe('Physical Disks'); expect(getStorageLoadingMessage()).toBe('Loading storage resources...'); expect(getStorageEmptyStateMessage()).toBe('No storage records match the current filters.'); @@ -84,12 +84,12 @@ describe('storagePagePresentation', () => { it('exports canonical storage view and table column contracts', () => { expect(STORAGE_VIEW_OPTIONS).toEqual([ - { value: 'pools', label: 'Pools' }, + { value: 'pools', label: 'Storage' }, { value: 'disks', label: 'Physical Disks' }, ]); expect(getStoragePoolTableColumns('Growth (24h)').map((column) => column.label)).toEqual([ 'Storage', - 'Primary Issue', + 'State', 'Source', 'Type', 'Host', @@ -98,7 +98,7 @@ describe('storagePagePresentation', () => { 'Growth (24h)', ]); expect(getStoragePoolTableColumns('Growth (24h)').map((column) => column.compactLabel)).toEqual( - ['Storage', 'Issue', 'Src', 'Type', 'Host', 'Prot', 'Used', '24h'], + ['Storage', 'State', 'Src', 'Type', 'Host', 'Prot', 'Used', '24h'], ); expect(getStoragePoolTableColumns('Growth (24h)')[0].colClassName).toContain('xl:w-[18%]'); expect(getStoragePoolTableColumns('Growth (24h)')[3].className).toContain( diff --git a/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts index de269d1ae..c35d972bc 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts @@ -4,6 +4,7 @@ import type { Resource } from '@/types/resource'; import { STORAGE_POOL_DETAIL_HISTORY_RANGE_OPTIONS, buildStoragePoolDetailConfigRows, + buildStoragePoolDetailTopologyRows, buildStoragePoolDetailZfsSummary, getStoragePoolLinkedDisks, getZfsErrorSummary, @@ -57,6 +58,10 @@ const buildDisk = (): Resource => physicalDisk: { devPath: '/dev/sda', model: 'Disk A', + storageRole: 'data', + storageGroup: 'tank', + storageState: 'online', + sizeBytes: 1_000, temperature: 44, smart: { reallocatedSectors: 1 }, }, @@ -115,9 +120,102 @@ describe('storagePoolDetailPresentation', () => { id: 'disk-1', devPath: '/dev/sda', model: 'Disk A', + role: 'data', + state: 'online', + sizeLabel: '1000 B', temperature: 44, hasIssue: true, + spunDown: false, + errorCount: 0, + ioLabel: '', }, ]); }); + + it('links UnRAID array disks by storage group and summarizes native topology facts', () => { + const record = buildRecord({ + name: 'Tower Array', + source: { + platform: 'unraid', + family: 'onprem', + origin: 'resource', + adapterId: 'resource-storage', + }, + details: { + type: 'unraid-array', + platform: 'unraid', + topology: 'array', + arrayState: 'STARTED', + }, + }); + const disks = [ + { + id: 'parity', + type: 'physical_disk', + name: 'Parity', + displayName: 'Parity', + platformId: 'tower', + platformType: 'unraid', + sourceType: 'agent', + status: 'online', + lastSeen: Date.now(), + physicalDisk: { + devPath: '/dev/sdb', + model: 'Parity Disk', + storageRole: 'parity', + storageGroup: 'unraid-array', + storageState: 'online', + sizeBytes: 6_000, + temperature: 31, + }, + }, + { + id: 'disk1', + type: 'physical_disk', + name: 'Disk 1', + displayName: 'Disk 1', + platformId: 'tower', + platformType: 'unraid', + sourceType: 'agent', + status: 'online', + lastSeen: Date.now(), + physicalDisk: { + devPath: '/dev/sdc', + model: 'Data Disk', + storageRole: 'data', + storageGroup: 'unraid-array', + storageState: 'online', + sizeBytes: 6_000, + temperature: 32, + spunDown: true, + readCount: 10, + writeCount: 20, + errorCount: 16, + }, + }, + ] as Resource[]; + + const linkedDisks = getStoragePoolLinkedDisks(record, disks); + + expect(linkedDisks.map((disk) => disk.role)).toEqual(['parity', 'data']); + expect(linkedDisks[1]).toEqual( + expect.objectContaining({ + devPath: '/dev/sdc', + sizeLabel: '5.86 KB', + spunDown: true, + errorCount: 16, + ioLabel: 'R 10 / W 20', + }), + ); + expect(buildStoragePoolDetailTopologyRows(record, linkedDisks)).toEqual( + expect.arrayContaining([ + { label: 'Kind', value: 'Array' }, + { label: 'State', value: 'Started' }, + { label: 'Parity', value: '1 disk' }, + { label: 'Data disks', value: '1 disk' }, + { label: 'Spun down', value: '1 disk' }, + { label: 'Disk errors', value: '16' }, + ]), + ); + }); }); diff --git a/frontend-modern/src/features/storageBackups/__tests__/storagePoolRowPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storagePoolRowPresentation.test.ts index d7b634f9f..191a492b7 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/storagePoolRowPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/storagePoolRowPresentation.test.ts @@ -61,6 +61,8 @@ describe('storage pool row presentation', () => { expect(model.platformToneClass).toContain('bg-indigo-100'); expect(model.hostLabel).toBe('pbs01'); expect(model.topologyLabel).toBe('Datastore'); + expect(model.stateLabel).toBe('Degraded'); + expect(model.stateToneClass).toContain('text-amber'); expect(model.compactProtection).toBe('Protection Reduced'); expect(model.capacityDeltaLabel).toBe('+40.00 GB'); expect(model.capacityDeltaToneClass).toContain('text-amber-600'); diff --git a/frontend-modern/src/features/storageBackups/detailPresentation.ts b/frontend-modern/src/features/storageBackups/detailPresentation.ts index f6583032a..4f5d5a06d 100644 --- a/frontend-modern/src/features/storageBackups/detailPresentation.ts +++ b/frontend-modern/src/features/storageBackups/detailPresentation.ts @@ -11,7 +11,8 @@ export const STORAGE_DETAIL_INLINE_LABEL_CLASS = export const STORAGE_DETAIL_BADGE_CLASS = 'text-[10px] font-normal text-slate-400 bg-surface-alt px-1.5 py-0.5 rounded'; -export const STORAGE_DETAIL_META_ROW_CLASS = 'flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]'; +export const STORAGE_DETAIL_META_ROW_CLASS = + 'flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]'; export const STORAGE_DETAIL_MONO_CHIP_CLASS = 'text-muted font-mono bg-surface-alt px-1.5 py-0.5 rounded border border-border'; @@ -47,9 +48,11 @@ export const STORAGE_DETAIL_KEY_VALUE_ROW_CLASS = 'flex justify-between'; export const STORAGE_DETAIL_KEY_CLASS = 'text-muted'; export const STORAGE_DETAIL_VALUE_CLASS = 'text-base-content font-medium'; -export const STORAGE_DETAIL_LINKED_DISK_ROW_CLASS = 'flex items-center gap-2 text-[11px] py-0.5'; -export const STORAGE_DETAIL_LINKED_DISK_PATH_CLASS = 'font-mono text-muted w-16 truncate'; -export const STORAGE_DETAIL_LINKED_DISK_MODEL_CLASS = 'text-base-content truncate flex-1'; +export const STORAGE_DETAIL_LINKED_DISK_ROW_CLASS = + 'flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] py-0.5'; +export const STORAGE_DETAIL_LINKED_DISK_PATH_CLASS = 'font-mono text-muted min-w-[4rem] truncate'; +export const STORAGE_DETAIL_LINKED_DISK_MODEL_CLASS = + 'text-base-content min-w-[8rem] flex-1 truncate'; export const STORAGE_DETAIL_SPACED_STACK_CLASS = 'space-y-3'; export const STORAGE_DETAIL_FULL_WIDTH_ROW_CLASS = 'col-span-2'; export const STORAGE_DETAIL_MUTED_TEXT_CLASS = 'text-muted'; diff --git a/frontend-modern/src/features/storageBackups/diskPresentation.ts b/frontend-modern/src/features/storageBackups/diskPresentation.ts index 5670bb7f4..d5270a9f1 100644 --- a/frontend-modern/src/features/storageBackups/diskPresentation.ts +++ b/frontend-modern/src/features/storageBackups/diskPresentation.ts @@ -48,6 +48,11 @@ export interface PhysicalDiskPresentationData { wearout: number; storageRole?: string; storageGroup?: string; + storageState?: string; + spunDown?: boolean; + readCount?: number; + writeCount?: number; + errorCount?: number; type: string; temperature: number; rpm: number; @@ -148,6 +153,52 @@ const titleize = (value: string | undefined | null): string => .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(' '); +const UNRAID_PHYSICAL_DISK_ROLES = new Set(['data', 'parity', 'cache']); +const UNRAID_PHYSICAL_DISK_FAULT_STATES = new Set([ + 'disabled', + 'invalid', + 'missing', + 'wrong', + 'error', + 'failed', + 'faulted', +]); +const PHYSICAL_DISK_BAD_HEALTH_STATES = new Set([ + 'BAD', + 'CRITICAL', + 'ERROR', + 'FAILED', + 'FAIL', + 'FAULTED', + 'UNHEALTHY', +]); + +const normalizePhysicalDiskState = (value: string | undefined | null): string => + (value || '').trim().toLowerCase(); + +const normalizePhysicalDiskHealth = (value: string | undefined | null): string => + (value || '').trim().toUpperCase(); + +export function isUnraidPhysicalDisk(disk: PhysicalDiskPresentationData): boolean { + const storageGroup = normalizePhysicalDiskState(disk.storageGroup); + const storageRole = normalizePhysicalDiskState(disk.storageRole); + return ( + storageGroup === 'unraid-array' || + (Boolean(disk.storageState?.trim()) && UNRAID_PHYSICAL_DISK_ROLES.has(storageRole)) + ); +} + +export function hasUnraidPhysicalDiskFaultSignal( + disk: PhysicalDiskPresentationData, +): boolean { + if (!isUnraidPhysicalDisk(disk)) return false; + if ((disk.errorCount || 0) > 0) return true; + if (UNRAID_PHYSICAL_DISK_FAULT_STATES.has(normalizePhysicalDiskState(disk.storageState))) { + return true; + } + return PHYSICAL_DISK_BAD_HEALTH_STATES.has(normalizePhysicalDiskHealth(disk.health)); +} + const slugifyPhysicalDiskFacetValue = (value: string): string => value .trim() @@ -247,6 +298,11 @@ export function extractPhysicalDiskPresentationData( used: pd.used || '', storageRole: pd.storageRole, storageGroup: pd.storageGroup, + storageState: pd.storageState, + spunDown: pd.spunDown, + readCount: pd.readCount, + writeCount: pd.writeCount, + errorCount: pd.errorCount, riskLevel: pd.risk?.level, riskReasons, smartAttributes: pd.smart @@ -424,7 +480,7 @@ export function hasPhysicalDiskSmartWarning(disk: PhysicalDiskPresentationData): export function getPhysicalDiskHealthStatus( disk: PhysicalDiskPresentationData, ): DiskHealthStatusPresentation { - const normalizedHealth = (disk.health || '').trim().toUpperCase(); + const normalizedHealth = normalizePhysicalDiskHealth(disk.health); const criticalRisk = (disk.riskLevel || '').trim().toLowerCase() === 'critical'; const warningRisk = (disk.riskLevel || '').trim().toLowerCase() === 'warning'; const smartWarning = hasPhysicalDiskSmartWarning(disk); @@ -448,6 +504,14 @@ export function getPhysicalDiskHealthStatus( }; } + if (isUnraidPhysicalDisk(disk) && !hasUnraidPhysicalDiskFaultSignal(disk)) { + return { + label: normalizePhysicalDiskState(disk.storageState) === 'online' ? 'Online' : 'Unknown', + summary: 'No active disk-health issues.', + tone: 'text-base-content', + }; + } + return { label: normalizedHealth === 'PASSED' || normalizedHealth === 'GOOD' ? 'Healthy' : 'Unknown', summary: @@ -462,8 +526,9 @@ export function getPhysicalDiskNormalizedHealth( resource: Resource, disk: PhysicalDiskPresentationData, ): NormalizedHealth { - if (resource.status === 'offline') return 'offline'; const status = getPhysicalDiskHealthStatus(disk).label; + if (status === 'Online') return 'healthy'; + if (resource.status === 'offline') return 'offline'; if (status === 'Replace Now') return 'critical'; if (status === 'Needs Attention') return 'warning'; if (status === 'Healthy') return 'healthy'; diff --git a/frontend-modern/src/features/storageBackups/groupPresentation.ts b/frontend-modern/src/features/storageBackups/groupPresentation.ts index 7537cc927..638ef04dc 100644 --- a/frontend-modern/src/features/storageBackups/groupPresentation.ts +++ b/frontend-modern/src/features/storageBackups/groupPresentation.ts @@ -48,7 +48,7 @@ const STORAGE_GROUP_HEALTH_ORDER: NormalizedHealth[] = [ ]; export const getStorageGroupPoolCountLabel = (count: number): string => - `${count} ${count === 1 ? 'pool' : 'pools'}`; + `${count} ${count === 1 ? 'storage item' : 'storage items'}`; export const getStorageGroupUsagePercentLabel = (usagePercent: number): string => formatPercent(usagePercent); diff --git a/frontend-modern/src/features/storageBackups/resourceStorageMapping.ts b/frontend-modern/src/features/storageBackups/resourceStorageMapping.ts index 750d0105e..edc456947 100644 --- a/frontend-modern/src/features/storageBackups/resourceStorageMapping.ts +++ b/frontend-modern/src/features/storageBackups/resourceStorageMapping.ts @@ -8,6 +8,15 @@ export type ResourceStorageMeta = { content?: string; contentTypes?: string[]; shared?: boolean; + path?: string; + protection?: string; + arrayState?: string; + syncAction?: string; + syncProgress?: number; + numProtected?: number; + numDisabled?: number; + numInvalid?: number; + numMissing?: number; isCeph?: boolean; isZfs?: boolean; }; @@ -45,6 +54,15 @@ const normalizeStorageMeta = (value: unknown): ResourceStorageMeta | null => { content: typeof candidate.content === 'string' ? candidate.content : undefined, contentTypes, shared: typeof candidate.shared === 'boolean' ? candidate.shared : undefined, + path: typeof candidate.path === 'string' ? candidate.path : undefined, + protection: typeof candidate.protection === 'string' ? candidate.protection : undefined, + arrayState: typeof candidate.arrayState === 'string' ? candidate.arrayState : undefined, + syncAction: typeof candidate.syncAction === 'string' ? candidate.syncAction : undefined, + syncProgress: typeof candidate.syncProgress === 'number' ? candidate.syncProgress : undefined, + numProtected: typeof candidate.numProtected === 'number' ? candidate.numProtected : undefined, + numDisabled: typeof candidate.numDisabled === 'number' ? candidate.numDisabled : undefined, + numInvalid: typeof candidate.numInvalid === 'number' ? candidate.numInvalid : undefined, + numMissing: typeof candidate.numMissing === 'number' ? candidate.numMissing : undefined, isCeph: typeof candidate.isCeph === 'boolean' ? candidate.isCeph : undefined, isZfs: typeof candidate.isZfs === 'boolean' ? candidate.isZfs : undefined, }; diff --git a/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts b/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts index 933ffef6f..0c3b5d618 100644 --- a/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts +++ b/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts @@ -40,10 +40,7 @@ const collectRiskReasons = (resource: Resource): StorageRiskReasonLike[] => { return out; }; -const findRiskReason = ( - resource: Resource, - codes: string[], -): StorageRiskReasonLike | undefined => { +const findRiskReason = (resource: Resource, codes: string[]): StorageRiskReasonLike | undefined => { const wanted = new Set(codes.map((code) => code.toLowerCase())); for (const reason of collectRiskReasons(resource)) { const code = (reason.code || '').toLowerCase(); @@ -52,7 +49,7 @@ const findRiskReason = ( return undefined; }; -const isUnraidStorageResource = (resource: Resource): boolean => { +export const isUnraidStorageResource = (resource: Resource): boolean => { const storage = resource.storage; if (!storage) return false; if (storage.arrayState || storage.syncAction) return true; @@ -62,10 +59,7 @@ const isUnraidStorageResource = (resource: Resource): boolean => { return false; }; -const unraidShortSyncLabel = ( - syncAction: string | undefined, - syncProgress?: number, -): string => { +const unraidShortSyncLabel = (syncAction: string | undefined, syncProgress?: number): string => { const action = (syncAction || '').trim().toLowerCase(); let base: string; switch (action) { @@ -104,15 +98,27 @@ const getUnraidShortProtectionLabel = (resource: Resource): string => { } if (storage.protectionReduced) { if ((storage.protection || '').trim().toLowerCase() === 'none') { - return 'Unprotected'; + return 'No parity'; } - if (findRiskReason(resource, ['unraid_no_parity'])) return 'Unprotected'; + if (findRiskReason(resource, ['unraid_no_parity'])) return 'No parity'; if (findRiskReason(resource, ['unraid_parity_unavailable'])) return 'Parity unavailable'; return 'Protection reduced'; } return ''; }; +export const hasUnraidStorageAttentionIssue = (resource: Resource): boolean => { + if (!isUnraidStorageResource(resource)) return false; + return Boolean( + findRiskReason(resource, [ + 'unraid_parity_unavailable', + 'unraid_invalid_disks', + 'unraid_disabled_disks', + 'unraid_missing_disks', + ]), + ); +}; + const getUnraidShortIssueLabel = (resource: Resource): string => { const reasons = collectRiskReasons(resource); if (reasons.length === 0) return ''; @@ -121,17 +127,10 @@ const getUnraidShortIssueLabel = (resource: Resource): string => { switch (code) { case 'unraid_parity_unavailable': return 'Parity unavailable'; - case 'unraid_no_parity': - return 'No parity protection'; case 'unraid_invalid_disks': case 'unraid_disabled_disks': case 'unraid_missing_disks': return trimSummary(reason.summary).replace(/^Unraid array reports\s+/i, '') || 'Disk issue'; - case 'unraid_sync_active': - return unraidShortSyncLabel( - resource.storage?.syncAction, - resource.storage?.syncProgress, - ); } } return ''; @@ -251,11 +250,11 @@ export const getResourceStorageTopologyLabel = ( }; export const getResourceStorageIssueLabel = (resource: Resource): string => { - if (resource.incidentLabel?.trim()) return resource.incidentLabel.trim(); if (isUnraidStorageResource(resource)) { const unraidLabel = getUnraidShortIssueLabel(resource); - if (unraidLabel) return unraidLabel; + return unraidLabel || 'Healthy'; } + if (resource.incidentLabel?.trim()) return resource.incidentLabel.trim(); const riskIssue = getResourceStorageRiskIssue(resource); if (riskIssue) return riskIssue; const postureIssue = getCompositePostureIssue(resource); @@ -264,6 +263,9 @@ export const getResourceStorageIssueLabel = (resource: Resource): string => { }; export const getResourceStorageIssueSummary = (resource: Resource): string => { + if (isUnraidStorageResource(resource) && !hasUnraidStorageAttentionIssue(resource)) { + return ''; + } if (resource.incidentSummary?.trim()) return resource.incidentSummary.trim(); const riskIssue = getResourceStorageRiskIssue(resource); if (riskIssue) return riskIssue; @@ -284,6 +286,9 @@ export const getResourceStorageImpactSummary = (resource: Resource): string => { }; export const getResourceStorageActionSummary = (resource: Resource): string => { + if (isUnraidStorageResource(resource) && !hasUnraidStorageAttentionIssue(resource)) { + return 'Monitor'; + } if (resource.incidentAction?.trim()) return resource.incidentAction.trim(); if (resource.storage?.rebuildInProgress) { return resource.storage.rebuildSummary || 'Monitor rebuild progress'; diff --git a/frontend-modern/src/features/storageBackups/rowPresentation.ts b/frontend-modern/src/features/storageBackups/rowPresentation.ts index 122dff00e..0a577e44b 100644 --- a/frontend-modern/src/features/storageBackups/rowPresentation.ts +++ b/frontend-modern/src/features/storageBackups/rowPresentation.ts @@ -1,10 +1,33 @@ import type { StorageRecord } from './models'; -import { getStorageRecordIssueSummary, getStorageRecordZfsPool } from './recordPresentation'; +import { + getStorageRecordIssueSummary, + getStorageRecordStatus, + getStorageRecordZfsPool, +} from './recordPresentation'; + +const titleize = (value: string): string => + value + .split(/[\s_-]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join(' '); + +const getRecordDetails = (record: StorageRecord): Record => + (record.details || {}) as Record; + +const getRecordStringDetail = (record: StorageRecord, key: string): string => { + const value = getRecordDetails(record)[key]; + return typeof value === 'string' ? value.trim() : ''; +}; export function getStoragePoolProtectionTextClass(record: StorageRecord): string { + const label = getCompactStoragePoolProtectionLabel(record).trim().toLowerCase(); if (record.rebuildInProgress) { return 'text-blue-700 dark:text-blue-300'; } + if (label === 'no parity') { + return 'text-base-content'; + } if (record.protectionReduced || record.incidentCategory === 'recoverability') { return 'text-red-700 dark:text-red-300'; } @@ -22,6 +45,23 @@ export function getStoragePoolIssueTextClass(record: StorageRecord): string { return 'text-base-content'; } +export function getStoragePoolStateTextClass(record: StorageRecord): string { + const normalized = getStoragePoolStateLabel(record).trim().toLowerCase(); + if ( + normalized === 'critical' || + normalized === 'faulted' || + normalized === 'failed' || + normalized === 'offline' || + normalized === 'unavailable' + ) { + return 'text-red-700 dark:text-red-300'; + } + if (normalized === 'warning' || normalized === 'warn' || normalized === 'degraded') { + return 'text-amber-700 dark:text-amber-300'; + } + return 'text-base-content'; +} + export function getCompactStoragePoolProtectionLabel(record: StorageRecord): string { const label = (record.protectionLabel || '').trim(); if (record.rebuildInProgress || record.protectionReduced) { @@ -33,6 +73,29 @@ export function getCompactStoragePoolProtectionLabel(record: StorageRecord): str return '—'; } +export function getStoragePoolStateLabel(record: StorageRecord): string { + const arrayState = getRecordStringDetail(record, 'arrayState'); + if (arrayState) { + return titleize(arrayState); + } + const pool = getStorageRecordZfsPool(record); + if (pool?.state) { + return pool.state === 'ONLINE' ? 'Online' : pool.state; + } + const status = getStorageRecordStatus(record); + return status ? titleize(status) : '—'; +} + +export function getStoragePoolStateTitle(record: StorageRecord): string { + const label = getStoragePoolStateLabel(record); + const summary = + getCompactStoragePoolIssueSummary(record).trim() || getStorageRecordIssueSummary(record).trim(); + if (summary && summary.toLowerCase() !== 'healthy' && label.toLowerCase() !== 'started') { + return summary; + } + return label === '—' ? '' : label; +} + export function getCompactStoragePoolProtectionTitle(record: StorageRecord): string { const label = getCompactStoragePoolProtectionLabel(record); if (label === '—') return ''; @@ -74,7 +137,10 @@ export function getCompactStoragePoolIssueLabel(record: StorageRecord): string { return pool.state; } const normalizedStatus = (record.statusLabel || '').trim().toLowerCase(); - if (normalizedStatus && !['online', 'available', 'running', 'healthy'].includes(normalizedStatus)) { + if ( + normalizedStatus && + !['online', 'available', 'running', 'healthy'].includes(normalizedStatus) + ) { return record.statusLabel || 'Issue'; } return '—'; diff --git a/frontend-modern/src/features/storageBackups/storageAdapters.ts b/frontend-modern/src/features/storageBackups/storageAdapters.ts index bb9320d42..6e82cb079 100644 --- a/frontend-modern/src/features/storageBackups/storageAdapters.ts +++ b/frontend-modern/src/features/storageBackups/storageAdapters.ts @@ -1,9 +1,5 @@ import type { Resource } from '@/types/resource'; -import type { - StorageRecord, - StorageAdapter, - StorageAdapterContext, -} from './models'; +import type { StorageRecord, StorageAdapter, StorageAdapterContext } from './models'; import { getCanonicalStoragePlatformKey, getResourceStorageActionSummary, @@ -14,6 +10,8 @@ import { getResourceStorageProtectionLabel, getResourceStorageProtectionSummary, getResourceStorageTopologyLabel, + hasUnraidStorageAttentionIssue, + isUnraidStorageResource, } from './resourceStoragePresentation'; import { getStorageCapabilitiesForResource, @@ -49,9 +47,7 @@ const mapResourceStorageRecord = (resource: Resource, adapterId: string): Storag storageMeta?.topology || (platformData.topology as string | undefined) || resource.storage?.topology; - const storageType = - storageTypeHint || - (resourceType === 'pbs' ? 'pbs' : resourceType); + const storageType = storageTypeHint || (resourceType === 'pbs' ? 'pbs' : resourceType); const shared = typeof storageMeta?.shared === 'boolean' ? storageMeta.shared @@ -122,6 +118,18 @@ const mapResourceStorageRecord = (resource: Resource, adapterId: string): Storag const actionSummary = getResourceStorageActionSummary(resource); const protectionLabel = getResourceStorageProtectionLabel(resource); const protectionSummary = getResourceStorageProtectionSummary(resource); + const isUnraid = isUnraidStorageResource(resource); + const normalizedHealth = normalizeStorageResourceHealth( + resource.status, + resource.tags, + resource.incidentSeverity, + ); + const health = + normalizedHealth === 'warning' && isUnraid && !hasUnraidStorageAttentionIssue(resource) + ? 'healthy' + : normalizedHealth; + const statusLabel = + isUnraid && storageMeta?.arrayState ? storageMeta.arrayState : resource.status; const topologyLabel = getResourceStorageTopologyLabel( resource, storageType, @@ -132,11 +140,12 @@ const mapResourceStorageRecord = (resource: Resource, adapterId: string): Storag return { id: resource.id, name: resource.name, - category: storageMeta?.isCeph || storageMeta?.isZfs + category: + storageMeta?.isCeph || storageMeta?.isZfs ? 'pool' : getStorageCategoryFromType(storageType, classificationContext), - health: normalizeStorageResourceHealth(resource.status, resource.tags, resource.incidentSeverity), - statusLabel: resource.status, + health, + statusLabel, hostLabel, platformLabel, platformKey: canonicalPlatform, @@ -160,7 +169,11 @@ const mapResourceStorageRecord = (resource: Resource, adapterId: string): Storag scope: isBackupRepository ? 'cluster' : isDatastore ? (shared ? 'cluster' : 'host') : 'node', }, capacity: buildStorageCapacity(totalBytes, usedBytes, freeBytes, usagePercent), - capabilities: getStorageCapabilitiesForResource(storageType, storageMeta, classificationContext), + capabilities: getStorageCapabilitiesForResource( + storageType, + storageMeta, + classificationContext, + ), source: buildStorageSource(canonicalPlatform, adapterId), observedAt: typeof resource.lastSeen === 'number' && Number.isFinite(resource.lastSeen) @@ -173,7 +186,7 @@ const mapResourceStorageRecord = (resource: Resource, adapterId: string): Storag }, details: { type: storageType, - status: resource.status, + status: statusLabel, parentId: resource.parentId, parentName: resource.parentName, node: proxmoxNode || (platformData.node as string | undefined) || storageNodes[0], @@ -196,9 +209,18 @@ const mapResourceStorageRecord = (resource: Resource, adapterId: string): Storag content, contentTypes: storageMeta?.contentTypes, shared, + path: storageMeta?.path, + protection: storageMeta?.protection, isCeph: storageMeta?.isCeph, isZfs: storageMeta?.isZfs, zfsPool: platformData.zfsPool, + arrayState: storageMeta?.arrayState, + syncAction: storageMeta?.syncAction, + syncProgress: storageMeta?.syncProgress, + numProtected: storageMeta?.numProtected, + numDisabled: storageMeta?.numDisabled, + numInvalid: storageMeta?.numInvalid, + numMissing: storageMeta?.numMissing, }, }; }; diff --git a/frontend-modern/src/features/storageBackups/storagePagePresentation.ts b/frontend-modern/src/features/storageBackups/storagePagePresentation.ts index 297567425..41393ed27 100644 --- a/frontend-modern/src/features/storageBackups/storagePagePresentation.ts +++ b/frontend-modern/src/features/storageBackups/storagePagePresentation.ts @@ -24,7 +24,7 @@ const STORAGE_POOL_TABLE_HEADER_CLASS = 'overflow-hidden text-ellipsis whitespace-nowrap px-1 sm:px-1.5 lg:px-2 py-0.5 text-left text-[10px] sm:text-[11px] lg:text-xs font-medium uppercase tracking-wider'; export const STORAGE_VIEW_OPTIONS: readonly StorageViewOption[] = [ - { value: 'pools', label: 'Pools' }, + { value: 'pools', label: 'Storage' }, { value: 'disks', label: 'Physical Disks' }, ]; @@ -38,8 +38,8 @@ export const getStoragePoolTableColumns = ( colClassName: 'w-[34%] sm:w-[26%] md:w-[23%] xl:w-[18%]', }, { - label: 'Primary Issue', - compactLabel: 'Issue', + label: 'State', + compactLabel: 'State', className: `${STORAGE_POOL_TABLE_HEADER_CLASS} w-[24%] sm:w-[20%] md:w-[17%] xl:w-[14%]`, colClassName: 'w-[24%] sm:w-[20%] md:w-[17%] xl:w-[14%]', }, @@ -143,7 +143,7 @@ export const getStoragePageBannerActionLabel = (kind: StoragePageBannerKind): st }; export const getStorageTableHeading = (view: 'pools' | 'disks'): string => - view === 'pools' ? 'Storage Pools' : 'Physical Disks'; + view === 'pools' ? 'Storage' : 'Physical Disks'; export const getStorageLoadingMessage = (): string => 'Loading storage resources...'; diff --git a/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts b/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts index 22eac4fd0..9f158e93d 100644 --- a/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts +++ b/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts @@ -22,8 +22,19 @@ export type StoragePoolDetailLinkedDisk = { id: string; devPath: string; model: string; + role: string; + state: string; + sizeLabel: string; temperature: number; hasIssue: boolean; + spunDown: boolean; + errorCount: number; + ioLabel: string; +}; + +export type StoragePoolDetailTopologyRow = { + label: string; + value: string; }; export type StoragePoolDetailChartTarget = { @@ -85,7 +96,15 @@ export function buildStoragePoolDetailConfigRows( { label: 'Node', value: getStorageRecordNodeLabel(record) }, { label: 'Type', value: getStorageRecordType(record) }, { label: 'Status', value: getStorageRecordStatus(record) }, - { label: 'Shared', value: getStorageRecordShared(record) === null ? '-' : getStorageRecordShared(record) ? 'Yes' : 'No' }, + { + label: 'Shared', + value: + getStorageRecordShared(record) === null + ? '-' + : getStorageRecordShared(record) + ? 'Yes' + : 'No', + }, { label: 'Used', value: totalBytes > 0 ? formatBytes(usedBytes) : 'n/a' }, { label: 'Free', value: totalBytes > 0 ? formatBytes(freeBytes) : 'n/a' }, { label: 'Total', value: totalBytes > 0 ? formatBytes(totalBytes) : 'n/a' }, @@ -99,7 +118,90 @@ export function buildStoragePoolDetailConfigRows( return rows; } -export function buildStoragePoolDetailZfsSummary(record: StorageRecord): StoragePoolDetailZfsSummary | null { +const getRecordDetails = (record: StorageRecord): Record => + (record.details || {}) as Record; + +const readRecordDetailString = (record: StorageRecord, key: string): string => { + const value = getRecordDetails(record)[key]; + return typeof value === 'string' ? value.trim() : ''; +}; + +const readRecordDetailNumber = (record: StorageRecord, key: string): number => { + const value = getRecordDetails(record)[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +}; + +const isUnraidStorageRecord = (record: StorageRecord): boolean => { + const platform = readRecordDetailString(record, 'platform').toLowerCase(); + const type = getStorageRecordType(record).toLowerCase(); + return record.source.platform === 'unraid' || platform === 'unraid' || type.startsWith('unraid-'); +}; + +const getUnraidStorageGroup = (record: StorageRecord): string => { + if (!isUnraidStorageRecord(record)) return ''; + const type = getStorageRecordType(record).toLowerCase(); + const topology = readRecordDetailString(record, 'topology').toLowerCase(); + if (type === 'unraid-array' || topology === 'array') return 'unraid-array'; + if (type === 'unraid-cache-pool') return record.name.trim(); + return ''; +}; + +const pluralize = (count: number, singular: string, plural = `${singular}s`): string => + `${count} ${count === 1 ? singular : plural}`; + +const titleizeStorageState = (value: string): string => + value + .split(/[\s_-]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join(' '); + +export function buildStoragePoolDetailTopologyRows( + record: StorageRecord, + linkedDisks: StoragePoolDetailLinkedDisk[], +): StoragePoolDetailTopologyRow[] { + if (!isUnraidStorageRecord(record)) return []; + + const type = getStorageRecordType(record).toLowerCase(); + const arrayState = readRecordDetailString(record, 'arrayState'); + const syncAction = readRecordDetailString(record, 'syncAction'); + const syncProgress = readRecordDetailNumber(record, 'syncProgress'); + const parityDisks = linkedDisks.filter((disk) => disk.role.toLowerCase() === 'parity').length; + const dataDisks = linkedDisks.filter((disk) => disk.role.toLowerCase() === 'data').length; + const cacheDisks = linkedDisks.filter((disk) => disk.role.toLowerCase() === 'cache').length; + const errorCount = linkedDisks.reduce((sum, disk) => sum + disk.errorCount, 0); + const spunDownCount = linkedDisks.filter((disk) => disk.spunDown).length; + + const rows: StoragePoolDetailTopologyRow[] = []; + rows.push({ label: 'Kind', value: type === 'unraid-cache-pool' ? 'Cache pool' : 'Array' }); + if (arrayState) rows.push({ label: 'State', value: titleizeStorageState(arrayState) }); + if (type === 'unraid-array') { + rows.push({ + label: 'Parity', + value: parityDisks > 0 ? pluralize(parityDisks, 'disk') : 'None configured', + }); + rows.push({ label: 'Data disks', value: pluralize(dataDisks, 'disk') }); + } else { + rows.push({ label: 'Devices', value: pluralize(cacheDisks || linkedDisks.length, 'disk') }); + } + if (spunDownCount > 0) { + rows.push({ label: 'Spun down', value: pluralize(spunDownCount, 'disk') }); + } + if (errorCount > 0) { + rows.push({ label: 'Disk errors', value: errorCount.toLocaleString() }); + } + if (syncAction) { + rows.push({ + label: 'Sync', + value: syncProgress > 0 ? `${syncAction} (${Math.round(syncProgress)}%)` : syncAction, + }); + } + return rows; +} + +export function buildStoragePoolDetailZfsSummary( + record: StorageRecord, +): StoragePoolDetailZfsSummary | null { const pool = getStorageRecordZfsPool(record); if (!pool) return null; @@ -114,36 +216,87 @@ export function buildStoragePoolDetailZfsSummary(record: StorageRecord): Storage }; } -const readDiskDevPath = (disk: Resource): string => { - const physicalDisk = (disk.platformData as Record)?.physicalDisk as - | Record +const readPhysicalDisk = (disk: Resource): NonNullable => { + const platformData = (disk.platformData || {}) as Record; + const platformDisk = platformData.physicalDisk as + | NonNullable | undefined; - return typeof physicalDisk?.devPath === 'string' ? physicalDisk.devPath : ''; + return (disk.physicalDisk || platformDisk || {}) as NonNullable; +}; + +const readDiskDevPath = (disk: Resource): string => { + const physicalDisk = readPhysicalDisk(disk); + return typeof physicalDisk.devPath === 'string' ? physicalDisk.devPath : ''; }; const readDiskModel = (disk: Resource): string => { - const physicalDisk = (disk.platformData as Record)?.physicalDisk as - | Record - | undefined; - return typeof physicalDisk?.model === 'string' && physicalDisk.model.trim() + const physicalDisk = readPhysicalDisk(disk); + return typeof physicalDisk.model === 'string' && physicalDisk.model.trim() ? physicalDisk.model : 'Unknown'; }; const readDiskTemperature = (disk: Resource): number => { - const physicalDisk = (disk.platformData as Record)?.physicalDisk as - | Record - | undefined; - return typeof physicalDisk?.temperature === 'number' ? physicalDisk.temperature : 0; + const physicalDisk = readPhysicalDisk(disk); + return typeof physicalDisk.temperature === 'number' ? physicalDisk.temperature : 0; }; const readDiskHasIssue = (disk: Resource): boolean => { - const physicalDisk = (disk.platformData as Record)?.physicalDisk as - | Record - | undefined; - const smart = physicalDisk?.smart as Record | undefined; + const physicalDisk = readPhysicalDisk(disk); + const smart = physicalDisk.smart as Record | undefined; const reallocated = smart?.reallocatedSectors; - return typeof reallocated === 'number' && reallocated > 0; + const errorCount = typeof physicalDisk.errorCount === 'number' ? physicalDisk.errorCount : 0; + return errorCount > 0 || (typeof reallocated === 'number' && reallocated > 0); +}; + +const readDiskStorageGroup = (disk: Resource): string => { + const physicalDisk = readPhysicalDisk(disk); + return typeof physicalDisk.storageGroup === 'string' ? physicalDisk.storageGroup.trim() : ''; +}; + +const readDiskRole = (disk: Resource): string => { + const physicalDisk = readPhysicalDisk(disk); + return typeof physicalDisk.storageRole === 'string' ? physicalDisk.storageRole.trim() : ''; +}; + +const readDiskState = (disk: Resource): string => { + const physicalDisk = readPhysicalDisk(disk); + return typeof physicalDisk.storageState === 'string' ? physicalDisk.storageState.trim() : ''; +}; + +const readDiskSizeLabel = (disk: Resource): string => { + const physicalDisk = readPhysicalDisk(disk); + return typeof physicalDisk.sizeBytes === 'number' && physicalDisk.sizeBytes > 0 + ? formatBytes(physicalDisk.sizeBytes) + : ''; +}; + +const readDiskSpunDown = (disk: Resource): boolean => readPhysicalDisk(disk).spunDown === true; + +const readDiskErrorCount = (disk: Resource): number => { + const count = readPhysicalDisk(disk).errorCount; + return typeof count === 'number' && Number.isFinite(count) ? count : 0; +}; + +const readDiskIOLabel = (disk: Resource): string => { + const physicalDisk = readPhysicalDisk(disk); + const readCount = typeof physicalDisk.readCount === 'number' ? physicalDisk.readCount : 0; + const writeCount = typeof physicalDisk.writeCount === 'number' ? physicalDisk.writeCount : 0; + if (readCount <= 0 && writeCount <= 0) return ''; + return `R ${readCount.toLocaleString()} / W ${writeCount.toLocaleString()}`; +}; + +const diskRoleRank = (role: string): number => { + switch (role.toLowerCase()) { + case 'parity': + return 0; + case 'data': + return 1; + case 'cache': + return 2; + default: + return 3; + } }; export function getStoragePoolLinkedDisks( @@ -151,18 +304,37 @@ export function getStoragePoolLinkedDisks( physicalDisks: Resource[], ): StoragePoolDetailLinkedDisk[] { const pool = getStorageRecordZfsPool(record); - if (!pool?.devices?.length) return []; + const unraidStorageGroup = getUnraidStorageGroup(record).toLowerCase(); + const directParentIds = new Set( + [record.id, record.refs?.resourceId].filter((value): value is string => Boolean(value)), + ); return physicalDisks .filter((disk) => { const devPath = readDiskDevPath(disk); - return devPath && pool.devices.some((device) => devPath.endsWith(device.name)); + const group = readDiskStorageGroup(disk).toLowerCase(); + if (directParentIds.has(disk.parentId || '')) return true; + if (pool?.devices?.length && devPath) { + return pool.devices.some((device) => devPath.endsWith(device.name)); + } + return Boolean(unraidStorageGroup && group === unraidStorageGroup); }) .map((disk) => ({ id: disk.id, devPath: readDiskDevPath(disk) || disk.name, model: readDiskModel(disk), + role: readDiskRole(disk), + state: readDiskState(disk), + sizeLabel: readDiskSizeLabel(disk), temperature: readDiskTemperature(disk), hasIssue: readDiskHasIssue(disk), - })); + spunDown: readDiskSpunDown(disk), + errorCount: readDiskErrorCount(disk), + ioLabel: readDiskIOLabel(disk), + })) + .sort((left, right) => { + const roleDelta = diskRoleRank(left.role) - diskRoleRank(right.role); + if (roleDelta !== 0) return roleDelta; + return left.devPath.localeCompare(right.devPath); + }); } diff --git a/frontend-modern/src/features/storageBackups/storagePoolRowPresentation.ts b/frontend-modern/src/features/storageBackups/storagePoolRowPresentation.ts index 1205492e9..6930145a3 100644 --- a/frontend-modern/src/features/storageBackups/storagePoolRowPresentation.ts +++ b/frontend-modern/src/features/storageBackups/storagePoolRowPresentation.ts @@ -13,6 +13,9 @@ import { getCompactStoragePoolIssueSummary, getCompactStoragePoolProtectionLabel, getCompactStoragePoolProtectionTitle, + getStoragePoolStateLabel, + getStoragePoolStateTextClass, + getStoragePoolStateTitle, } from './rowPresentation'; export interface StoragePoolRowModel { @@ -26,6 +29,9 @@ export interface StoragePoolRowModel { topologyLabel: string; compactProtection: string; compactProtectionTitle: string; + stateLabel: string; + stateTitle: string; + stateToneClass: string; capacityDeltaLabel: string; capacityDeltaTitle: string; capacityDeltaToneClass: string; @@ -86,6 +92,9 @@ export const buildStoragePoolRowModel = ( topologyLabel: getStorageRecordTopologyLabel(record), compactProtection: getCompactStoragePoolProtectionLabel(record), compactProtectionTitle: getCompactStoragePoolProtectionTitle(record), + stateLabel: getStoragePoolStateLabel(record), + stateTitle: getStoragePoolStateTitle(record), + stateToneClass: getStoragePoolStateTextClass(record), capacityDeltaLabel: capacityDelta?.label ?? '—', capacityDeltaTitle: capacityDelta?.title ?? 'No used-capacity change history available.', capacityDeltaToneClass: capacityDelta?.toneClass ?? STORAGE_POOL_ROW_PLACEHOLDER_CLASS, diff --git a/frontend-modern/src/types/__tests__/resource.test.ts b/frontend-modern/src/types/__tests__/resource.test.ts index 03018cac2..32b4156b9 100644 --- a/frontend-modern/src/types/__tests__/resource.test.ts +++ b/frontend-modern/src/types/__tests__/resource.test.ts @@ -11,6 +11,8 @@ import { getMemoryPercent, getDiskPercent, type ResourceChangeKind, + type ResourceAgentUnraidDisk, + type ResourcePhysicalDiskMeta, type ResourceProxmoxMeta, type ResourceVMwareMeta, type Resource, @@ -125,6 +127,55 @@ describe('Resource Type Guards', () => { }); describe('Resource Helper Functions', () => { + describe('ResourceAgentUnraidDisk', () => { + it('accepts native Unraid disk metadata in the resource contract', () => { + const disk: ResourceAgentUnraidDisk = { + name: 'disk1', + device: '/dev/sdc', + role: 'data', + status: 'online', + rawStatus: 'DISK_OK', + model: 'WDC WD60EFRX', + serial: 'SERIAL-DATA', + filesystem: 'xfs', + transport: 'sata', + sizeBytes: 6_000_000_000_000, + usedBytes: 4_000, + freeBytes: 2_000, + temperature: 31, + spunDown: true, + readCount: 11, + writeCount: 12, + errorCount: 16, + slot: 1, + }; + + expect(disk.model).toBe('WDC WD60EFRX'); + expect(disk.usedBytes).toBe(4_000); + expect(disk.errorCount).toBe(16); + }); + }); + + describe('ResourcePhysicalDiskMeta', () => { + it('accepts native UnRAID media state on physical disks', () => { + const disk: ResourcePhysicalDiskMeta = { + devPath: '/dev/sdc', + model: 'WDC WD60EFRX', + storageRole: 'data', + storageGroup: 'unraid-array', + storageState: 'online', + spunDown: true, + readCount: 11, + writeCount: 12, + errorCount: 16, + }; + + expect(disk.storageGroup).toBe('unraid-array'); + expect(disk.spunDown).toBe(true); + expect(disk.errorCount).toBe(16); + }); + }); + describe('ResourceProxmoxMeta', () => { it('accepts Proxmox pool membership in the canonical resource contract', () => { const proxmox: ResourceProxmoxMeta = { diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index c48cda360..59ff5915c 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -377,6 +377,10 @@ export interface ResourcePhysicalDiskMeta { storageRole?: string; storageGroup?: string; storageState?: string; + spunDown?: boolean; + readCount?: number; + writeCount?: number; + errorCount?: number; smart?: { powerOnHours?: number; powerCycles?: number; @@ -408,9 +412,18 @@ export interface ResourceAgentUnraidDisk { role?: string; status?: string; rawStatus?: string; + model?: string; serial?: string; filesystem?: string; + transport?: string; sizeBytes?: number; + usedBytes?: number; + freeBytes?: number; + temperature?: number; + spunDown?: boolean; + readCount?: number; + writeCount?: number; + errorCount?: number; slot?: number; } diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 894864fa4..9f5a2da43 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -144,7 +144,6 @@ import infrastructurePageShellSource from '@/pages/Infrastructure.tsx?raw'; import operationsPageRouteSource from '@/pages/Operations.tsx?raw'; import discoveryTargetSource from '@/utils/discoveryTarget.ts?raw'; import infrastructureEmptyStatePresentationSource from '@/utils/infrastructureEmptyStatePresentation.ts?raw'; -import recoverySummarySource from '@/components/Recovery/RecoverySummary.tsx?raw'; import recoveryComponentSource from '@/components/Recovery/Recovery.tsx?raw'; import recoveryActivitySectionSource from '@/components/Recovery/RecoveryActivitySection.tsx?raw'; import recoveryHistorySectionSource from '@/components/Recovery/RecoveryHistorySection.tsx?raw'; @@ -161,7 +160,6 @@ import recoveryDatePresentationSource from '@/utils/recoveryDatePresentation.ts? import recoveryRecordPresentationSource from '@/utils/recoveryRecordPresentation.ts?raw'; import recoveryEmptyStatePresentationSource from '@/utils/recoveryEmptyStatePresentation.ts?raw'; import recoveryStatusPresentationSource from '@/utils/recoveryStatusPresentation.ts?raw'; -import recoverySummaryPresentationSource from '@/utils/recoverySummaryPresentation.ts?raw'; import recoveryTablePresentationSource from '@/utils/recoveryTablePresentation.ts?raw'; import recoveryTimelineChartPresentationSource from '@/utils/recoveryTimelineChartPresentation.ts?raw'; import recoveryTimelinePresentationSource from '@/utils/recoveryTimelinePresentation.ts?raw'; @@ -1053,16 +1051,17 @@ describe('frontend resource type boundaries', () => { expect(emptyStatePresentationSource).toContain('export function getEmptyStatePresentation'); expect(discoveryTargetSource).toContain('canonicalizeFrontendResourceType'); expect(recoveryOutcomePresentationSource).toContain('import type { RecoveryOutcome }'); - expect(recoverySummarySource).toContain('buildRecoveryPostureSummary'); - expect(recoverySummarySource).toContain('RECOVERY_SUMMARY_TIME_RANGES'); - expect(recoverySummarySource).toContain('buildRecoveryFreshnessBuckets'); - expect(recoverySummarySource).not.toContain( + expect(recoveryComponentSource).not.toContain('RecoverySummary'); + expect(recoveryComponentSource).not.toContain('recovery-summary'); + expect(recoveryActivitySectionSource).toContain('RECOVERY_ACTIVITY_RANGE_DAYS'); + expect(recoveryActivitySectionSource).toContain('Recovery activity range'); + expect(recoverySource).not.toContain( "const RECOVERY_TIME_RANGES: readonly string[] = ['7d', '30d', '90d']", ); - expect(recoverySummarySource).not.toContain( + expect(recoverySource).not.toContain( 'const RECOVERY_TIME_RANGE_LABELS: Record', ); - expect(recoverySummarySource).not.toContain('const FRESHNESS_LABELS:'); + expect(recoverySource).not.toContain('const FRESHNESS_LABELS:'); expect(recoverySource).toContain('getRecoveryArtifactModePresentation'); expect(recoverySource).not.toContain('const MODE_LABELS: Record'); expect(recoverySource).not.toContain('const MODE_BADGE_CLASS: Record'); @@ -1070,7 +1069,7 @@ describe('frontend resource type boundaries', () => { expect(recoverySource).not.toContain( "const ISSUE_RAIL_CLASS: Record, string>", ); - expect(recoverySummarySource).not.toContain('const normalizeOutcome ='); + expect(recoverySource).not.toContain('const normalizeOutcome ='); expect(recoveryArtifactModePresentationSource).toContain( 'export function getRecoveryArtifactModePresentation', ); @@ -1119,11 +1118,12 @@ describe('frontend resource type boundaries', () => { expect(recoveryComponentSource.indexOf('{eventsActivity()}')).toBeGreaterThan( recoveryComponentSource.indexOf(""), ); - expect(recoverySource).toContain('getRecoveryFilterChipPresentation'); + expect(recoverySource).not.toContain('getRecoveryFilterChipPresentation'); expect(recoverySource).not.toContain('const titleize ='); - expect(recoverySummarySource).toContain(' { expect(recoveryActivitySectionSource).toContain('getRecoveryTimelineAxisLabelClass'); expect(recoverySource).toContain('getRecoveryTimelineLabelEvery'); expect(recoverySource).toContain('getRecoveryGroupNoTimestampLabel'); - expect(recoveryProtectedInventorySectionSource).toContain('getRecoveryProtectedSearchPlaceholder'); + expect(recoveryProtectedInventorySectionSource).toContain( + 'getRecoveryProtectedSearchPlaceholder', + ); expect(recoveryHistorySectionSource).toContain('getRecoveryHistorySearchPlaceholder'); expect(recoverySource).toContain('getRecoverySearchHistoryEmptyMessage'); expect(recoverySource).not.toContain('Search protected items...'); @@ -1255,6 +1257,7 @@ describe('frontend resource type boundaries', () => { expect(recoveryDatePresentationSource).toContain('export function parseRecoveryDateKey'); expect(recoveryDatePresentationSource).toContain('export function getRecoveryPrettyDateLabel'); expect(recoveryDatePresentationSource).toContain('export function getRecoveryFullDateLabel'); + expect(recoveryDatePresentationSource).toContain('export function getRecoveryFilterDateLabel'); expect(recoveryDatePresentationSource).toContain('export function getRecoveryCompactAxisLabel'); expect(recoveryDatePresentationSource).toContain('export function formatRecoveryTimeOnly'); expect(recoveryDatePresentationSource).toContain('export function getRecoveryNiceAxisMax'); @@ -1364,18 +1367,10 @@ describe('frontend resource type boundaries', () => { expect(recoveryTimelinePresentationSource).toContain( 'export function getRecoveryTimelineColumnButtonClass', ); - expect(recoverySummaryPresentationSource).toContain( - 'export const RECOVERY_SUMMARY_TIME_RANGES', - ); - expect(recoverySummaryPresentationSource).toContain('export const RECOVERY_FRESHNESS_BUCKETS'); - expect(recoverySummaryPresentationSource).toContain( - 'export function getRecoveryAttentionChipClass', - ); - expect(recoverySummaryPresentationSource).toContain( - 'export function getRecoveryAttentionDotClass', - ); - expect(recoverySummarySource).not.toContain('function getAttentionChipClass('); - expect(recoverySummarySource).not.toContain('function getAttentionDotClass('); + expect(recoverySource).not.toContain('RECOVERY_SUMMARY_TIME_RANGES'); + expect(recoverySource).not.toContain('RECOVERY_FRESHNESS_BUCKETS'); + expect(recoverySource).not.toContain('function getAttentionChipClass('); + expect(recoverySource).not.toContain('function getAttentionDotClass('); expect(systemSettingsPresentationSource).toContain('export const PVE_POLLING_PRESETS'); expect(systemSettingsPresentationSource).toContain('export const BACKUP_INTERVAL_OPTIONS'); expect(systemSettingsPresentationSource).toContain( @@ -1736,7 +1731,7 @@ describe('frontend resource type boundaries', () => { expect(useDiskLiveMetricModelSource).toContain('getDiskLiveMetricFormattedValue'); expect(useDiskLiveMetricModelSource).toContain('getDiskLiveMetricTextClass'); expect(storagePoolRowSource).toContain('getStoragePoolProtectionTextClass'); - expect(storagePoolRowSource).toContain('getStoragePoolIssueTextClass'); + expect(storagePoolRowSource).toContain('stateToneClass'); expect(storagePoolRowSource).toContain('buildStoragePoolRowModel'); expect(storagePoolRowSource).toContain('STORAGE_POOL_ROW_CLASS'); expect(storagePoolRowSource).not.toContain('getSourcePlatformBadge'); @@ -1753,6 +1748,9 @@ describe('frontend resource type boundaries', () => { expect(storagePoolRowPresentationSource).toContain('getCompactStoragePoolIssueLabel'); expect(storagePoolRowPresentationSource).toContain('getCompactStoragePoolIssueSummary'); expect(storagePoolRowPresentationSource).toContain('getCompactStoragePoolProtectionTitle'); + expect(storagePoolRowPresentationSource).toContain('getStoragePoolStateLabel'); + expect(storagePoolRowPresentationSource).toContain('getStoragePoolStateTitle'); + expect(storagePoolRowPresentationSource).toContain('getStoragePoolStateTextClass'); expect(storagePoolRowSource).not.toContain('const protectionTextClass ='); expect(storagePoolRowSource).not.toContain('const issueTextClass ='); expect(storagePoolRowSource).not.toContain('const compactProtection = createMemo(() => {'); @@ -1805,6 +1803,9 @@ describe('frontend resource type boundaries', () => { 'export function getStoragePoolProtectionTextClass', ); expect(storageRowPresentationSource).toContain('export function getStoragePoolIssueTextClass'); + expect(storageRowPresentationSource).toContain('export function getStoragePoolStateLabel'); + expect(storageRowPresentationSource).toContain('export function getStoragePoolStateTitle'); + expect(storageRowPresentationSource).toContain('export function getStoragePoolStateTextClass'); expect(storageRowPresentationSource).toContain( 'export function getCompactStoragePoolProtectionLabel', ); diff --git a/internal/hostagent/agent_metrics_test.go b/internal/hostagent/agent_metrics_test.go index 643b852ee..3a1117edc 100644 --- a/internal/hostagent/agent_metrics_test.go +++ b/internal/hostagent/agent_metrics_test.go @@ -231,7 +231,26 @@ func TestBuildReport(t *testing.T) { SyncAction: "check", Disks: []agentshost.UnraidDisk{ {Name: "parity", Role: "parity", Status: "online"}, - {Name: "disk1", Role: "data", Status: "online"}, + { + Name: "disk1", + Device: "/dev/sdc", + Role: "data", + Status: "online", + RawStatus: "DISK_OK", + Model: "WDC WD60EFRX", + Serial: "DATA-1", + Filesystem: "xfs", + Transport: "sata", + SizeBytes: 6_000_000_000_000, + UsedBytes: 4_000, + FreeBytes: 2_000, + Temperature: 31, + SpunDown: true, + ReadCount: 11, + WriteCount: 12, + ErrorCount: 1, + Slot: 1, + }, }, }, nil } @@ -251,6 +270,16 @@ func TestBuildReport(t *testing.T) { if len(report.Unraid.Disks) != 2 { t.Fatalf("expected 2 Unraid disks, got %d", len(report.Unraid.Disks)) } + disk := report.Unraid.Disks[1] + if disk.Model != "WDC WD60EFRX" || disk.Transport != "sata" || disk.SizeBytes != 6_000_000_000_000 { + t.Fatalf("expected enriched Unraid disk metadata, got %+v", disk) + } + if disk.UsedBytes != 4_000 || disk.FreeBytes != 2_000 || disk.Temperature != 31 || !disk.SpunDown { + t.Fatalf("expected native Unraid capacity and state fields, got %+v", disk) + } + if disk.ReadCount != 11 || disk.WriteCount != 12 || disk.ErrorCount != 1 { + t.Fatalf("expected native Unraid counters, got %+v", disk) + } mc.unraidStorageFn = nil }) diff --git a/internal/hostagent/unraid.go b/internal/hostagent/unraid.go index 12e9a5431..24dc7d124 100644 --- a/internal/hostagent/unraid.go +++ b/internal/hostagent/unraid.go @@ -14,6 +14,7 @@ import ( ) const hostAgentUnraidVersionPath = "/etc/unraid-version" +const hostAgentUnraidDisksINIPath = "/var/local/emhttp/disks.ini" var commonUnraidMdcmdPaths = []string{ "/usr/local/sbin/mdcmd", @@ -51,7 +52,14 @@ func CollectUnraidStorage(ctx context.Context, collector SystemCollector) (*agen return nil, fmt.Errorf("run mdcmd status: %w", err) } - return parseUnraidStatusOutput(output) + storage, err := parseUnraidStatusOutput(output) + if err != nil { + return nil, err + } + if data, readErr := collector.ReadFile(hostAgentUnraidDisksINIPath); readErr == nil { + storage = mergeUnraidDiskINI(storage, parseUnraidDisksINI(string(data))) + } + return storage, nil } func resolveUnraidMdcmdBinary(collector SystemCollector) (string, error) { @@ -101,7 +109,7 @@ func parseUnraidStatusOutput(output string) (*agentshost.UnraidStorage, error) { RawStatus: strings.TrimSpace(firstNonEmpty(fields[fmt.Sprintf("rdevStatus.%d", idx)], fields[fmt.Sprintf("diskState.%d", idx)])), Serial: strings.TrimSpace(firstNonEmpty(fields[fmt.Sprintf("rdevSerial.%d", idx)], fields[fmt.Sprintf("diskSerial.%d", idx)], fields[fmt.Sprintf("rdevId.%d", idx)], fields[fmt.Sprintf("diskId.%d", idx)])), Filesystem: strings.TrimSpace(firstNonEmpty(fields[fmt.Sprintf("diskFsType.%d", idx)], fields[fmt.Sprintf("fsType.%d", idx)])), - SizeBytes: parseFirstInt64(fields[fmt.Sprintf("diskSize.%d", idx)], fields[fmt.Sprintf("rdevSize.%d", idx)]), + SizeBytes: parseUnraidKiBAsBytes(firstNonEmpty(fields[fmt.Sprintf("diskSize.%d", idx)], fields[fmt.Sprintf("rdevSize.%d", idx)])), Slot: idx, } disk.Role = inferUnraidDiskRole(disk.Name, idx) @@ -144,6 +152,180 @@ func parseUnraidKeyValueOutput(output string) map[string]string { return fields } +type unraidINISection struct { + name string + fields map[string]string +} + +func parseUnraidDisksINI(input string) []agentshost.UnraidDisk { + sections := parseUnraidINISections(input) + disks := make([]agentshost.UnraidDisk, 0, len(sections)) + for _, section := range sections { + if section.name == "" { + continue + } + fields := section.fields + idx := parseUnraidIntField(fields, "idx") + name := firstNonEmpty(fields["name"], section.name) + role := normalizeUnraidDiskRole(firstNonEmpty(fields["type"], inferUnraidDiskRole(name, idx))) + device := normalizeBlockDevice(fields["device"]) + rawStatus := strings.TrimSpace(fields["status"]) + disk := agentshost.UnraidDisk{ + Name: strings.TrimSpace(name), + Device: device, + Role: role, + Status: normalizeUnraidDiskStatus(rawStatus, device), + RawStatus: rawStatus, + Filesystem: strings.TrimSpace(fields["fsType"]), + Transport: normalizeUnraidTransport(fields["transport"], fields["rotational"]), + SizeBytes: parseUnraidSizeBytes(fields), + UsedBytes: parseUnraidKiBAsBytes(fields["fsUsed"]), + FreeBytes: parseUnraidKiBAsBytes(fields["fsFree"]), + Temperature: parseUnraidTemperature(fields["temp"]), + SpunDown: parseUnraidBool(fields["spundown"]), + ReadCount: parseFirstInt64(fields["numReads"]), + WriteCount: parseFirstInt64(fields["numWrites"]), + ErrorCount: parseFirstInt64(fields["numErrors"]), + Slot: idx, + } + disk.Model, disk.Serial = parseUnraidDiskIdentity(firstNonEmpty(fields["id"], fields["idSb"])) + if disk.Serial == "" { + disk.Serial = strings.TrimSpace(firstNonEmpty(fields["id"], fields["idSb"])) + } + if isUnraidEmptySlot(disk) { + continue + } + if disk.Name == "" { + disk.Name = defaultUnraidDiskName(disk.Role, idx) + } + if disk.Name == "" && disk.Device == "" && disk.RawStatus == "" && disk.Serial == "" && disk.SizeBytes == 0 { + continue + } + disks = append(disks, disk) + } + return disks +} + +func parseUnraidINISections(input string) []unraidINISection { + lines := strings.Split(input, "\n") + sections := make([]unraidINISection, 0) + var current *unraidINISection + for _, rawLine := range lines { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + name := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "["), "]")) + name = strings.Trim(name, `"`) + sections = append(sections, unraidINISection{name: name, fields: make(map[string]string)}) + current = §ions[len(sections)-1] + continue + } + if current == nil || !strings.Contains(line, "=") { + continue + } + parts := strings.SplitN(line, "=", 2) + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + if unquoted, err := strconv.Unquote(value); err == nil { + value = unquoted + } + if key != "" { + current.fields[key] = value + } + } + return sections +} + +func mergeUnraidDiskINI(storage *agentshost.UnraidStorage, iniDisks []agentshost.UnraidDisk) *agentshost.UnraidStorage { + if storage == nil { + storage = &agentshost.UnraidStorage{} + } + if len(iniDisks) == 0 { + return storage + } + + merged := make([]agentshost.UnraidDisk, 0, len(storage.Disks)+len(iniDisks)) + bySlot := make(map[int]int, len(storage.Disks)) + for _, disk := range storage.Disks { + bySlot[disk.Slot] = len(merged) + merged = append(merged, disk) + } + + for _, disk := range iniDisks { + if pos, ok := bySlot[disk.Slot]; ok { + merged[pos] = mergeUnraidDisk(merged[pos], disk) + continue + } + bySlot[disk.Slot] = len(merged) + merged = append(merged, disk) + } + + sort.SliceStable(merged, func(i, j int) bool { + return merged[i].Slot < merged[j].Slot + }) + storage.Disks = merged + return storage +} + +func mergeUnraidDisk(base, incoming agentshost.UnraidDisk) agentshost.UnraidDisk { + out := base + if strings.TrimSpace(incoming.Name) != "" { + out.Name = strings.TrimSpace(incoming.Name) + } + if strings.TrimSpace(incoming.Device) != "" { + out.Device = strings.TrimSpace(incoming.Device) + } + if strings.TrimSpace(incoming.Role) != "" { + out.Role = strings.TrimSpace(incoming.Role) + } + if strings.TrimSpace(incoming.Status) != "" { + out.Status = strings.TrimSpace(incoming.Status) + } + if strings.TrimSpace(incoming.RawStatus) != "" { + out.RawStatus = strings.TrimSpace(incoming.RawStatus) + } + if strings.TrimSpace(incoming.Model) != "" { + out.Model = strings.TrimSpace(incoming.Model) + } + if strings.TrimSpace(incoming.Serial) != "" { + out.Serial = strings.TrimSpace(incoming.Serial) + } + if strings.TrimSpace(incoming.Filesystem) != "" { + out.Filesystem = strings.TrimSpace(incoming.Filesystem) + } + if strings.TrimSpace(incoming.Transport) != "" { + out.Transport = strings.TrimSpace(incoming.Transport) + } + if incoming.SizeBytes > 0 { + out.SizeBytes = incoming.SizeBytes + } + if incoming.UsedBytes > 0 { + out.UsedBytes = incoming.UsedBytes + } + if incoming.FreeBytes > 0 { + out.FreeBytes = incoming.FreeBytes + } + if incoming.Temperature > 0 { + out.Temperature = incoming.Temperature + } + out.SpunDown = incoming.SpunDown + if incoming.ReadCount > 0 { + out.ReadCount = incoming.ReadCount + } + if incoming.WriteCount > 0 { + out.WriteCount = incoming.WriteCount + } + if incoming.ErrorCount > 0 { + out.ErrorCount = incoming.ErrorCount + } + if incoming.Slot > 0 || out.Slot == 0 { + out.Slot = incoming.Slot + } + return out +} + func collectUnraidIndexes(fields map[string]string) []int { indexes := make(map[int]struct{}) prefixes := []string{ @@ -204,11 +386,29 @@ func inferUnraidDiskRole(name string, idx int) string { return "flash" case idx == 0: return "parity" + case idx == 29: + return "parity" + case idx > 0 && idx < 29: + return "data" + case idx >= 30: + return "cache" default: return "" } } +func normalizeUnraidDiskRole(role string) string { + role = strings.ToLower(strings.TrimSpace(role)) + switch role { + case "parity", "data", "cache", "flash": + return role + case "array": + return "data" + default: + return role + } +} + func defaultUnraidDiskName(role string, idx int) string { switch role { case "parity": @@ -326,6 +526,72 @@ func parseFirstInt64(values ...string) int64 { return 0 } +func parseUnraidKiBAsBytes(value string) int64 { + parsed := parseFirstInt64(value) + if parsed <= 0 { + return 0 + } + return parsed * 1024 +} + +func parseUnraidSizeBytes(fields map[string]string) int64 { + sectors := parseFirstInt64(fields["sectors"]) + sectorSize := parseFirstInt64(fields["sector_size"]) + if sectors > 0 && sectorSize > 0 { + return sectors * sectorSize + } + return parseUnraidKiBAsBytes(firstNonEmpty(fields["size"], fields["sizeSb"])) +} + +func parseUnraidTemperature(value string) int { + value = strings.TrimSpace(value) + if value == "" || value == "*" || strings.EqualFold(value, "na") { + return 0 + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed < 0 { + return 0 + } + return parsed +} + +func parseUnraidBool(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +func normalizeUnraidTransport(transport string, rotational string) string { + transport = strings.ToLower(strings.TrimSpace(transport)) + switch transport { + case "ata": + return "sata" + case "nvme", "sata", "sas", "usb": + return transport + } + if strings.TrimSpace(rotational) == "0" { + return "ssd" + } + return transport +} + +func parseUnraidDiskIdentity(id string) (string, string) { + id = strings.TrimSpace(id) + if id == "" { + return "", "" + } + idx := strings.LastIndex(id, "_") + if idx <= 0 || idx >= len(id)-1 { + return strings.ReplaceAll(id, "_", " "), "" + } + model := strings.ReplaceAll(strings.TrimSpace(id[:idx]), "_", " ") + serial := strings.TrimSpace(id[idx+1:]) + return model, serial +} + func parseFloatField(fields map[string]string, key string) (float64, bool) { value := strings.TrimSpace(fields[key]) if value == "" { diff --git a/internal/hostagent/unraid_test.go b/internal/hostagent/unraid_test.go index 401de9892..db23a7787 100644 --- a/internal/hostagent/unraid_test.go +++ b/internal/hostagent/unraid_test.go @@ -55,6 +55,9 @@ rdevStatus.2=DISK_DSBL if got := storage.Disks[1]; got.Role != "data" || got.Serial != "SER123" || got.Filesystem != "xfs" { t.Fatalf("unexpected data disk: %+v", got) } + if got := storage.Disks[1].SizeBytes; got != 12000*1024 { + t.Fatalf("data disk size = %d, want %d", got, int64(12000*1024)) + } if got := storage.Disks[2]; got.Status != "disabled" { t.Fatalf("disabled disk status = %q, want disabled", got.Status) } @@ -103,11 +106,75 @@ rdevId.29= if len(storage.Disks) != 1 { t.Fatalf("disk count = %d, want only assigned disks: %+v", len(storage.Disks), storage.Disks) } - if got := storage.Disks[0]; got.Name != "md1p1" || got.Status != "online" || got.Serial != "WDC_DATA" { + if got := storage.Disks[0]; got.Name != "md1p1" || got.Role != "data" || got.Status != "online" || got.Serial != "WDC_DATA" { t.Fatalf("unexpected assigned disk: %+v", got) } } +func TestParseUnraidDisksINIAddsNativeTopologyFields(t *testing.T) { + input := ` +["disk1"] +idx="1" +name="disk1" +device="sde" +id="WDC_WD60EFRX-68L0BN1_WD-WX11D65CUC39" +size="5860522532" +sectors="11721045168" +sector_size="512" +transport="ata" +rotational="1" +spundown="0" +status="DISK_OK" +temp="25" +numReads="494251710" +numWrites="344072698" +numErrors="0" +type="Data" +fsType="luks:xfs" +fsSize="5858433572" +fsFree="1459693324" +fsUsed="4398740248" +["cachepool"] +idx="30" +name="cachepool" +device="sdf" +id="SSD_2000GB_AA202305222000G68551" +size="1953514552" +sectors="3907029168" +sector_size="512" +transport="ata" +rotational="0" +spundown="1" +status="DISK_OK" +temp="*" +numErrors="2" +type="Cache" +fsType="btrfs" +fsFree="1783019588" +fsUsed="166957852" +` + + disks := parseUnraidDisksINI(input) + if len(disks) != 2 { + t.Fatalf("disk count = %d, want 2: %+v", len(disks), disks) + } + if got := disks[0]; got.Name != "disk1" || got.Role != "data" || got.Device != "/dev/sde" || got.Transport != "sata" { + t.Fatalf("unexpected disk1 identity: %+v", got) + } + if got := disks[0]; got.Model != "WDC WD60EFRX-68L0BN1" || got.Serial != "WD-WX11D65CUC39" { + t.Fatalf("unexpected disk1 model/serial: %+v", got) + } + if got := disks[0].SizeBytes; got != 11721045168*512 { + t.Fatalf("disk1 size = %d, want %d", got, int64(11721045168*512)) + } + if got := disks[0].UsedBytes; got != 4398740248*1024 { + t.Fatalf("disk1 used = %d, want %d", got, int64(4398740248*1024)) + } + if got := disks[1]; got.Name != "cachepool" || got.Role != "cache" || !got.SpunDown || got.Temperature != 0 || got.ErrorCount != 2 { + t.Fatalf("unexpected cachepool fields: %+v", got) + } +} + func TestCollectUnraidStorageSkipsNonUnraid(t *testing.T) { t.Parallel() diff --git a/internal/models/models.go b/internal/models/models.go index f1257970f..711894d3d 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -443,15 +443,24 @@ func (s HostUnraidStorage) NormalizeCollections() HostUnraidStorage { // HostUnraidDisk represents a disk's role and state inside an Unraid array. type HostUnraidDisk struct { - Name string `json:"name"` - Device string `json:"device,omitempty"` - Role string `json:"role,omitempty"` - Status string `json:"status,omitempty"` - RawStatus string `json:"rawStatus,omitempty"` - Serial string `json:"serial,omitempty"` - Filesystem string `json:"filesystem,omitempty"` - SizeBytes int64 `json:"sizeBytes,omitempty"` - Slot int `json:"slot,omitempty"` + Name string `json:"name"` + Device string `json:"device,omitempty"` + Role string `json:"role,omitempty"` + Status string `json:"status,omitempty"` + RawStatus string `json:"rawStatus,omitempty"` + Model string `json:"model,omitempty"` + Serial string `json:"serial,omitempty"` + Filesystem string `json:"filesystem,omitempty"` + Transport string `json:"transport,omitempty"` + SizeBytes int64 `json:"sizeBytes,omitempty"` + UsedBytes int64 `json:"usedBytes,omitempty"` + FreeBytes int64 `json:"freeBytes,omitempty"` + Temperature int `json:"temperature,omitempty"` + SpunDown bool `json:"spunDown,omitempty"` + ReadCount int64 `json:"readCount,omitempty"` + WriteCount int64 `json:"writeCount,omitempty"` + ErrorCount int64 `json:"errorCount,omitempty"` + Slot int `json:"slot,omitempty"` } // HostCephCluster represents Ceph cluster status collected directly by the host agent. diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index 315d85489..72f7a6b3b 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -171,6 +171,49 @@ func TestBroadcastResourceProjectionPreservesCanonicalHealthContext(t *testing.T } } +func TestHostUnraidReadStateProjectionPreservesNativeDiskFields(t *testing.T) { + projected := hostUnraidFromReadStateView(&unifiedresources.HostUnraidMeta{ + ArrayStarted: true, + ArrayState: "STARTED", + Disks: []unifiedresources.HostUnraidDiskMeta{ + { + Name: "disk1", + Device: "/dev/sdc", + Role: "data", + Status: "online", + RawStatus: "DISK_OK", + Model: "WDC WD60EFRX", + Serial: "SERIAL-DATA", + Filesystem: "xfs", + Transport: "sata", + SizeBytes: 6_000_000_000_000, + UsedBytes: 4_000, + FreeBytes: 2_000, + Temperature: 31, + SpunDown: true, + ReadCount: 11, + WriteCount: 12, + ErrorCount: 16, + Slot: 1, + }, + }, + }) + + if projected == nil || len(projected.Disks) != 1 { + t.Fatalf("expected projected Unraid disk metadata, got %+v", projected) + } + disk := projected.Disks[0] + if disk.Model != "WDC WD60EFRX" || disk.Transport != "sata" || disk.SizeBytes != 6_000_000_000_000 { + t.Fatalf("expected native metadata to survive read-state projection, got %+v", disk) + } + if disk.UsedBytes != 4_000 || disk.FreeBytes != 2_000 || disk.Temperature != 31 || !disk.SpunDown { + t.Fatalf("expected native capacity and state fields to survive projection, got %+v", disk) + } + if disk.ReadCount != 11 || disk.WriteCount != 12 || disk.ErrorCount != 16 { + t.Fatalf("expected native counters to survive projection, got %+v", disk) + } +} + func TestBroadcastResourceProjectionCoalescesSplitHostIdentities(t *testing.T) { data, err := os.ReadFile("monitor.go") if err != nil { diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 694fced34..1db7863a4 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -3394,15 +3394,24 @@ func hostUnraidFromReadStateView(unraid *unifiedresources.HostUnraidMeta) *model out.Disks = make([]models.HostUnraidDisk, 0, len(unraid.Disks)) for _, disk := range unraid.Disks { out.Disks = append(out.Disks, models.HostUnraidDisk{ - Name: disk.Name, - Device: disk.Device, - Role: disk.Role, - Status: disk.Status, - RawStatus: disk.RawStatus, - Serial: disk.Serial, - Filesystem: disk.Filesystem, - SizeBytes: disk.SizeBytes, - Slot: disk.Slot, + Name: disk.Name, + Device: disk.Device, + Role: disk.Role, + Status: disk.Status, + RawStatus: disk.RawStatus, + Model: disk.Model, + Serial: disk.Serial, + Filesystem: disk.Filesystem, + Transport: disk.Transport, + SizeBytes: disk.SizeBytes, + UsedBytes: disk.UsedBytes, + FreeBytes: disk.FreeBytes, + Temperature: disk.Temperature, + SpunDown: disk.SpunDown, + ReadCount: disk.ReadCount, + WriteCount: disk.WriteCount, + ErrorCount: disk.ErrorCount, + Slot: disk.Slot, }) } } diff --git a/internal/monitoring/monitor_agents.go b/internal/monitoring/monitor_agents.go index badf8d524..7b307f6e6 100644 --- a/internal/monitoring/monitor_agents.go +++ b/internal/monitoring/monitor_agents.go @@ -1805,15 +1805,24 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config. continue } disks = append(disks, models.HostUnraidDisk{ - Name: strings.TrimSpace(disk.Name), - Device: device, - Role: strings.TrimSpace(disk.Role), - Status: status, - RawStatus: rawStatus, - Serial: strings.TrimSpace(disk.Serial), - Filesystem: strings.TrimSpace(disk.Filesystem), - SizeBytes: disk.SizeBytes, - Slot: disk.Slot, + Name: strings.TrimSpace(disk.Name), + Device: device, + Role: strings.TrimSpace(disk.Role), + Status: status, + RawStatus: rawStatus, + Model: strings.TrimSpace(disk.Model), + Serial: strings.TrimSpace(disk.Serial), + Filesystem: strings.TrimSpace(disk.Filesystem), + Transport: strings.TrimSpace(disk.Transport), + SizeBytes: disk.SizeBytes, + UsedBytes: disk.UsedBytes, + FreeBytes: disk.FreeBytes, + Temperature: disk.Temperature, + SpunDown: disk.SpunDown, + ReadCount: disk.ReadCount, + WriteCount: disk.WriteCount, + ErrorCount: disk.ErrorCount, + Slot: disk.Slot, }) } unraidData = &models.HostUnraidStorage{ diff --git a/internal/monitoring/monitor_host_agents_test.go b/internal/monitoring/monitor_host_agents_test.go index 024f5c4b5..9614db66d 100644 --- a/internal/monitoring/monitor_host_agents_test.go +++ b/internal/monitoring/monitor_host_agents_test.go @@ -801,7 +801,26 @@ func TestApplyHostReportStoresUnraidTopology(t *testing.T) { SyncProgress: 55, Disks: []agentshost.UnraidDisk{ {Name: "parity", Device: "/dev/sdb", Role: "parity", Status: "online", RawStatus: "DISK_OK", Serial: "SERIAL-PARITY"}, - {Name: "disk1", Device: "/dev/sdc", Role: "data", Status: "online", RawStatus: "DISK_OK", Serial: "SERIAL-DATA"}, + { + Name: "disk1", + Device: "/dev/sdc", + Role: "data", + Status: "online", + RawStatus: "DISK_OK", + Model: "WDC WD60EFRX", + Serial: "SERIAL-DATA", + Filesystem: "xfs", + Transport: "sata", + SizeBytes: 6_000_000_000_000, + UsedBytes: 4_000, + FreeBytes: 2_000, + Temperature: 31, + SpunDown: true, + ReadCount: 11, + WriteCount: 12, + ErrorCount: 16, + Slot: 1, + }, }, }, Timestamp: time.Now().UTC(), @@ -821,6 +840,16 @@ func TestApplyHostReportStoresUnraidTopology(t *testing.T) { if len(host.Unraid.Disks) != 2 || host.Unraid.Disks[0].Role != "parity" { t.Fatalf("unexpected unraid disks %+v", host.Unraid.Disks) } + disk := host.Unraid.Disks[1] + if disk.Model != "WDC WD60EFRX" || disk.Transport != "sata" || disk.SizeBytes != 6_000_000_000_000 { + t.Fatalf("expected enriched unraid disk metadata, got %+v", disk) + } + if disk.UsedBytes != 4_000 || disk.FreeBytes != 2_000 || disk.Temperature != 31 || !disk.SpunDown { + t.Fatalf("expected native unraid capacity and state fields, got %+v", disk) + } + if disk.ReadCount != 11 || disk.WriteCount != 12 || disk.ErrorCount != 16 { + t.Fatalf("expected native unraid counters, got %+v", disk) + } } func TestApplyHostReportNormalizesLegacyUnraidRawStatuses(t *testing.T) { diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go index db13f7a1d..5ae419e7c 100644 --- a/internal/unifiedresources/adapters.go +++ b/internal/unifiedresources/adapters.go @@ -3,6 +3,7 @@ package unifiedresources import ( "net" "net/url" + "strconv" "strings" "time" @@ -240,16 +241,26 @@ func resourceFromHost(host models.Host) (Resource, ResourceIdentity) { if host.Unraid != nil { disks := make([]HostUnraidDiskMeta, len(host.Unraid.Disks)) for i, disk := range host.Unraid.Disks { + sizeBytes := unraidDiskSizeBytes(host, disk) disks[i] = HostUnraidDiskMeta{ - Name: disk.Name, - Device: disk.Device, - Role: disk.Role, - Status: disk.Status, - RawStatus: disk.RawStatus, - Serial: disk.Serial, - Filesystem: disk.Filesystem, - SizeBytes: disk.SizeBytes, - Slot: disk.Slot, + Name: disk.Name, + Device: disk.Device, + Role: disk.Role, + Status: disk.Status, + RawStatus: disk.RawStatus, + Model: disk.Model, + Serial: disk.Serial, + Filesystem: disk.Filesystem, + Transport: disk.Transport, + SizeBytes: sizeBytes, + UsedBytes: disk.UsedBytes, + FreeBytes: disk.FreeBytes, + Temperature: disk.Temperature, + SpunDown: disk.SpunDown, + ReadCount: disk.ReadCount, + WriteCount: disk.WriteCount, + ErrorCount: disk.ErrorCount, + Slot: disk.Slot, } } assessment := storagehealth.AssessUnraidStorage(*host.Unraid) @@ -500,6 +511,92 @@ func resourceFromHostUnraidStorage(host models.Host) (Resource, ResourceIdentity return resource, identity } +func resourceFromHostUnraidCacheStorage(host models.Host, disk models.HostUnraidDisk) (Resource, ResourceIdentity) { + name := unraidCachePoolName(disk) + if name == "" { + name = "cache" + } + total, used, _, percent := unraidDiskCapacityForHost(host, disk) + resource := Resource{ + Type: ResourceTypeStorage, + Technology: "unraid", + Name: name, + Status: statusFromString(firstNonEmpty(disk.Status, host.Status)), + LastSeen: host.LastSeen, + UpdatedAt: time.Now().UTC(), + Metrics: metricsFromUnraidDiskCapacity(total, used, percent), + Storage: &StorageMeta{ + Type: "unraid-cache-pool", + Content: "files", + ContentTypes: []string{"files"}, + Shared: false, + Enabled: true, + Active: strings.EqualFold(strings.TrimSpace(disk.Status), "online"), + IsCeph: false, + IsZFS: strings.EqualFold(strings.TrimSpace(disk.Filesystem), "zfs"), + Platform: "unraid", + Topology: "pool", + Protection: "none", + Path: "/mnt/" + name, + }, + Tags: uniqueStrings([]string{ + "unraid", + "storage", + "cache", + strings.TrimSpace(disk.Filesystem), + }), + } + identity := ResourceIdentity{ + MachineID: hostUnraidCacheStorageIdentity(host, disk), + Hostnames: uniqueStrings([]string{ + host.Hostname, + host.Hostname + ":unraid-cache:" + name, + }), + } + return resource, identity +} + +func resourceFromHostUnraidPhysicalDisk(host models.Host, disk models.HostUnraidDisk) (Resource, ResourceIdentity) { + model := strings.TrimSpace(disk.Model) + name := firstNonEmpty(model, disk.Name, disk.Device, host.Hostname) + health := unraidPhysicalDiskHealth(disk) + assessment := assessUnraidPhysicalDisk(disk) + sizeBytes := unraidDiskSizeBytes(host, disk) + resource := Resource{ + Type: ResourceTypePhysicalDisk, + Name: name, + Status: physicalDiskStatus(model, health, assessment), + LastSeen: host.LastSeen, + UpdatedAt: time.Now().UTC(), + PhysicalDisk: &PhysicalDiskMeta{ + DevPath: strings.TrimSpace(disk.Device), + Model: model, + Serial: strings.TrimSpace(disk.Serial), + DiskType: unraidDiskDiskType(disk), + SizeBytes: sizeBytes, + Health: health, + Wearout: -1, + Temperature: disk.Temperature, + Used: unraidDiskMountPath(disk), + StorageRole: unraidDiskRole(&disk), + StorageGroup: unraidDiskGroup(&disk), + StorageState: strings.TrimSpace(disk.Status), + SpunDown: disk.SpunDown, + ReadCount: disk.ReadCount, + WriteCount: disk.WriteCount, + ErrorCount: disk.ErrorCount, + Risk: physicalDiskRiskFromAssessment(assessment), + }, + } + identity := ResourceIdentity{ + Hostnames: uniqueStrings([]string{host.Hostname}), + } + if serial := strings.TrimSpace(disk.Serial); serial != "" { + identity.MachineID = serial + } + return resource, identity +} + func resourceFromHostSMARTDisk(host models.Host, disk models.HostDiskSMART) (Resource, ResourceIdentity) { name := strings.TrimSpace(disk.Model) if name == "" { @@ -510,9 +607,9 @@ func resourceFromHostSMARTDisk(host models.Host, disk models.HostDiskSMART) (Res } var matchedDisk *models.Disk - normalizedDevice := strings.TrimSpace(strings.TrimPrefix(disk.Device, "/dev/")) + normalizedDevice := strings.ToLower(normalizePhysicalDiskDeviceToken(disk.Device)) for i := range host.Disks { - hostDevice := strings.TrimSpace(strings.TrimPrefix(host.Disks[i].Device, "/dev/")) + hostDevice := strings.ToLower(normalizePhysicalDiskDeviceToken(host.Disks[i].Device)) if normalizedDevice == "" || hostDevice == "" { continue } @@ -529,7 +626,35 @@ func resourceFromHostSMARTDisk(host models.Host, disk models.HostDiskSMART) (Res used = strings.TrimSpace(matchedDisk.Mountpoint) } unraidDisk := matchUnraidDisk(host.Unraid, disk) + model := strings.TrimSpace(disk.Model) + serial := strings.TrimSpace(disk.Serial) + diskType := strings.TrimSpace(disk.Type) + temperature := disk.Temperature + health := strings.TrimSpace(disk.Health) + if unraidDisk != nil { + if model == "" { + model = strings.TrimSpace(unraidDisk.Model) + } + if serial == "" { + serial = strings.TrimSpace(unraidDisk.Serial) + } + if diskType == "" { + diskType = unraidDiskDiskType(*unraidDisk) + } + if sizeBytes <= 0 { + sizeBytes = unraidDiskSizeBytes(host, *unraidDisk) + } + if temperature <= 0 { + temperature = unraidDisk.Temperature + } + if health == "" || strings.EqualFold(health, "UNKNOWN") { + health = unraidPhysicalDiskHealth(*unraidDisk) + } + } assessment := storagehealth.AssessHostSMARTDisk(disk) + if unraidDisk != nil { + assessment = storagehealth.SummarizeAssessments(assessment, assessUnraidPhysicalDisk(*unraidDisk)) + } storageGroup := unraidDiskGroup(unraidDisk) if storageGroup == "" { @@ -538,24 +663,28 @@ func resourceFromHostSMARTDisk(host models.Host, disk models.HostDiskSMART) (Res resource := Resource{ Type: ResourceTypePhysicalDisk, - Name: name, - Status: physicalDiskStatus(disk.Model, disk.Health, assessment), + Name: firstNonEmpty(model, name), + Status: physicalDiskStatus(model, health, assessment), LastSeen: host.LastSeen, UpdatedAt: time.Now().UTC(), PhysicalDisk: &PhysicalDiskMeta{ DevPath: strings.TrimSpace(disk.Device), - Model: strings.TrimSpace(disk.Model), - Serial: strings.TrimSpace(disk.Serial), + Model: model, + Serial: serial, WWN: strings.TrimSpace(disk.WWN), - DiskType: strings.TrimSpace(disk.Type), + DiskType: diskType, SizeBytes: sizeBytes, - Health: strings.TrimSpace(disk.Health), + Health: health, Wearout: -1, - Temperature: disk.Temperature, + Temperature: temperature, Used: used, StorageRole: unraidDiskRole(unraidDisk), StorageGroup: storageGroup, StorageState: unraidDiskState(unraidDisk), + SpunDown: unraidDisk != nil && unraidDisk.SpunDown, + ReadCount: unraidDiskCounter(unraidDisk, "read"), + WriteCount: unraidDiskCounter(unraidDisk, "write"), + ErrorCount: unraidDiskCounter(unraidDisk, "error"), SMART: convertSMARTAttributes(disk.Attributes), Risk: physicalDiskRiskFromAssessment(assessment), }, @@ -564,8 +693,8 @@ func resourceFromHostSMARTDisk(host models.Host, disk models.HostDiskSMART) (Res identity := ResourceIdentity{ Hostnames: uniqueStrings([]string{host.Hostname}), } - if disk.Serial != "" { - identity.MachineID = strings.TrimSpace(disk.Serial) + if serial != "" { + identity.MachineID = serial } else if disk.WWN != "" { identity.MachineID = strings.TrimSpace(disk.WWN) } @@ -591,6 +720,33 @@ func hostUnraidStorageSourceID(host models.Host) string { return hostID + "/storage:unraid-array" } +func hostUnraidCacheStorageIdentity(host models.Host, disk models.HostUnraidDisk) string { + pool := unraidCachePoolName(disk) + if pool == "" { + return "" + } + if machineID := strings.TrimSpace(host.MachineID); machineID != "" { + return machineID + "/storage/unraid-cache/" + pool + } + if hostname := strings.TrimSpace(host.Hostname); hostname != "" { + return hostname + "/storage/unraid-cache/" + pool + } + return "" +} + +func hostUnraidCacheStorageSourceID(host models.Host, disk models.HostUnraidDisk) string { + hostID := strings.TrimSpace(host.ID) + pool := unraidCachePoolName(disk) + if hostID == "" || pool == "" { + return "" + } + return hostID + "/storage:unraid-cache:" + pool +} + +func unraidCachePoolName(disk models.HostUnraidDisk) string { + return strings.TrimSpace(firstNonEmpty(disk.Name, strings.TrimPrefix(disk.Device, "/dev/"))) +} + func unraidStoragePath(host models.Host) string { for _, disk := range host.Disks { mount := strings.TrimSpace(disk.Mountpoint) @@ -602,6 +758,21 @@ func unraidStoragePath(host models.Host) string { return "/mnt/user" } +func unraidDiskMountPath(disk models.HostUnraidDisk) string { + name := strings.TrimSpace(disk.Name) + switch unraidDiskRole(&disk) { + case "data": + if name != "" { + return "/mnt/" + name + } + case "cache": + if name != "" { + return "/mnt/" + name + } + } + return "" +} + func unraidStorageCapacity(host models.Host) (int64, int64, int64, float64) { for _, disk := range host.Disks { mount := strings.TrimSpace(disk.Mountpoint) @@ -622,7 +793,14 @@ func unraidStorageCapacity(host models.Host) (int64, int64, int64, float64) { var used int64 var free int64 for _, disk := range host.Unraid.Disks { - if strings.TrimSpace(disk.Role) != "data" { + if unraidDiskRole(&disk) != "data" { + continue + } + diskTotal, diskUsed, diskFree, _ := unraidDiskCapacityForHost(host, disk) + if diskTotal > 0 { + total += diskTotal + used += diskUsed + free += diskFree continue } device := strings.TrimSpace(strings.TrimPrefix(strings.ToLower(disk.Device), "/dev/")) @@ -638,19 +816,127 @@ func unraidStorageCapacity(host models.Host) (int64, int64, int64, float64) { return total, used, free, (float64(used) / float64(total)) * 100 } +func unraidDiskCapacity(disk models.HostUnraidDisk) (int64, int64, int64, float64) { + used := disk.UsedBytes + free := disk.FreeBytes + total := used + free + if total <= 0 { + total = disk.SizeBytes + } + percent := float64(0) + if total > 0 && used > 0 { + percent = (float64(used) / float64(total)) * 100 + } + return total, used, free, percent +} + +func unraidDiskCapacityForHost(host models.Host, disk models.HostUnraidDisk) (int64, int64, int64, float64) { + total, used, free, percent := unraidDiskCapacity(disk) + if used <= 0 && free <= 0 { + total = unraidDiskSizeBytes(host, disk) + } + if total > 0 && used > 0 { + percent = (float64(used) / float64(total)) * 100 + } + return total, used, free, percent +} + +func unraidDiskSizeBytes(host models.Host, disk models.HostUnraidDisk) int64 { + size := disk.SizeBytes + if size <= 0 { + return 0 + } + if !shouldScaleLegacyUnraidKiBSize(host, disk, size) { + return size + } + if size > maxInt64()/1024 { + return size + } + return size * 1024 +} + +func shouldScaleLegacyUnraidKiBSize(host models.Host, disk models.HostUnraidDisk, size int64) bool { + const maxPlausibleLegacyKiBValue = int64(100_000_000_000) + if size <= 0 || size >= maxPlausibleLegacyKiBValue { + return false + } + + if total := disk.UsedBytes + disk.FreeBytes; legacyKiBMatchesTotal(total, size) { + return true + } + + role := unraidDiskRole(&disk) + switch role { + case "data", "parity": + arrayTotal := unraidArrayMountTotal(host) + if arrayTotal <= 0 || host.Unraid == nil { + return false + } + var rawDataTotal int64 + for _, candidate := range host.Unraid.Disks { + if unraidDiskRole(&candidate) != "data" || candidate.SizeBytes <= 0 { + continue + } + rawDataTotal += candidate.SizeBytes + } + return legacyKiBMatchesTotal(arrayTotal, rawDataTotal) + case "cache": + return legacyKiBMatchesTotal(unraidDiskMountTotal(host, disk), size) + default: + return false + } +} + +func legacyKiBMatchesTotal(total int64, rawKiB int64) bool { + if total <= 0 || rawKiB <= 0 || rawKiB > maxInt64()/1024 { + return false + } + scaled := rawKiB * 1024 + lower := scaled - scaled/20 + upper := scaled + scaled/20 + return total >= lower && total <= upper +} + +func unraidArrayMountTotal(host models.Host) int64 { + for _, disk := range host.Disks { + mount := strings.TrimSpace(disk.Mountpoint) + if (mount == "/mnt/user" || mount == "/mnt/user0") && disk.Total > 0 { + return disk.Total + } + } + return 0 +} + +func unraidDiskMountTotal(host models.Host, disk models.HostUnraidDisk) int64 { + mountPath := unraidDiskMountPath(disk) + if mountPath == "" { + return 0 + } + for _, mounted := range host.Disks { + if strings.TrimSpace(mounted.Mountpoint) == mountPath && mounted.Total > 0 { + return mounted.Total + } + } + return 0 +} + +func maxInt64() int64 { + return int64(^uint64(0) >> 1) +} + func matchUnraidDisk(unraid *models.HostUnraidStorage, disk models.HostDiskSMART) *models.HostUnraidDisk { if unraid == nil || len(unraid.Disks) == 0 { return nil } - normalizedDevice := strings.TrimSpace(strings.TrimPrefix(strings.ToLower(disk.Device), "/dev/")) + normalizedDevice := strings.ToLower(normalizePhysicalDiskDeviceToken(disk.Device)) normalizedSerial := strings.TrimSpace(strings.ToLower(disk.Serial)) for i := range unraid.Disks { candidate := &unraid.Disks[i] if normalizedSerial != "" && strings.EqualFold(strings.TrimSpace(candidate.Serial), normalizedSerial) { return candidate } - candidateDevice := strings.TrimSpace(strings.TrimPrefix(strings.ToLower(candidate.Device), "/dev/")) + candidateDevice := strings.ToLower(normalizePhysicalDiskDeviceToken(candidate.Device)) if normalizedDevice != "" && candidateDevice != "" && candidateDevice == normalizedDevice { return candidate } @@ -662,19 +948,33 @@ func unraidDiskRole(disk *models.HostUnraidDisk) string { if disk == nil { return "" } - return strings.TrimSpace(disk.Role) + role := strings.ToLower(strings.TrimSpace(disk.Role)) + if role != "" { + return role + } + name := strings.ToLower(strings.TrimSpace(disk.Name)) + switch { + case strings.HasPrefix(name, "parity"): + return "parity" + case strings.HasPrefix(name, "cache"): + return "cache" + case strings.HasPrefix(name, "disk"), strings.HasPrefix(name, "md"), disk.Slot > 0: + return "data" + default: + return "" + } } func unraidDiskGroup(disk *models.HostUnraidDisk) string { if disk == nil { return "" } - role := strings.TrimSpace(disk.Role) + role := unraidDiskRole(disk) switch role { case "parity", "data": return "unraid-array" case "cache": - return "unraid-cache" + return unraidCachePoolName(*disk) default: return "" } @@ -687,6 +987,91 @@ func unraidDiskState(disk *models.HostUnraidDisk) string { return strings.TrimSpace(disk.Status) } +func unraidDiskCounter(disk *models.HostUnraidDisk, counter string) int64 { + if disk == nil { + return 0 + } + switch counter { + case "read": + return disk.ReadCount + case "write": + return disk.WriteCount + case "error": + return disk.ErrorCount + default: + return 0 + } +} + +func unraidDiskDiskType(disk models.HostUnraidDisk) string { + transport := strings.ToLower(strings.TrimSpace(disk.Transport)) + switch transport { + case "ata": + return "sata" + case "nvme", "sata", "sas", "ssd", "hdd", "usb": + return transport + default: + return transport + } +} + +func unraidPhysicalDiskHealth(disk models.HostUnraidDisk) string { + switch strings.ToLower(strings.TrimSpace(disk.Status)) { + case "online": + return "PASSED" + case "disabled", "invalid", "missing", "wrong", "error": + return "FAILED" + default: + return "UNKNOWN" + } +} + +func assessUnraidPhysicalDisk(disk models.HostUnraidDisk) storagehealth.Assessment { + assessment := storagehealth.AssessSample(storagehealth.Sample{ + Model: disk.Model, + Health: unraidPhysicalDiskHealth(disk), + Temperature: disk.Temperature, + Wearout: -1, + }) + addReason := func(code string, severity storagehealth.RiskLevel, summary string) { + if strings.TrimSpace(summary) == "" { + return + } + assessment.Reasons = append(assessment.Reasons, storagehealth.Reason{ + Code: code, + Severity: severity, + Summary: summary, + }) + if incidentSeverityRank(severity) > incidentSeverityRank(assessment.Level) { + assessment.Level = severity + } + } + label := firstNonEmpty(disk.Name, disk.Device, disk.Serial, "disk") + status := strings.ToUpper(strings.TrimSpace(disk.Status)) + switch strings.ToLower(strings.TrimSpace(disk.Status)) { + case "", "online": + default: + addReason("unraid_disk_state", storagehealth.RiskCritical, "Unraid disk "+label+" is "+status) + } + if disk.ErrorCount > 0 { + addReason("unraid_disk_errors", storagehealth.RiskWarning, "Unraid disk "+label+" reports "+int64Label(disk.ErrorCount)+" error(s)") + } + return assessment +} + +func int64Label(value int64) string { + return strconv.FormatInt(value, 10) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + func resourceFromDockerHost(host models.DockerHost) (Resource, ResourceIdentity) { name := host.Hostname if host.CustomDisplayName != "" { diff --git a/internal/unifiedresources/adapters_test.go b/internal/unifiedresources/adapters_test.go index 914b28d2e..a25611ff0 100644 --- a/internal/unifiedresources/adapters_test.go +++ b/internal/unifiedresources/adapters_test.go @@ -495,12 +495,24 @@ func TestResourceFromHostSMARTDiskCarriesUnraidRole(t *testing.T) { Unraid: &models.HostUnraidStorage{ ArrayStarted: true, Disks: []models.HostUnraidDisk{ - {Name: "parity", Device: "/dev/sdb", Role: "parity", Status: "online", Serial: "PARITY-1"}, + { + Name: "parity", + Device: "/dev/sdb", + Role: "parity", + Status: "online", + Model: "Seagate", + Serial: "PARITY-1", + SizeBytes: 6_000_000_000_000, + SpunDown: true, + ReadCount: 11, + WriteCount: 12, + ErrorCount: 16, + }, }, }, Sensors: models.HostSensorSummary{ SMART: []models.HostDiskSMART{ - {Device: "/dev/sdb", Serial: "PARITY-1", Model: "Seagate"}, + {Device: "sdb [sat]", Serial: "PARITY-1"}, }, }, } @@ -515,4 +527,104 @@ func TestResourceFromHostSMARTDiskCarriesUnraidRole(t *testing.T) { if resource.PhysicalDisk.StorageGroup != "unraid-array" { t.Fatalf("storageGroup = %q, want unraid-array", resource.PhysicalDisk.StorageGroup) } + if resource.PhysicalDisk.SizeBytes != 6_000_000_000_000 { + t.Fatalf("sizeBytes = %d, want unraid disk size", resource.PhysicalDisk.SizeBytes) + } + if resource.PhysicalDisk.Health != "PASSED" { + t.Fatalf("health = %q, want PASSED from Unraid online state", resource.PhysicalDisk.Health) + } + if !resource.PhysicalDisk.SpunDown || resource.PhysicalDisk.ReadCount != 11 || resource.PhysicalDisk.WriteCount != 12 || resource.PhysicalDisk.ErrorCount != 16 { + t.Fatalf("expected native Unraid disk counters, got %+v", resource.PhysicalDisk) + } +} + +func TestResourceFromHostSMARTDiskInfersUnraidArrayRole(t *testing.T) { + host := models.Host{ + ID: "tower-host", + Hostname: "tower", + LastSeen: time.Now().UTC(), + Unraid: &models.HostUnraidStorage{ + ArrayStarted: true, + Disks: []models.HostUnraidDisk{ + { + Name: "md1p1", + Device: "/dev/sde", + Status: "online", + SizeBytes: 6_000_000_000_000, + Slot: 1, + }, + }, + }, + Sensors: models.HostSensorSummary{ + SMART: []models.HostDiskSMART{ + {Device: "sde [sat]", Health: "UNKNOWN"}, + }, + }, + } + + resource, _ := resourceFromHostSMARTDisk(host, host.Sensors.SMART[0]) + if resource.PhysicalDisk == nil { + t.Fatal("expected physical disk metadata") + } + if resource.PhysicalDisk.StorageRole != "data" { + t.Fatalf("storageRole = %q, want data", resource.PhysicalDisk.StorageRole) + } + if resource.PhysicalDisk.StorageGroup != "unraid-array" { + t.Fatalf("storageGroup = %q, want unraid-array", resource.PhysicalDisk.StorageGroup) + } + if resource.PhysicalDisk.SizeBytes != 6_000_000_000_000 { + t.Fatalf("sizeBytes = %d, want unraid disk size", resource.PhysicalDisk.SizeBytes) + } + if resource.PhysicalDisk.Health != "PASSED" { + t.Fatalf("health = %q, want PASSED from Unraid online state", resource.PhysicalDisk.Health) + } +} + +func TestResourceFromHostSMARTDiskNormalizesLegacyUnraidKiBSize(t *testing.T) { + const legacyDiskKiB = int64(5_860_522_532) + const normalizedDiskBytes = legacyDiskKiB * 1024 + host := models.Host{ + ID: "tower-host", + Hostname: "tower", + LastSeen: time.Now().UTC(), + Disks: []models.Disk{ + { + Mountpoint: "/mnt/user", + Total: normalizedDiskBytes * 4, + Used: normalizedDiskBytes * 3, + Free: normalizedDiskBytes, + Usage: 75, + }, + }, + Unraid: &models.HostUnraidStorage{ + ArrayStarted: true, + Disks: []models.HostUnraidDisk{ + {Name: "md1p1", Device: "/dev/sde", Status: "online", SizeBytes: legacyDiskKiB, Slot: 1}, + {Name: "md2p1", Device: "/dev/sdd", Status: "online", SizeBytes: legacyDiskKiB, Slot: 2}, + {Name: "md3p1", Device: "/dev/sdb", Status: "online", SizeBytes: legacyDiskKiB, Slot: 3}, + {Name: "md4p1", Device: "/dev/sdc", Status: "online", SizeBytes: legacyDiskKiB, Slot: 4}, + }, + }, + Sensors: models.HostSensorSummary{ + SMART: []models.HostDiskSMART{ + {Device: "sde [sat]", Health: "UNKNOWN"}, + }, + }, + } + + resource, _ := resourceFromHostSMARTDisk(host, host.Sensors.SMART[0]) + if resource.PhysicalDisk == nil { + t.Fatal("expected physical disk metadata") + } + if resource.PhysicalDisk.SizeBytes != normalizedDiskBytes { + t.Fatalf("sizeBytes = %d, want normalized Unraid bytes %d", resource.PhysicalDisk.SizeBytes, normalizedDiskBytes) + } + + agentResource, _ := resourceFromHost(host) + if agentResource.Agent == nil || agentResource.Agent.Unraid == nil || len(agentResource.Agent.Unraid.Disks) == 0 { + t.Fatalf("expected agent Unraid metadata, got %+v", agentResource.Agent) + } + if got := agentResource.Agent.Unraid.Disks[0].SizeBytes; got != normalizedDiskBytes { + t.Fatalf("agent Unraid disk sizeBytes = %d, want %d", got, normalizedDiskBytes) + } } diff --git a/internal/unifiedresources/canonical_ids_types_test.go b/internal/unifiedresources/canonical_ids_types_test.go index a5305fe22..967df6ddd 100644 --- a/internal/unifiedresources/canonical_ids_types_test.go +++ b/internal/unifiedresources/canonical_ids_types_test.go @@ -1,6 +1,10 @@ package unifiedresources -import "testing" +import ( + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" +) func TestCanonicalResourceTypeDoesNotAliasHost(t *testing.T) { if got := CanonicalResourceType(ResourceType("host")); got != ResourceType("host") { @@ -198,6 +202,45 @@ func TestPhysicalDiskTemperatureAggregateDefaultsToNil(t *testing.T) { } } +func TestHostUnraidDiskSourceIDNormalizesDeviceAndPrefersSerial(t *testing.T) { + host := models.Host{ID: "host-tower"} + + tests := []struct { + name string + disk models.HostUnraidDisk + want string + }{ + { + name: "plain dev path", + disk: models.HostUnraidDisk{Device: "/dev/sdd"}, + want: "host-tower:sdd", + }, + { + name: "smartctl transport suffix", + disk: models.HostUnraidDisk{Device: "sdf [sat]"}, + want: "host-tower:sdf", + }, + { + name: "serial wins", + disk: models.HostUnraidDisk{Device: "/dev/sdg", Serial: "SERIAL-DATA"}, + want: "SERIAL-DATA", + }, + { + name: "slot fallback", + disk: models.HostUnraidDisk{Name: "disk1"}, + want: "host-tower:unraid-slot:disk1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := HostUnraidDiskSourceID(host, tt.disk); got != tt.want { + t.Fatalf("HostUnraidDiskSourceID(%+v) = %q, want %q", tt.disk, got, tt.want) + } + }) + } +} + func TestIsUnsupportedLegacyResourceIDAlias(t *testing.T) { tests := []struct { name string diff --git a/internal/unifiedresources/metrics.go b/internal/unifiedresources/metrics.go index 37bbccabf..a99d45652 100644 --- a/internal/unifiedresources/metrics.go +++ b/internal/unifiedresources/metrics.go @@ -43,17 +43,29 @@ func metricsFromUnraidStorage(host models.Host) *ResourceMetrics { metrics := &ResourceMetrics{} total, used, _, percent := unraidStorageCapacity(host) if total > 0 { - metrics.Disk = &MetricValue{ - Used: &used, - Total: &total, - Percent: percent, - Unit: "bytes", - Source: SourceAgent, - } + metrics.Disk = unraidDiskMetric(total, used, percent) } return metrics } +func metricsFromUnraidDiskCapacity(total, used int64, percent float64) *ResourceMetrics { + metrics := &ResourceMetrics{} + if total > 0 { + metrics.Disk = unraidDiskMetric(total, used, percent) + } + return metrics +} + +func unraidDiskMetric(total, used int64, percent float64) *MetricValue { + return &MetricValue{ + Used: &used, + Total: &total, + Percent: percent, + Unit: "bytes", + Source: SourceAgent, + } +} + func metricsFromDockerHost(host models.DockerHost) *ResourceMetrics { return buildHostMetricPayload( host.CPUUsage, diff --git a/internal/unifiedresources/metrics_targets_test.go b/internal/unifiedresources/metrics_targets_test.go index 14e758f82..cdf0b1821 100644 --- a/internal/unifiedresources/metrics_targets_test.go +++ b/internal/unifiedresources/metrics_targets_test.go @@ -71,6 +71,22 @@ func TestBuildMetricsTarget_UsesCanonicalAgentTypeForInfrastructureFamilies(t *t } } +func TestMetricsFromUnraidDiskCapacityUsesNativeCapacityFields(t *testing.T) { + metrics := metricsFromUnraidDiskCapacity(1_000, 650, 65) + if metrics == nil || metrics.Disk == nil { + t.Fatal("expected disk metrics from native Unraid capacity") + } + if metrics.Disk.Total == nil || *metrics.Disk.Total != 1_000 { + t.Fatalf("disk total = %#v, want 1000", metrics.Disk.Total) + } + if metrics.Disk.Used == nil || *metrics.Disk.Used != 650 { + t.Fatalf("disk used = %#v, want 650", metrics.Disk.Used) + } + if metrics.Disk.Percent != 65 || metrics.Disk.Source != SourceAgent { + t.Fatalf("unexpected disk metric projection: %+v", metrics.Disk) + } +} + func TestBuildMetricsTarget_CanonicalizesSourceIDWhitespace(t *testing.T) { tests := []struct { name string diff --git a/internal/unifiedresources/physical_disk_ids.go b/internal/unifiedresources/physical_disk_ids.go index f4cfa4518..1d38eefe5 100644 --- a/internal/unifiedresources/physical_disk_ids.go +++ b/internal/unifiedresources/physical_disk_ids.go @@ -21,7 +21,7 @@ func PreferredPhysicalDiskMetricID(serial, wwn, fallback string) string { } func HostSMARTDiskSourceID(host models.Host, disk models.HostDiskSMART) string { - device := strings.TrimSpace(strings.TrimPrefix(disk.Device, "/dev/")) + device := normalizePhysicalDiskDeviceToken(disk.Device) fallback := "" if device != "" { fallback = fmt.Sprintf("%s:%s", strings.TrimSpace(host.ID), device) @@ -29,6 +29,17 @@ func HostSMARTDiskSourceID(host models.Host, disk models.HostDiskSMART) string { return PreferredPhysicalDiskMetricID(disk.Serial, disk.WWN, fallback) } +func HostUnraidDiskSourceID(host models.Host, disk models.HostUnraidDisk) string { + device := normalizePhysicalDiskDeviceToken(disk.Device) + fallback := "" + if device != "" { + fallback = fmt.Sprintf("%s:%s", strings.TrimSpace(host.ID), device) + } else if strings.TrimSpace(disk.Name) != "" { + fallback = fmt.Sprintf("%s:unraid-slot:%s", strings.TrimSpace(host.ID), strings.TrimSpace(disk.Name)) + } + return PreferredPhysicalDiskMetricID(disk.Serial, "", fallback) +} + func PhysicalDiskMetricID(disk models.PhysicalDisk) string { fallback := strings.TrimSpace(disk.ID) if fallback == "" && strings.TrimSpace(disk.DevPath) != "" { @@ -48,3 +59,11 @@ func PhysicalDiskMetaMetricID(disk *PhysicalDiskMeta, fallback string) string { } return PreferredPhysicalDiskMetricID(disk.Serial, disk.WWN, fallback) } + +func normalizePhysicalDiskDeviceToken(device string) string { + device = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(device), "/dev/")) + if fields := strings.Fields(device); len(fields) > 0 { + device = fields[0] + } + return strings.TrimSpace(device) +} diff --git a/internal/unifiedresources/registry.go b/internal/unifiedresources/registry.go index 2757eae26..6996ae529 100644 --- a/internal/unifiedresources/registry.go +++ b/internal/unifiedresources/registry.go @@ -11,6 +11,7 @@ import ( "time" "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/storagehealth" "github.com/rcourtman/pulse-go-rewrite/pkg/fsfilters" ) @@ -141,6 +142,9 @@ func (rr *ResourceRegistry) IngestSnapshot(snapshot models.StateSnapshot) { for _, host := range snapshot.Hosts { rr.ingestHostUnraidStorage(host) } + for _, host := range snapshot.Hosts { + rr.ingestHostUnraidPhysicalDisks(host) + } for _, host := range snapshot.Hosts { rr.ingestHostSMARTDisks(host) } @@ -406,7 +410,7 @@ func (rr *ResourceRegistry) seedSourceIDForResourceLocked(resource *Resource, so } fallback := "" if parentSourceID := rr.seedParentSourceIDLocked(resource, SourceAgent); parentSourceID != "" { - device := strings.TrimSpace(strings.TrimPrefix(resource.PhysicalDisk.DevPath, "/dev/")) + device := normalizePhysicalDiskDeviceToken(resource.PhysicalDisk.DevPath) if device != "" { fallback = fmt.Sprintf("%s:%s", parentSourceID, device) } @@ -816,6 +820,70 @@ func (rr *ResourceRegistry) ingestHostUnraidStorage(host models.Host) { resource.ParentID = &parentID } rr.ingest(SourceAgent, hostUnraidStorageSourceID(host), resource, identity) + + for _, disk := range host.Unraid.Disks { + if unraidDiskRole(&disk) != "cache" { + continue + } + resource, identity := resourceFromHostUnraidCacheStorage(host, disk) + if parentID != "" { + resource.ParentID = &parentID + } + if sourceID := hostUnraidCacheStorageSourceID(host, disk); sourceID != "" { + rr.ingest(SourceAgent, sourceID, resource, identity) + } + } +} + +func (rr *ResourceRegistry) ingestHostUnraidPhysicalDisks(host models.Host) { + if host.Unraid == nil || len(host.Unraid.Disks) == 0 { + return + } + + hostParentID := rr.sourceResourceID(SourceAgent, host.ID) + unraidStorageID := rr.sourceResourceID(SourceAgent, hostUnraidStorageSourceID(host)) + cacheStorageIDs := make(map[string]string) + for _, disk := range host.Unraid.Disks { + if unraidDiskRole(&disk) != "cache" { + continue + } + sourceID := hostUnraidCacheStorageSourceID(host, disk) + if sourceID == "" { + continue + } + if resourceID := rr.sourceResourceID(SourceAgent, sourceID); resourceID != "" { + cacheStorageIDs[unraidCachePoolName(disk)] = resourceID + } + } + + for _, disk := range host.Unraid.Disks { + if strings.TrimSpace(disk.Device) == "" { + continue + } + resource, identity := resourceFromHostUnraidPhysicalDisk(host, disk) + if resource.PhysicalDisk == nil { + continue + } + parentID := hostParentID + switch unraidDiskGroup(&disk) { + case "unraid-array": + if unraidStorageID != "" { + parentID = unraidStorageID + } + default: + if cacheID := cacheStorageIDs[unraidCachePoolName(disk)]; cacheID != "" { + parentID = cacheID + } + } + if parentID != "" { + resource.ParentID = &parentID + } + sourceID := HostUnraidDiskSourceID(host, disk) + if sourceID == "" { + continue + } + rr.ingest(SourceAgent, sourceID, resource, identity) + } } func (rr *ResourceRegistry) ingestHostSMARTDisks(host models.Host) { @@ -834,8 +902,17 @@ func (rr *ResourceRegistry) ingestHostSMARTDisks(host models.Host) { continue } parentID := hostParentID - if matched := matchUnraidDisk(host.Unraid, disk); matched != nil && unraidDiskGroup(matched) == "unraid-array" && unraidStorageID != "" { - parentID = unraidStorageID + if matched := matchUnraidDisk(host.Unraid, disk); matched != nil { + switch unraidDiskGroup(matched) { + case "unraid-array": + if unraidStorageID != "" { + parentID = unraidStorageID + } + default: + if cacheID := rr.sourceResourceID(SourceAgent, hostUnraidCacheStorageSourceID(host, *matched)); cacheID != "" { + parentID = cacheID + } + } } if parentID != "" { resource.ParentID = &parentID @@ -1466,7 +1543,7 @@ func mergePhysicalDiskData(existing *PhysicalDiskMeta, incoming *PhysicalDiskMet if incoming.SizeBytes > 0 { merged.SizeBytes = incoming.SizeBytes } - if incoming.Health != "" { + if shouldReplacePhysicalDiskHealth(merged.Health, incoming.Health) { merged.Health = incoming.Health } if incoming.Wearout >= 0 && (merged.Wearout < 0 || incoming.SMART != nil || merged.SMART == nil) { @@ -1493,15 +1570,84 @@ func mergePhysicalDiskData(existing *PhysicalDiskMeta, incoming *PhysicalDiskMet if incoming.StorageState != "" { merged.StorageState = incoming.StorageState } + if incoming.SpunDown { + merged.SpunDown = true + } + if incoming.ReadCount > 0 { + merged.ReadCount = incoming.ReadCount + } + if incoming.WriteCount > 0 { + merged.WriteCount = incoming.WriteCount + } + if incoming.ErrorCount > 0 { + merged.ErrorCount = incoming.ErrorCount + } if incoming.SMART != nil { smart := *incoming.SMART merged.SMART = &smart } - merged.Risk = physicalDiskRiskFromAssessment(physicalDiskAssessmentFromMeta(&merged)) + merged.Risk = mergePhysicalDiskRisk( + physicalDiskRiskFromAssessment(physicalDiskAssessmentFromMeta(&merged)), + existing.Risk, + incoming.Risk, + ) return &merged } +func shouldReplacePhysicalDiskHealth(existing, incoming string) bool { + incoming = strings.ToUpper(strings.TrimSpace(incoming)) + if incoming == "" { + return false + } + existing = strings.ToUpper(strings.TrimSpace(existing)) + switch incoming { + case "UNKNOWN": + return existing == "" + case "FAILED": + return true + case "PASSED", "OK": + return existing == "" || existing == "UNKNOWN" || existing == "PASSED" || existing == "OK" + default: + return true + } +} + +func mergePhysicalDiskRisk(base *PhysicalDiskRisk, risks ...*PhysicalDiskRisk) *PhysicalDiskRisk { + var merged *PhysicalDiskRisk + appendRisk := func(risk *PhysicalDiskRisk) { + if risk == nil { + return + } + if merged == nil { + merged = &PhysicalDiskRisk{Level: risk.Level} + } + if incidentSeverityRank(risk.Level) > incidentSeverityRank(merged.Level) { + merged.Level = risk.Level + } + seen := make(map[string]struct{}, len(merged.Reasons)) + for _, reason := range merged.Reasons { + seen[reason.Code+"\x00"+reason.Summary] = struct{}{} + } + for _, reason := range risk.Reasons { + key := reason.Code + "\x00" + reason.Summary + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + merged.Reasons = append(merged.Reasons, reason) + } + } + appendRisk(base) + for _, risk := range risks { + appendRisk(risk) + } + if merged == nil || (merged.Level == storagehealth.RiskHealthy && len(merged.Reasons) == 0) { + return nil + } + return merged +} + func mergeVMwareData(existing *VMwareData, incoming *VMwareData) *VMwareData { if existing == nil { return incoming diff --git a/internal/unifiedresources/registry_test.go b/internal/unifiedresources/registry_test.go index d8475b897..78ef4b1d4 100644 --- a/internal/unifiedresources/registry_test.go +++ b/internal/unifiedresources/registry_test.go @@ -1755,6 +1755,107 @@ func TestResourceRegistry_IngestSnapshotPropagatesUnraidDiskRole(t *testing.T) { } } +func TestResourceRegistry_IngestSnapshotCreatesUnraidDisksWithoutSMART(t *testing.T) { + rr := NewRegistry(nil) + now := time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC) + + rr.IngestSnapshot(models.StateSnapshot{ + Hosts: []models.Host{ + { + ID: "host-tower", + Hostname: "tower", + Status: "online", + LastSeen: now, + Disks: []models.Disk{ + {Mountpoint: "/mnt/user", Total: 10_000, Used: 7_000, Free: 3_000, Usage: 70}, + }, + Unraid: &models.HostUnraidStorage{ + ArrayStarted: true, + ArrayState: "STARTED", + Disks: []models.HostUnraidDisk{ + { + Name: "disk1", + Device: "/dev/sdb", + Role: "data", + Status: "online", + Model: "WDC WD60EFRX", + Serial: "DATA-1", + Filesystem: "xfs", + Transport: "sata", + SizeBytes: 6_000_000_000_000, + UsedBytes: 4_000, + FreeBytes: 2_000, + Temperature: 31, + SpunDown: true, + ReadCount: 11, + WriteCount: 12, + ErrorCount: 16, + }, + { + Name: "cachepool", + Device: "/dev/sdc", + Role: "cache", + Status: "online", + Model: "SSD 2000GB", + Serial: "CACHE-1", + Filesystem: "btrfs", + Transport: "sata", + SizeBytes: 2_000_000_000_000, + UsedBytes: 200, + FreeBytes: 800, + }, + }, + }, + }, + }, + }) + + disks := rr.ListByType(ResourceTypePhysicalDisk) + if len(disks) != 2 { + t.Fatalf("expected 2 Unraid physical disks, got %d: %+v", len(disks), disks) + } + bySerial := map[string]Resource{} + for _, disk := range disks { + if disk.PhysicalDisk != nil { + bySerial[disk.PhysicalDisk.Serial] = disk + } + } + data := bySerial["DATA-1"] + if data.PhysicalDisk == nil || data.PhysicalDisk.StorageRole != "data" || data.PhysicalDisk.StorageGroup != "unraid-array" { + t.Fatalf("expected data disk to belong to unraid array, got %+v", data.PhysicalDisk) + } + if data.PhysicalDisk.SizeBytes != 6_000_000_000_000 || data.PhysicalDisk.Temperature != 31 { + t.Fatalf("expected Unraid size/temp on data disk, got %+v", data.PhysicalDisk) + } + if !data.PhysicalDisk.SpunDown || data.PhysicalDisk.ReadCount != 11 || data.PhysicalDisk.WriteCount != 12 || data.PhysicalDisk.ErrorCount != 16 { + t.Fatalf("expected Unraid disk counters on data disk, got %+v", data.PhysicalDisk) + } + cache := bySerial["CACHE-1"] + if cache.PhysicalDisk == nil || cache.PhysicalDisk.StorageRole != "cache" || cache.PhysicalDisk.StorageGroup != "cachepool" { + t.Fatalf("expected cache disk to belong to cachepool, got %+v", cache.PhysicalDisk) + } + + storage := rr.ListByType(ResourceTypeStorage) + var sawArray, sawCache bool + for _, resource := range storage { + if resource.Storage == nil { + continue + } + switch resource.Storage.Type { + case "unraid-array": + sawArray = true + case "unraid-cache-pool": + sawCache = true + if resource.Name != "cachepool" || resource.Metrics == nil || resource.Metrics.Disk == nil || resource.Metrics.Disk.Percent != 20 { + t.Fatalf("unexpected cache storage resource: %+v", resource) + } + } + } + if !sawArray || !sawCache { + t.Fatalf("expected array and cache storage resources, got %+v", storage) + } +} + func TestResourceRegistry_IngestSnapshotCreatesUnraidStorageResource(t *testing.T) { rr := NewRegistry(nil) now := time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC) @@ -1850,6 +1951,54 @@ func TestResourceRegistry_IngestSnapshotParentsUnraidArrayDisksUnderStorage(t *t } } +func TestResourceRegistry_IngestSnapshotParentsInferredUnraidArrayDisksUnderStorage(t *testing.T) { + rr := NewRegistry(nil) + now := time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC) + + rr.IngestSnapshot(models.StateSnapshot{ + Hosts: []models.Host{ + { + ID: "host-tower", + Hostname: "tower", + Status: "online", + LastSeen: now, + MachineID: "machine-tower", + Unraid: &models.HostUnraidStorage{ + ArrayStarted: true, + Disks: []models.HostUnraidDisk{ + {Name: "md1p1", Device: "/dev/sde", Status: "online", Slot: 1}, + }, + }, + Sensors: models.HostSensorSummary{ + SMART: []models.HostDiskSMART{ + { + Device: "sde [sat]", + Model: "Array Disk", + Type: "sata", + Health: "UNKNOWN", + }, + }, + }, + }, + }, + }) + + storage := rr.ListByType(ResourceTypeStorage) + disks := rr.ListByType(ResourceTypePhysicalDisk) + if len(storage) != 1 || len(disks) != 1 { + t.Fatalf("expected 1 storage and 1 disk resource, got storage=%d disk=%d", len(storage), len(disks)) + } + if disks[0].ParentID == nil || *disks[0].ParentID != storage[0].ID { + t.Fatalf("expected inferred unraid disk parent to be storage resource %q, got %+v", storage[0].ID, disks[0].ParentID) + } + if disks[0].PhysicalDisk == nil || disks[0].PhysicalDisk.StorageRole != "data" || disks[0].PhysicalDisk.StorageGroup != "unraid-array" { + t.Fatalf("expected inferred unraid data disk metadata, got %+v", disks[0].PhysicalDisk) + } + if storage[0].ChildCount != 1 { + t.Fatalf("expected storage child count 1, got %d", storage[0].ChildCount) + } +} + func TestResourceRegistry_IngestSnapshotDerivesStorageConsumers(t *testing.T) { rr := NewRegistry(nil) now := time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC) diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index 4381f42a6..83db083ca 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -357,6 +357,10 @@ type PhysicalDiskMeta struct { StorageRole string `json:"storageRole,omitempty"` StorageGroup string `json:"storageGroup,omitempty"` StorageState string `json:"storageState,omitempty"` + SpunDown bool `json:"spunDown,omitempty"` + ReadCount int64 `json:"readCount,omitempty"` + WriteCount int64 `json:"writeCount,omitempty"` + ErrorCount int64 `json:"errorCount,omitempty"` SMART *SMARTMeta `json:"smart,omitempty"` Risk *PhysicalDiskRisk `json:"risk,omitempty"` } @@ -487,15 +491,24 @@ type HostRAIDMeta struct { // HostUnraidDiskMeta describes a disk's role and state inside an Unraid array. type HostUnraidDiskMeta struct { - Name string `json:"name"` - Device string `json:"device,omitempty"` - Role string `json:"role,omitempty"` - Status string `json:"status,omitempty"` - RawStatus string `json:"rawStatus,omitempty"` - Serial string `json:"serial,omitempty"` - Filesystem string `json:"filesystem,omitempty"` - SizeBytes int64 `json:"sizeBytes,omitempty"` - Slot int `json:"slot,omitempty"` + Name string `json:"name"` + Device string `json:"device,omitempty"` + Role string `json:"role,omitempty"` + Status string `json:"status,omitempty"` + RawStatus string `json:"rawStatus,omitempty"` + Model string `json:"model,omitempty"` + Serial string `json:"serial,omitempty"` + Filesystem string `json:"filesystem,omitempty"` + Transport string `json:"transport,omitempty"` + SizeBytes int64 `json:"sizeBytes,omitempty"` + UsedBytes int64 `json:"usedBytes,omitempty"` + FreeBytes int64 `json:"freeBytes,omitempty"` + Temperature int `json:"temperature,omitempty"` + SpunDown bool `json:"spunDown,omitempty"` + ReadCount int64 `json:"readCount,omitempty"` + WriteCount int64 `json:"writeCount,omitempty"` + ErrorCount int64 `json:"errorCount,omitempty"` + Slot int `json:"slot,omitempty"` } // HostUnraidMeta describes Unraid array topology from a host agent. diff --git a/pkg/agents/host/report.go b/pkg/agents/host/report.go index 663cfdedd..243637b3e 100644 --- a/pkg/agents/host/report.go +++ b/pkg/agents/host/report.go @@ -190,15 +190,24 @@ type UnraidStorage struct { // UnraidDisk represents a disk's role and state inside an Unraid array. type UnraidDisk struct { - Name string `json:"name"` - Device string `json:"device,omitempty"` - Role string `json:"role,omitempty"` - Status string `json:"status,omitempty"` - RawStatus string `json:"rawStatus,omitempty"` - Serial string `json:"serial,omitempty"` - Filesystem string `json:"filesystem,omitempty"` - SizeBytes int64 `json:"sizeBytes,omitempty"` - Slot int `json:"slot,omitempty"` + Name string `json:"name"` + Device string `json:"device,omitempty"` + Role string `json:"role,omitempty"` + Status string `json:"status,omitempty"` + RawStatus string `json:"rawStatus,omitempty"` + Model string `json:"model,omitempty"` + Serial string `json:"serial,omitempty"` + Filesystem string `json:"filesystem,omitempty"` + Transport string `json:"transport,omitempty"` + SizeBytes int64 `json:"sizeBytes,omitempty"` + UsedBytes int64 `json:"usedBytes,omitempty"` + FreeBytes int64 `json:"freeBytes,omitempty"` + Temperature int `json:"temperature,omitempty"` + SpunDown bool `json:"spunDown,omitempty"` + ReadCount int64 `json:"readCount,omitempty"` + WriteCount int64 `json:"writeCount,omitempty"` + ErrorCount int64 `json:"errorCount,omitempty"` + Slot int `json:"slot,omitempty"` } // CephCluster represents Ceph cluster status collected by the host agent. diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py index 9e166d1bd..47485eec6 100644 --- a/scripts/release_control/canonical_completion_guard_test.py +++ b/scripts/release_control/canonical_completion_guard_test.py @@ -147,7 +147,6 @@ def first_matching_policy_id(rule: dict, rel: str) -> str: RECOVERY_PRODUCT_SURFACE_EXACT_FILES = [ - "frontend-modern/src/components/Recovery/RecoverySummary.test.tsx", "frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx", "frontend-modern/src/pages/__tests__/Recovery.test.tsx", "frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts", @@ -1407,36 +1406,11 @@ class CanonicalCompletionGuardTest(unittest.TestCase): ], ) - def test_recovery_summary_presentation_change_requires_storage_recovery_contract(self): + def test_retired_recovery_summary_presentation_stays_unowned(self): required = infer_impacted_subsystems( ["frontend-modern/src/utils/recoverySummaryPresentation.ts"] ) - self.assertEqual(set(required), {"storage-recovery"}) - - recovery = required["storage-recovery"] - self.assertEqual( - recovery["contract"], - "docs/release-control/v6/internal/subsystems/storage-recovery.md", - ) - self.assertEqual( - recovery["touched_runtime_files"], - ["frontend-modern/src/utils/recoverySummaryPresentation.ts"], - ) - self.assertEqual( - recovery["verification_requirements"], - [ - { - "id": "recovery-product-surface", - "label": "recovery product surface proof", - "touched_runtime_files": [ - "frontend-modern/src/utils/recoverySummaryPresentation.ts" - ], - "allow_same_subsystem_tests": False, - "test_prefixes": [], - "exact_files": RECOVERY_PRODUCT_SURFACE_EXACT_FILES, - } - ], - ) + self.assertEqual(required, {}) def test_recovery_record_presentation_change_requires_storage_recovery_contract(self): required = infer_impacted_subsystems( diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 93401f703..98743024e 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -6,7 +6,6 @@ from subsystem_lookup import lookup_paths, parse_args, render_pretty RECOVERY_PRODUCT_SURFACE_EXACT_FILES = [ - "frontend-modern/src/components/Recovery/RecoverySummary.test.tsx", "frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx", "frontend-modern/src/pages/__tests__/Recovery.test.tsx", "frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts", @@ -1026,27 +1025,14 @@ class SubsystemLookupTest(unittest.TestCase): RECOVERY_PRODUCT_SURFACE_EXACT_FILES, ) - def test_lookup_paths_assigns_recovery_summary_presentation_to_storage_recovery(self) -> None: - result = lookup_paths(["frontend-modern/src/utils/recoverySummaryPresentation.ts"]) - self.assertEqual(result["unowned_runtime_files"], []) - self.assertEqual( - {item["subsystem"] for item in result["impacted_subsystems"]}, - {"storage-recovery"}, - ) + def test_lookup_paths_reports_retired_recovery_summary_presentation_as_unowned(self) -> None: + path = "frontend-modern/src/utils/recoverySummaryPresentation.ts" + result = lookup_paths([path]) + self.assertEqual(result["unowned_runtime_files"], [path]) + self.assertEqual(result["impacted_subsystems"], []) file_entry = result["files"][0] self.assertEqual(file_entry["classification"], "runtime") - self.assertEqual( - {match["subsystem"] for match in file_entry["matches"]}, - {"storage-recovery"}, - ) - match = file_entry["matches"][0] - self.assertEqual(match["contract"], "docs/release-control/v6/internal/subsystems/storage-recovery.md") - self.assertEqual(match["lane_context"]["lane_id"], "L15") - self.assertEqual(match["verification_requirement"]["id"], "recovery-product-surface") - self.assertEqual( - match["verification_requirement"]["exact_files"], - RECOVERY_PRODUCT_SURFACE_EXACT_FILES, - ) + self.assertEqual(file_entry["matches"], []) def test_lookup_paths_assigns_recovery_record_presentation_to_storage_recovery(self) -> None: result = lookup_paths(["frontend-modern/src/utils/recoveryRecordPresentation.ts"])