Clarify storage topology and recovery guards

This commit is contained in:
rcourtman 2026-05-14 20:51:32 +01:00
parent 4622df523d
commit 3580a5ed6d
58 changed files with 2670 additions and 575 deletions

View file

@ -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": [

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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=<value>` route form, with legacy `stale=1` accepted only as compatibility input
14. Reintroducing stacked full-width recovery tables as the primary desktop layout; the governed recovery surface must expose one primary data region at a time with 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

View file

@ -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

View file

@ -88,7 +88,7 @@ const Storage: Component = () => {
<div ref={setClearSurfaceRootRef} class="space-y-4" data-testid="storage-page">
<PageHeader
title="Storage"
description="Review capacity, node health, pools, and storage pressure across connected clusters and devices."
description="Review capacity, topology, protection, and physical media across connected storage platforms."
/>
<Show when={!storageSummaryCollapsed()}>

View file

@ -48,11 +48,18 @@ interface StoragePoolDetailProps {
}
export const StoragePoolDetail: Component<StoragePoolDetailProps> = (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<StoragePoolDetailProps> = (props) => {
{/* Right: Configuration & details */}
<div class={STORAGE_DETAIL_SPACED_STACK_CLASS}>
<Show when={topologyRows().length > 0}>
<div class={STORAGE_DETAIL_CARD_CLASS}>
<h4 class={STORAGE_DETAIL_SECTION_TITLE_SPACED_CLASS}>Topology</h4>
<div class={STORAGE_DETAIL_CONFIG_GRID_CLASS}>
<For each={topologyRows()}>
{(row) => <StorageDetailKeyValueRow label={row.label} value={row.value} />}
</For>
</div>
</div>
</Show>
{/* Config card */}
<div class={STORAGE_DETAIL_CARD_CLASS}>
<h4 class={STORAGE_DETAIL_SECTION_TITLE_SPACED_CLASS}>Configuration</h4>
@ -154,6 +172,28 @@ export const StoragePoolDetail: Component<StoragePoolDetailProps> = (props) => {
class={`flex-shrink-0 ${getLinkedDiskHealthDotClass(disk.hasIssue)}`}
/>
<span class={STORAGE_DETAIL_LINKED_DISK_MODEL_CLASS}>{disk.model}</span>
<Show when={disk.role}>
<span class="flex-shrink-0 rounded bg-surface-alt px-1.5 py-0.5 text-[10px] uppercase text-muted">
{disk.role}
</span>
</Show>
<Show when={disk.sizeLabel}>
<span class="flex-shrink-0 font-mono text-muted">{disk.sizeLabel}</span>
</Show>
<Show when={disk.state}>
<span class="flex-shrink-0 text-muted">{disk.state}</span>
</Show>
<Show when={disk.ioLabel}>
<span class="flex-shrink-0 font-mono text-muted">{disk.ioLabel}</span>
</Show>
<Show when={disk.spunDown}>
<span class="flex-shrink-0 text-muted">spun down</span>
</Show>
<Show when={disk.errorCount > 0}>
<span class="flex-shrink-0 font-semibold text-amber-700 dark:text-amber-300">
{disk.errorCount.toLocaleString()} errors
</span>
</Show>
<Show when={disk.temperature > 0}>
<span
class={`font-medium ${getLinkedDiskTemperatureTextClass(

View file

@ -1,9 +1,6 @@
import { Component, Show, createMemo } from 'solid-js';
import type { StorageRecord } from '@/features/storageBackups/models';
import {
getStoragePoolIssueTextClass,
getStoragePoolProtectionTextClass,
} from '@/features/storageBackups/rowPresentation';
import { getStoragePoolProtectionTextClass } from '@/features/storageBackups/rowPresentation';
import {
buildStoragePoolRowModel,
STORAGE_POOL_ROW_GROWTH_CELL_CLASS,
@ -101,14 +98,14 @@ export const StoragePoolRow: Component<StoragePoolRowProps> = (props) => {
<td class={STORAGE_POOL_ROW_ISSUE_CELL_CLASS}>
<Show
when={row().compactIssue !== '—'}
when={row().stateLabel !== '—'}
fallback={<span class={STORAGE_POOL_ROW_PLACEHOLDER_CLASS}>—</span>}
>
<span
class={`${STORAGE_POOL_ROW_ISSUE_TEXT_CLASS} ${getStoragePoolIssueTextClass(props.record)}`}
title={row().compactIssueSummary || row().compactIssue}
class={`${STORAGE_POOL_ROW_ISSUE_TEXT_CLASS} ${row().stateToneClass}`}
title={row().stateTitle || row().stateLabel}
>
{row().compactIssue}
{row().stateLabel}
</span>
</Show>
</td>
@ -171,7 +168,6 @@ export const StoragePoolRow: Component<StoragePoolRowProps> = (props) => {
{row().capacityDeltaLabel}
</span>
</td>
</tr>
<Show when={props.expanded}>
<StoragePoolDetail

View file

@ -11,11 +11,7 @@ import { SummaryPanel } from '@/components/shared/SummaryPanel';
import { SummaryMetricCard } from '@/components/shared/SummaryMetricCard';
import { SummarySynchronizedReadout } from '@/components/shared/SummarySynchronizedReadout';
import { buildInteractiveSparklineSynchronizedReadout } from '@/components/shared/interactiveSparklineModel';
import {
type MetricPoint,
type TimeRange,
type StorageSummaryChartsResponse,
} from '@/api/charts';
import { type MetricPoint, type TimeRange, type StorageSummaryChartsResponse } from '@/api/charts';
import {
SUMMARY_TIME_RANGE_LABEL,
type SummaryTimeRange,
@ -125,10 +121,10 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
}));
});
const interactiveSummarySeries = createMemo<InteractiveSparklineSeries[]>(() => [
...allPoolUsageSeries(),
...allPoolUsedSeries(),
...allPoolAvailSeries(),
...allDiskTempSeries(),
...allPoolUsageSeries(),
...allPoolUsedSeries(),
...allPoolAvailSeries(),
...allDiskTempSeries(),
]);
const summaryFocus = useSummaryContextualFocusState({
chartHoveredSeriesId: () => chartHoverSync()?.seriesId ?? null,
@ -214,6 +210,12 @@ const StorageSummary: Component<StorageSummaryProps> = (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<StorageSummaryProps> = (props) => {
testId="storage-summary"
headerLeft={
<>
<span class="font-medium text-base-content">
{props.poolCount} {props.poolCount === 1 ? 'pool' : 'pools'}
</span>
<span class="font-medium text-base-content">{storageItemCountLabel()}</span>
<Show
when={(props.poolsDegraded ?? 0) > 0 || (props.disksFailing ?? 0) > 0}
fallback={
<>
<Show when={props.poolCount > 0 || props.diskCount > 0}>
<span class="text-emerald-600 dark:text-emerald-400">all healthy</span>
<span class="text-emerald-600 dark:text-emerald-400">all online</span>
</Show>
<Show when={props.diskCount > 0}>
<span class="text-muted">
@ -293,17 +293,17 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
when={props.onScopeToDegradedPools}
fallback={
<span class="text-amber-600 dark:text-amber-400">
{props.poolsDegraded} degraded
{storageAttentionLabel()}
</span>
}
>
<button
type="button"
onClick={() => props.onScopeToDegradedPools?.()}
title="Show degraded pools"
title="Show storage needing attention"
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.poolsDegraded} degraded
{storageAttentionLabel()}
</button>
</Show>
</Show>
@ -312,7 +312,8 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
when={props.onScopeToFailingDisks}
fallback={
<span class="text-amber-600 dark:text-amber-400">
{props.disksFailing} {props.disksFailing === 1 ? 'disk failing' : 'disks failing'}
{props.disksFailing}{' '}
{props.disksFailing === 1 ? 'disk failing' : 'disks failing'}
</span>
}
>
@ -322,7 +323,8 @@ const StorageSummary: Component<StorageSummaryProps> = (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'}
</button>
</Show>
</Show>
@ -336,7 +338,7 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
onTimeRangeChange={props.onTimeRangeChange}
>
<SummaryMetricCard
label="Pool Usage"
label="Storage Usage"
secondaryLabel={focusedLabel(poolUsageSeries())}
headerValue={renderSyncedReadout(poolUsageSyncedReadout())}
loaded={props.loaded}

View file

@ -603,7 +603,7 @@ describe('Storage', () => {
});
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(() => <Storage />);
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',
);

View file

@ -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'],
};

View file

@ -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(() => (
<table>
<tbody>
<StoragePoolDetail
record={makeRecord({
name: 'Tower Array',
source: {
platform: 'unraid',
family: 'onprem',
origin: 'resource',
adapterId: 'resource-storage',
},
details: {
type: 'unraid-array',
platform: 'unraid',
topology: 'array',
arrayState: 'STARTED',
},
})}
physicalDisks={
[
{
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_000_000_000,
temperature: 31,
spunDown: true,
readCount: 10,
writeCount: 20,
errorCount: 16,
},
},
] as Resource[]
}
summarySeriesId="tower-array"
/>
</tbody>
</table>
));
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();
});
});

View file

@ -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', () => {
}
});
});
});

View file

@ -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<StorageRecord[]>([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<Resource[]>([
{
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);
});
});

View file

@ -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,
};

View file

@ -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',

View file

@ -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');
});

View file

@ -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', () => {

View file

@ -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',

View file

@ -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<Resource>),
],
});
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({

View file

@ -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(

View file

@ -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' },
]),
);
});
});

View file

@ -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');

View file

@ -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';

View file

@ -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';

View file

@ -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);

View file

@ -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,
};

View file

@ -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';

View file

@ -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<string, unknown> =>
(record.details || {}) as Record<string, unknown>;
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 '—';

View file

@ -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,
},
};
};

View file

@ -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...';

View file

@ -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<string, unknown> =>
(record.details || {}) as Record<string, unknown>;
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<string, unknown>)?.physicalDisk as
| Record<string, unknown>
const readPhysicalDisk = (disk: Resource): NonNullable<Resource['physicalDisk']> => {
const platformData = (disk.platformData || {}) as Record<string, unknown>;
const platformDisk = platformData.physicalDisk as
| NonNullable<Resource['physicalDisk']>
| undefined;
return typeof physicalDisk?.devPath === 'string' ? physicalDisk.devPath : '';
return (disk.physicalDisk || platformDisk || {}) as NonNullable<Resource['physicalDisk']>;
};
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<string, unknown>)?.physicalDisk as
| Record<string, unknown>
| 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<string, unknown>)?.physicalDisk as
| Record<string, unknown>
| 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<string, unknown>)?.physicalDisk as
| Record<string, unknown>
| undefined;
const smart = physicalDisk?.smart as Record<string, unknown> | undefined;
const physicalDisk = readPhysicalDisk(disk);
const smart = physicalDisk.smart as Record<string, unknown> | 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);
});
}

