mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 18:44:53 +00:00
Clarify storage topology and recovery guards
This commit is contained in:
parent
4622df523d
commit
3580a5ed6d
58 changed files with 2670 additions and 575 deletions
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()}>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 '—';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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...';
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
)
|
||||
|
||||
const hostAgentUnraidVersionPath = "/etc/unraid-version"
|
||||
const hostAgentUnraidDisksINIPath = "/var/local/emhttp/disks.ini"
|
||||
|
||||
var commonUnraidMdcmdPaths = []string{
|
||||
"/usr/local/sbin/mdcmd",
|
||||
|
|
@ -51,7 +52,14 @@ func CollectUnraidStorage(ctx context.Context, collector SystemCollector) (*agen
|
|||
return nil, fmt.Errorf("run mdcmd status: %w", err)
|
||||
}
|
||||
|
||||
return parseUnraidStatusOutput(output)
|
||||
storage, err := parseUnraidStatusOutput(output)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data, readErr := collector.ReadFile(hostAgentUnraidDisksINIPath); readErr == nil {
|
||||
storage = mergeUnraidDiskINI(storage, parseUnraidDisksINI(string(data)))
|
||||
}
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
func resolveUnraidMdcmdBinary(collector SystemCollector) (string, error) {
|
||||
|
|
@ -101,7 +109,7 @@ func parseUnraidStatusOutput(output string) (*agentshost.UnraidStorage, error) {
|
|||
RawStatus: strings.TrimSpace(firstNonEmpty(fields[fmt.Sprintf("rdevStatus.%d", idx)], fields[fmt.Sprintf("diskState.%d", idx)])),
|
||||
Serial: strings.TrimSpace(firstNonEmpty(fields[fmt.Sprintf("rdevSerial.%d", idx)], fields[fmt.Sprintf("diskSerial.%d", idx)], fields[fmt.Sprintf("rdevId.%d", idx)], fields[fmt.Sprintf("diskId.%d", idx)])),
|
||||
Filesystem: strings.TrimSpace(firstNonEmpty(fields[fmt.Sprintf("diskFsType.%d", idx)], fields[fmt.Sprintf("fsType.%d", idx)])),
|
||||
SizeBytes: parseFirstInt64(fields[fmt.Sprintf("diskSize.%d", idx)], fields[fmt.Sprintf("rdevSize.%d", idx)]),
|
||||
SizeBytes: parseUnraidKiBAsBytes(firstNonEmpty(fields[fmt.Sprintf("diskSize.%d", idx)], fields[fmt.Sprintf("rdevSize.%d", idx)])),
|
||||
Slot: idx,
|
||||
}
|
||||
disk.Role = inferUnraidDiskRole(disk.Name, idx)
|
||||
|
|
@ -144,6 +152,180 @@ func parseUnraidKeyValueOutput(output string) map[string]string {
|
|||
return fields
|
||||
}
|
||||
|
||||
type unraidINISection struct {
|
||||
name string
|
||||
fields map[string]string
|
||||
}
|
||||
|
||||
func parseUnraidDisksINI(input string) []agentshost.UnraidDisk {
|
||||
sections := parseUnraidINISections(input)
|
||||
disks := make([]agentshost.UnraidDisk, 0, len(sections))
|
||||
for _, section := range sections {
|
||||
if section.name == "" {
|
||||
continue
|
||||
}
|
||||
fields := section.fields
|
||||
idx := parseUnraidIntField(fields, "idx")
|
||||
name := firstNonEmpty(fields["name"], section.name)
|
||||
role := normalizeUnraidDiskRole(firstNonEmpty(fields["type"], inferUnraidDiskRole(name, idx)))
|
||||
device := normalizeBlockDevice(fields["device"])
|
||||
rawStatus := strings.TrimSpace(fields["status"])
|
||||
disk := agentshost.UnraidDisk{
|
||||
Name: strings.TrimSpace(name),
|
||||
Device: device,
|
||||
Role: role,
|
||||
Status: normalizeUnraidDiskStatus(rawStatus, device),
|
||||
RawStatus: rawStatus,
|
||||
Filesystem: strings.TrimSpace(fields["fsType"]),
|
||||
Transport: normalizeUnraidTransport(fields["transport"], fields["rotational"]),
|
||||
SizeBytes: parseUnraidSizeBytes(fields),
|
||||
UsedBytes: parseUnraidKiBAsBytes(fields["fsUsed"]),
|
||||
FreeBytes: parseUnraidKiBAsBytes(fields["fsFree"]),
|
||||
Temperature: parseUnraidTemperature(fields["temp"]),
|
||||
SpunDown: parseUnraidBool(fields["spundown"]),
|
||||
ReadCount: parseFirstInt64(fields["numReads"]),
|
||||
WriteCount: parseFirstInt64(fields["numWrites"]),
|
||||
ErrorCount: parseFirstInt64(fields["numErrors"]),
|
||||
Slot: idx,
|
||||
}
|
||||
disk.Model, disk.Serial = parseUnraidDiskIdentity(firstNonEmpty(fields["id"], fields["idSb"]))
|
||||
if disk.Serial == "" {
|
||||
disk.Serial = strings.TrimSpace(firstNonEmpty(fields["id"], fields["idSb"]))
|
||||
}
|
||||
if isUnraidEmptySlot(disk) {
|
||||
continue
|
||||
}
|
||||
if disk.Name == "" {
|
||||
disk.Name = defaultUnraidDiskName(disk.Role, idx)
|
||||
}
|
||||
if disk.Name == "" && disk.Device == "" && disk.RawStatus == "" && disk.Serial == "" && disk.SizeBytes == 0 {
|
||||
continue
|
||||
}
|
||||
disks = append(disks, disk)
|
||||
}
|
||||
return disks
|
||||
}
|
||||
|
||||
func parseUnraidINISections(input string) []unraidINISection {
|
||||
lines := strings.Split(input, "\n")
|
||||
sections := make([]unraidINISection, 0)
|
||||
var current *unraidINISection
|
||||
for _, rawLine := range lines {
|
||||
line := strings.TrimSpace(rawLine)
|
||||
if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
name := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "["), "]"))
|
||||
name = strings.Trim(name, `"`)
|
||||
sections = append(sections, unraidINISection{name: name, fields: make(map[string]string)})
|
||||
current = §ions[len(sections)-1]
|
||||
continue
|
||||
}
|
||||
if current == nil || !strings.Contains(line, "=") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
if unquoted, err := strconv.Unquote(value); err == nil {
|
||||
value = unquoted
|
||||
}
|
||||
if key != "" {
|
||||
current.fields[key] = value
|
||||
}
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
func mergeUnraidDiskINI(storage *agentshost.UnraidStorage, iniDisks []agentshost.UnraidDisk) *agentshost.UnraidStorage {
|
||||
if storage == nil {
|
||||
storage = &agentshost.UnraidStorage{}
|
||||
}
|
||||
if len(iniDisks) == 0 {
|
||||
return storage
|
||||
}
|
||||
|
||||
merged := make([]agentshost.UnraidDisk, 0, len(storage.Disks)+len(iniDisks))
|
||||
bySlot := make(map[int]int, len(storage.Disks))
|
||||
for _, disk := range storage.Disks {
|
||||
bySlot[disk.Slot] = len(merged)
|
||||
merged = append(merged, disk)
|
||||
}
|
||||
|
||||
for _, disk := range iniDisks {
|
||||
if pos, ok := bySlot[disk.Slot]; ok {
|
||||
merged[pos] = mergeUnraidDisk(merged[pos], disk)
|
||||
continue
|
||||
}
|
||||
bySlot[disk.Slot] = len(merged)
|
||||
merged = append(merged, disk)
|
||||
}
|
||||
|
||||
sort.SliceStable(merged, func(i, j int) bool {
|
||||
return merged[i].Slot < merged[j].Slot
|
||||
})
|
||||
storage.Disks = merged
|
||||
return storage
|
||||
}
|
||||
|
||||
func mergeUnraidDisk(base, incoming agentshost.UnraidDisk) agentshost.UnraidDisk {
|
||||
out := base
|
||||
if strings.TrimSpace(incoming.Name) != "" {
|
||||
out.Name = strings.TrimSpace(incoming.Name)
|
||||
}
|
||||
if strings.TrimSpace(incoming.Device) != "" {
|
||||
out.Device = strings.TrimSpace(incoming.Device)
|
||||
}
|
||||
if strings.TrimSpace(incoming.Role) != "" {
|
||||
out.Role = strings.TrimSpace(incoming.Role)
|
||||
}
|
||||
if strings.TrimSpace(incoming.Status) != "" {
|
||||
out.Status = strings.TrimSpace(incoming.Status)
|
||||
}
|
||||
if strings.TrimSpace(incoming.RawStatus) != "" {
|
||||
out.RawStatus = strings.TrimSpace(incoming.RawStatus)
|
||||
}
|
||||
if strings.TrimSpace(incoming.Model) != "" {
|
||||
out.Model = strings.TrimSpace(incoming.Model)
|
||||
}
|
||||
if strings.TrimSpace(incoming.Serial) != "" {
|
||||
out.Serial = strings.TrimSpace(incoming.Serial)
|
||||
}
|
||||
if strings.TrimSpace(incoming.Filesystem) != "" {
|
||||
out.Filesystem = strings.TrimSpace(incoming.Filesystem)
|
||||
}
|
||||
if strings.TrimSpace(incoming.Transport) != "" {
|
||||
out.Transport = strings.TrimSpace(incoming.Transport)
|
||||
}
|
||||
if incoming.SizeBytes > 0 {
|
||||
out.SizeBytes = incoming.SizeBytes
|
||||
}
|
||||
if incoming.UsedBytes > 0 {
|
||||
out.UsedBytes = incoming.UsedBytes
|
||||
}
|
||||
if incoming.FreeBytes > 0 {
|
||||
out.FreeBytes = incoming.FreeBytes
|
||||
}
|
||||
if incoming.Temperature > 0 {
|
||||
out.Temperature = incoming.Temperature
|
||||
}
|
||||
out.SpunDown = incoming.SpunDown
|
||||
if incoming.ReadCount > 0 {
|
||||
out.ReadCount = incoming.ReadCount
|
||||
}
|
||||
if incoming.WriteCount > 0 {
|
||||
out.WriteCount = incoming.WriteCount
|
||||
}
|
||||
if incoming.ErrorCount > 0 {
|
||||
out.ErrorCount = incoming.ErrorCount
|
||||
}
|
||||
if incoming.Slot > 0 || out.Slot == 0 {
|
||||
out.Slot = incoming.Slot
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func collectUnraidIndexes(fields map[string]string) []int {
|
||||
indexes := make(map[int]struct{})
|
||||
prefixes := []string{
|
||||
|
|
@ -204,11 +386,29 @@ func inferUnraidDiskRole(name string, idx int) string {
|
|||
return "flash"
|
||||
case idx == 0:
|
||||
return "parity"
|
||||
case idx == 29:
|
||||
return "parity"
|
||||
case idx > 0 && idx < 29:
|
||||
return "data"
|
||||
case idx >= 30:
|
||||
return "cache"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeUnraidDiskRole(role string) string {
|
||||
role = strings.ToLower(strings.TrimSpace(role))
|
||||
switch role {
|
||||
case "parity", "data", "cache", "flash":
|
||||
return role
|
||||
case "array":
|
||||
return "data"
|
||||
default:
|
||||
return role
|
||||
}
|
||||
}
|
||||
|
||||
func defaultUnraidDiskName(role string, idx int) string {
|
||||
switch role {
|
||||
case "parity":
|
||||
|
|
@ -326,6 +526,72 @@ func parseFirstInt64(values ...string) int64 {
|
|||
return 0
|
||||
}
|
||||
|
||||
func parseUnraidKiBAsBytes(value string) int64 {
|
||||
parsed := parseFirstInt64(value)
|
||||
if parsed <= 0 {
|
||||
return 0
|
||||
}
|
||||
return parsed * 1024
|
||||
}
|
||||
|
||||
func parseUnraidSizeBytes(fields map[string]string) int64 {
|
||||
sectors := parseFirstInt64(fields["sectors"])
|
||||
sectorSize := parseFirstInt64(fields["sector_size"])
|
||||
if sectors > 0 && sectorSize > 0 {
|
||||
return sectors * sectorSize
|
||||
}
|
||||
return parseUnraidKiBAsBytes(firstNonEmpty(fields["size"], fields["sizeSb"]))
|
||||
}
|
||||
|
||||
func parseUnraidTemperature(value string) int {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || value == "*" || strings.EqualFold(value, "na") {
|
||||
return 0
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil || parsed < 0 {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func parseUnraidBool(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeUnraidTransport(transport string, rotational string) string {
|
||||
transport = strings.ToLower(strings.TrimSpace(transport))
|
||||
switch transport {
|
||||
case "ata":
|
||||
return "sata"
|
||||
case "nvme", "sata", "sas", "usb":
|
||||
return transport
|
||||
}
|
||||
if strings.TrimSpace(rotational) == "0" {
|
||||
return "ssd"
|
||||
}
|
||||
return transport
|
||||
}
|
||||
|
||||
func parseUnraidDiskIdentity(id string) (string, string) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return "", ""
|
||||
}
|
||||
idx := strings.LastIndex(id, "_")
|
||||
if idx <= 0 || idx >= len(id)-1 {
|
||||
return strings.ReplaceAll(id, "_", " "), ""
|
||||
}
|
||||
model := strings.ReplaceAll(strings.TrimSpace(id[:idx]), "_", " ")
|
||||
serial := strings.TrimSpace(id[idx+1:])
|
||||
return model, serial
|
||||
}
|
||||
|
||||
func parseFloatField(fields map[string]string, key string) (float64, bool) {
|
||||
value := strings.TrimSpace(fields[key])
|
||||
if value == "" {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue