diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 5e53c139ca..802d3c025f 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -1475,7 +1475,11 @@ not a replacement status card, CTA band, or page-local nested card. must distinguish inventory datastores from backup repositories so VMware rows on the shared storage route stay canonical to the admitted phase-1 floor instead of reviving backup-target, protected-target, or recovery-local semantics on a - shared page. + shared page. Those presenters must also source ZFS pool health from the + canonical `details.zfsPool` payload (meta-first `storage.zfsPool`, flat + `platformData.zfsPool` fallback) when building pool detail and bar + summaries, rather than re-deriving device-level health from risk-reason + strings or presenting flattened pool-state scalars as the full report. 30. Keep infrastructure settings-shell API alternatives on the shared shell contract. `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/settingsHeaderMeta.ts`, and diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 7ef12008f9..03755ef4ce 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -431,7 +431,12 @@ state remains a separate `unraid_sync_active` reason. Realtime resource broadcasts must preserve canonical identity, discovery target, metrics target, incident rollups, and raw `agent`/`storage` facet payloads so frontend infrastructure surfaces can explain degraded/warning rows without falling back -to generic status labels. That realtime broadcast contract also owns source and +to generic status labels. Storage platform-data payloads built by +`monitorStoragePlatformData` must carry the full `zfsPool` report (scan +activity and per-device states/errors/messages) alongside the flattened +`zfsPoolState`/error scalars whenever canonical `StorageMeta.ZFSPool` is +present, so `/api/state` and websocket consumers can render device-level ZFS +health in parity with the unified-resources read path. That realtime broadcast contract also owns source and platform identity for storage resources: `internal/monitoring/monitor.go` must carry the canonical `Resource.Sources` array onto `ResourceFrontend` and `platformData.sources`, and must derive storage `platformType` from the owning diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 3e4a296d2c..ea19b65c9b 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -1502,6 +1502,18 @@ chart entitlement sequence. They must expose `14d` between `7d` and `30d` and pass the selected range through to `HistoryChart` unchanged, rather than inventing storage-local range catalogs, paid-tier labels, or alternate metrics-history gating. +The storage pool detail's ZFS Pool card is the canonical home for +device-level ZFS health. +`frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts` +builds the pool summary (state, scan activity, pool error totals) and the +per-device report (name, vdev type, state, R/W/C error counts, message) from +the record's `details.zfsPool`, which +`frontend-modern/src/features/storageBackups/storageAdapters.ts` and +`resourceStorageMapping.ts` resolve meta-first from canonical +`storage.zfsPool` with flat `platformData.zfsPool` fallback. +`frontend-modern/src/components/Storage/StoragePoolDetail.tsx` renders that +report inside the row expansion so degraded pools name the failing device and +running scrub/resilver without re-promoting per-device noise into table rows. Shared chart transport that storage and recovery coexist with must also stay on rendered-metric budgets. When `internal/api/router.go` batches workload history for adjacent overview or shared summary cards, it may parallelize the diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 005d668ba0..c07cd60ff2 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -826,7 +826,13 @@ AI-only summary payloads, or page-local heuristics. `frontend-modern/src/hooks/useUnifiedResources.ts` must carry the provider-reported storage `pool` metadata alongside path and ZFS health so storage consumers do not have to recover backing-pool identity from names - or path heuristics. + or path heuristics. ZFS health means the full pool report: + `StorageMeta.ZFSPool` carries the provider's pool object (scan activity + plus per-device states, error counters, and messages) to consumers + alongside the flattened `zfsPoolState`/`zfs*Errors` scalars, so storage + surfaces can show which device failed and whether a scrub/resilver is + running instead of reducing pool health to a one-word state + (`TestCanonicalStorageMetadataCarriesFullZFSPoolReport` pins the path). 17. Keep compatible unified-resource consumers on one shared snapshot truth. `frontend-modern/src/hooks/useUnifiedResources.ts` may seed narrower type-filtered consumers from a fresh `all-resources` snapshot when the diff --git a/frontend-modern/src/components/Storage/StoragePoolDetail.tsx b/frontend-modern/src/components/Storage/StoragePoolDetail.tsx index ad1357c391..65a681495b 100644 --- a/frontend-modern/src/components/Storage/StoragePoolDetail.tsx +++ b/frontend-modern/src/components/Storage/StoragePoolDetail.tsx @@ -13,6 +13,7 @@ import { } from '@/features/storageBackups/diskDetailPresentation'; import { STORAGE_POOL_DETAIL_HISTORY_RANGE_OPTIONS, + getZfsDeviceStateTextClass, getZfsErrorTextClass, getZfsScanTextClass, } from '@/features/storageBackups/storagePoolDetailPresentation'; @@ -151,6 +152,36 @@ export const StoragePoolDetail: Component = (props) => { {zfsSummary()!.errorSummary} + 0}> +
+ + {(device) => ( +
+ + {device.name} + + + {device.type} + + + {device.state} + + + {device.errorSummary} + + + + {device.message} + + +
+ )} +
+
+
diff --git a/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts index 749bf13251..2642abb95d 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts @@ -558,4 +558,50 @@ describe('storageAdapters', () => { }); expect(records[0].refs?.resourceId).toBe('pool:tank'); }); + + it('carries the full ZFS pool payload from storage meta into record details', () => { + const zfsPool = { + name: 'rpool', + state: 'DEGRADED', + status: 'Degraded', + scan: 'resilver in progress', + readErrors: 0, + writeErrors: 0, + checksumErrors: 2, + devices: [ + { + name: 'sda2', + type: 'disk', + state: 'ONLINE', + readErrors: 0, + writeErrors: 0, + checksumErrors: 0, + }, + { + name: 'sdb2', + type: 'disk', + state: 'FAULTED', + readErrors: 3, + writeErrors: 0, + checksumErrors: 0, + }, + ], + }; + const state = baseState(); + const records = buildStorageRecords({ + state, + resources: [ + makeResourceStorage({ + storage: { + type: 'zfspool', + isZfs: true, + zfsPool, + } as Resource['storage'], + }), + ], + }); + + expect(records).toHaveLength(1); + expect(records[0].details?.zfsPool).toEqual(zfsPool); + }); }); diff --git a/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts index c35d972bc1..4d0f9525da 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts @@ -37,7 +37,17 @@ const buildRecord = (overrides: Partial = {}): StorageRecord => ( readErrors: 1, writeErrors: 2, checksumErrors: 3, - devices: [{ name: 'sda' }], + devices: [ + { name: 'sda', type: 'disk', state: 'ONLINE' }, + { + name: 'sdb', + type: 'disk', + state: 'FAULTED', + readErrors: 3, + checksumErrors: 1, + message: 'too many errors', + }, + ], }, }, ...overrides, @@ -114,6 +124,16 @@ describe('storagePoolDetailPresentation', () => { state: 'ONLINE', scan: 'scrub repaired 0B', errorSummary: 'Errors: R:1 W:2 C:3', + devices: [ + { name: 'sda', type: 'disk', state: 'ONLINE', errorSummary: '', message: '' }, + { + name: 'sdb', + type: 'disk', + state: 'FAULTED', + errorSummary: '3R/0W/1C errors', + message: 'too many errors', + }, + ], }); expect(getStoragePoolLinkedDisks(record, [buildDisk()])).toEqual([ { diff --git a/frontend-modern/src/features/storageBackups/resourceStorageMapping.ts b/frontend-modern/src/features/storageBackups/resourceStorageMapping.ts index edc4569476..bf07b7b321 100644 --- a/frontend-modern/src/features/storageBackups/resourceStorageMapping.ts +++ b/frontend-modern/src/features/storageBackups/resourceStorageMapping.ts @@ -1,4 +1,5 @@ import type { Resource } from '@/types/resource'; +import type { ZFSPool } from '@/types/api'; import type { StorageCapability, StorageCategory } from './models'; export type ResourceStorageMeta = { @@ -19,6 +20,7 @@ export type ResourceStorageMeta = { numMissing?: number; isCeph?: boolean; isZfs?: boolean; + zfsPool?: ZFSPool; }; type ResourceWithStorageMeta = Resource & { @@ -65,6 +67,10 @@ const normalizeStorageMeta = (value: unknown): ResourceStorageMeta | null => { numMissing: typeof candidate.numMissing === 'number' ? candidate.numMissing : undefined, isCeph: typeof candidate.isCeph === 'boolean' ? candidate.isCeph : undefined, isZfs: typeof candidate.isZfs === 'boolean' ? candidate.isZfs : undefined, + zfsPool: + candidate.zfsPool && typeof candidate.zfsPool === 'object' + ? (candidate.zfsPool as ResourceStorageMeta['zfsPool']) + : undefined, }; }; diff --git a/frontend-modern/src/features/storageBackups/storageAdapters.ts b/frontend-modern/src/features/storageBackups/storageAdapters.ts index 6e82cb079f..d750277050 100644 --- a/frontend-modern/src/features/storageBackups/storageAdapters.ts +++ b/frontend-modern/src/features/storageBackups/storageAdapters.ts @@ -213,7 +213,7 @@ const mapResourceStorageRecord = (resource: Resource, adapterId: string): Storag protection: storageMeta?.protection, isCeph: storageMeta?.isCeph, isZfs: storageMeta?.isZfs, - zfsPool: platformData.zfsPool, + zfsPool: storageMeta?.zfsPool ?? platformData.zfsPool, arrayState: storageMeta?.arrayState, syncAction: storageMeta?.syncAction, syncProgress: storageMeta?.syncProgress, diff --git a/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts b/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts index 9f158e93d9..9d1e8a181a 100644 --- a/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts +++ b/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts @@ -42,10 +42,19 @@ export type StoragePoolDetailChartTarget = { resourceId: string | null; }; +export type StoragePoolDetailZfsDevice = { + name: string; + type: string; + state: string; + errorSummary: string; + message: string; +}; + export type StoragePoolDetailZfsSummary = { state: string; scan: string; errorSummary: string | null; + devices: StoragePoolDetailZfsDevice[]; }; export const STORAGE_POOL_DETAIL_HISTORY_RANGE_OPTIONS: readonly { @@ -199,6 +208,22 @@ export function buildStoragePoolDetailTopologyRows( return rows; } +export function getZfsDeviceErrorSummary( + readErrors: number, + writeErrors: number, + checksumErrors: number, +): string { + if (readErrors <= 0 && writeErrors <= 0 && checksumErrors <= 0) return ''; + return `${readErrors}R/${writeErrors}W/${checksumErrors}C errors`; +} + +export function getZfsDeviceStateTextClass(state: string): string { + const normalized = (state || '').trim().toUpperCase(); + if (!normalized || normalized === 'ONLINE') return 'text-muted'; + if (normalized === 'DEGRADED') return 'font-medium text-amber-700 dark:text-amber-300'; + return 'font-medium text-red-700 dark:text-red-300'; +} + export function buildStoragePoolDetailZfsSummary( record: StorageRecord, ): StoragePoolDetailZfsSummary | null { @@ -213,6 +238,17 @@ export function buildStoragePoolDetailZfsSummary( errorSummary: hasErrors ? getZfsErrorSummary(pool.readErrors, pool.writeErrors, pool.checksumErrors) : null, + devices: (pool.devices || []).map((device) => ({ + name: device.name, + type: device.type || '', + state: device.state || '', + errorSummary: getZfsDeviceErrorSummary( + device.readErrors || 0, + device.writeErrors || 0, + device.checksumErrors || 0, + ), + message: (device.message || '').trim(), + })), }; } diff --git a/frontend-modern/src/hooks/useUnifiedResources.ts b/frontend-modern/src/hooks/useUnifiedResources.ts index 591bc78155..29d90400ad 100644 --- a/frontend-modern/src/hooks/useUnifiedResources.ts +++ b/frontend-modern/src/hooks/useUnifiedResources.ts @@ -319,6 +319,7 @@ type APIResource = { nodes?: string[]; pool?: string; path?: string; + zfsPool?: unknown; zfsPoolState?: string; zfsReadErrors?: number; zfsWriteErrors?: number; diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index 6afac23453..c340d2e799 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -20,6 +20,7 @@ import type { PBSPruneJob, PBSSyncJob, PBSVerifyJob, + ZFSPool, } from '@/types/api'; import { PLATFORM_TYPE_KEYS as GENERATED_PLATFORM_TYPE_KEYS, @@ -385,6 +386,7 @@ export interface ResourceStorageMeta { nodes?: string[]; pool?: string; path?: string; + zfsPool?: ZFSPool; zfsPoolState?: string; zfsReadErrors?: number; zfsWriteErrors?: number; diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 176044bb70..79240baca4 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -6018,6 +6018,9 @@ func monitorStoragePlatformData(resource unifiedresources.Resource, platformID s payload["zfsReadErrors"] = storage.ZFSReadErrors payload["zfsWriteErrors"] = storage.ZFSWriteErrors payload["zfsChecksumErrors"] = storage.ZFSChecksumErrors + if storage.ZFSPool != nil { + payload["zfsPool"] = storage.ZFSPool + } return payload } diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go index 7ad3449e07..e35355a4de 100644 --- a/internal/unifiedresources/adapters.go +++ b/internal/unifiedresources/adapters.go @@ -1610,7 +1610,10 @@ func resourceFromStorage(storage models.Storage) (Resource, ResourceIdentity) { zfsPoolState := "" var zfsReadErrors, zfsWriteErrors, zfsChecksumErrors int64 + var zfsPool *models.ZFSPool if storage.ZFSPool != nil { + normalized := storage.ZFSPool.NormalizeCollections() + zfsPool = &normalized zfsPoolState = strings.TrimSpace(storage.ZFSPool.State) zfsReadErrors = storage.ZFSPool.ReadErrors zfsWriteErrors = storage.ZFSPool.WriteErrors @@ -1641,6 +1644,7 @@ func resourceFromStorage(storage models.Storage) (Resource, ResourceIdentity) { Nodes: append([]string(nil), storage.Nodes...), Pool: storage.Pool, Path: storage.Path, + ZFSPool: zfsPool, ZFSPoolState: zfsPoolState, ZFSReadErrors: zfsReadErrors, ZFSWriteErrors: zfsWriteErrors, diff --git a/internal/unifiedresources/adapters_test.go b/internal/unifiedresources/adapters_test.go index c4da3aa12f..f747bb1ca6 100644 --- a/internal/unifiedresources/adapters_test.go +++ b/internal/unifiedresources/adapters_test.go @@ -310,7 +310,12 @@ func TestResourceFromStorageDetectsZFSMetadata(t *testing.T) { Active: true, ZFSPool: &models.ZFSPool{ Name: "rpool", - State: "ONLINE", + State: "DEGRADED", + Scan: "resilver in progress since Thu Jun 11 09:00:00 2026", + Devices: []models.ZFSDevice{ + {Name: "sda2", Type: "disk", State: "ONLINE"}, + {Name: "sdb2", Type: "disk", State: "FAULTED", ReadErrors: 3}, + }, }, } @@ -327,6 +332,22 @@ func TestResourceFromStorageDetectsZFSMetadata(t *testing.T) { if resource.Storage.Shared { t.Fatalf("expected shared=false") } + pool := resource.Storage.ZFSPool + if pool == nil { + t.Fatal("expected full ZFS pool payload on storage metadata") + } + if pool.Scan != storage.ZFSPool.Scan { + t.Fatalf("zfs scan = %q, want %q", pool.Scan, storage.ZFSPool.Scan) + } + if len(pool.Devices) != 2 { + t.Fatalf("zfs devices = %d, want 2", len(pool.Devices)) + } + if pool.Devices[1].State != "FAULTED" || pool.Devices[1].ReadErrors != 3 { + t.Fatalf("zfs device passthrough mismatch: %+v", pool.Devices[1]) + } + if pool == storage.ZFSPool { + t.Fatal("expected ZFS pool to be copied, not aliased to the source pointer") + } } func TestResourceFromStorageDerivesZFSTopologyRisk(t *testing.T) { diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index e647ba4f86..5a30ba91f6 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -377,6 +377,43 @@ func TestCanonicalStorageMetadataPreservesBackingPoolField(t *testing.T) { } } +func TestCanonicalStorageMetadataCarriesFullZFSPoolReport(t *testing.T) { + requiredSnippets := map[string][]string{ + "types.go": { + "ZFSPool *models.ZFSPool `json:\"zfsPool,omitempty\"`", + }, + "adapters.go": { + "normalized := storage.ZFSPool.NormalizeCollections()", + "ZFSPool: zfsPool,", + }, + filepath.Join("..", "monitoring", "monitor.go"): { + "payload[\"zfsPool\"] = storage.ZFSPool", + }, + filepath.Join("..", "..", "frontend-modern", "src", "types", "resource.ts"): { + "zfsPool?: ZFSPool;", + }, + filepath.Join("..", "..", "frontend-modern", "src", "hooks", "useUnifiedResources.ts"): { + "zfsPool?: unknown;", + }, + filepath.Join("..", "..", "frontend-modern", "src", "features", "storageBackups", "storageAdapters.ts"): { + "zfsPool: storageMeta?.zfsPool ?? platformData.zfsPool,", + }, + } + + for path, snippets := range requiredSnippets { + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read %s: %v", path, err) + } + source := string(data) + for _, snippet := range snippets { + if !strings.Contains(source, snippet) { + t.Fatalf("%s must contain %q", path, snippet) + } + } + } +} + func TestCephPoolsProjectThroughCanonicalStoragePath(t *testing.T) { requiredSnippets := map[string][]string{ filepath.Join("..", "models", "models.go"): { diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index ceffb7f93d..b79f1db65c 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -390,10 +390,14 @@ type StorageMeta struct { Path string `json:"path,omitempty"` // local mount path on the node // ZFS metadata (when IsZFS is true and the source provides details). - ZFSPoolState string `json:"zfsPoolState,omitempty"` - ZFSReadErrors int64 `json:"zfsReadErrors,omitempty"` - ZFSWriteErrors int64 `json:"zfsWriteErrors,omitempty"` - ZFSChecksumErrors int64 `json:"zfsChecksumErrors,omitempty"` + // ZFSPool carries the full pool report (scan status, per-device states + // and error counts); the scalar fields below are the flattened summary + // kept for consumers that only need pool-level state. + ZFSPool *models.ZFSPool `json:"zfsPool,omitempty"` + ZFSPoolState string `json:"zfsPoolState,omitempty"` + ZFSReadErrors int64 `json:"zfsReadErrors,omitempty"` + ZFSWriteErrors int64 `json:"zfsWriteErrors,omitempty"` + ZFSChecksumErrors int64 `json:"zfsChecksumErrors,omitempty"` // Unraid metadata (when Platform is "unraid"). ArrayState string `json:"arrayState,omitempty"`