View file

@ -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,

View file

@ -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 = {

View file

@ -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;
}

View file

@ -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<string, string>',
);
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<ArtifactMode, string>');
expect(recoverySource).not.toContain('const MODE_BADGE_CLASS: Record<ArtifactMode, string>');
@ -1070,7 +1069,7 @@ describe('frontend resource type boundaries', () => {
expect(recoverySource).not.toContain(
"const ISSUE_RAIL_CLASS: Record<Exclude<IssueTone, 'none'>, 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("<Show when={workspaceView() === 'events'}>"),
);
expect(recoverySource).toContain('getRecoveryFilterChipPresentation');
expect(recoverySource).not.toContain('getRecoveryFilterChipPresentation');
expect(recoverySource).not.toContain('const titleize =');
expect(recoverySummarySource).toContain('<SummaryPanel');
expect(recoverySummarySource).toContain('timeRange={props.timeRange()}');
expect(recoverySummarySource).not.toContain('segmentedButtonClass(');
expect(recoverySource).not.toContain('<SummaryPanel');
expect(recoverySource).not.toContain('SummaryMetricCard');
expect(recoveryActivitySectionSource).toContain('onRangeChange');
expect(recoveryActivitySectionSource).not.toContain('segmentedButtonClass(');
expect(recoverySource).not.toContain('border-blue-200 bg-blue-50 px-2 py-0.5');
expect(recoverySource).not.toContain('border-cyan-200 bg-cyan-50 px-2 py-0.5');
expect(recoverySource).not.toContain('border-emerald-200 bg-emerald-50 px-2 py-0.5');
@ -1165,7 +1165,9 @@ describe('frontend resource type boundaries', () => {
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',
);

View file

@ -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
})

View file

@ -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 = &sections[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 == "" {

View file

@ -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()

View file

@ -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.

View file

@ -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 {

View file

@ -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,
})
}
}

View file

@ -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{

View file

@ -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) {

View file

@ -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 != "" {

View file

@ -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)
}
}

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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)
}

View file

@ -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

View file

@ -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)

View file

@ -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.

View file

@ -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.

View file

@ -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(

View file

@ -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"])