mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-22 19:56:01 +00:00
storage(pools): carry the full ZFS pool payload to the storage surface
The unified-resource adapter flattened models.ZFSPool to four scalars (state + R/W/C totals), so scan status and the per-device report never reached the frontend. The storage detail's ZFS card, the usage bar's scrub/resilver overlay and ZFS tooltip, and ZFS-device-to-disk matching were all dead code on the unified path: a degraded pool rendered as a one-word State cell with no way to see which device failed or that a resilver was running. Found by the v5-parity audit of the pools table (same pipeline disease as #1471 on the disks table). - Add StorageMeta.ZFSPool and populate it in resourceFromStorage (normalized copy, not aliased). - Serialize it in monitorStoragePlatformData so the /api/state path carries it alongside the existing flat scalars. - Map it through normalizeStorageMeta and the storage record adapter (meta first, flat platformData fallback). - Render the per-device report (name, vdev type, state, R/W/C errors, message) in the detail's ZFS Pool card - the v6 home for what v5 showed via the health-map dots and the degraded-pool warning row. - Pin the end-to-end path in code_standards_test and update the unified-resources, monitoring, storage-recovery, and frontend-primitives contracts. Verified live in mock mode: degraded pool now shows the resilver scan, pool error totals, per-device states, and the bar pulse overlay.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<StoragePoolDetailProps> = (props) => {
|
||||
{zfsSummary()!.errorSummary}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={zfsSummary()!.devices.length > 0}>
|
||||
<div class={`${STORAGE_DETAIL_FULL_WIDTH_ROW_CLASS} space-y-0.5 pt-1`}>
|
||||
<For each={zfsSummary()!.devices}>
|
||||
{(device) => (
|
||||
<div class={STORAGE_DETAIL_LINKED_DISK_ROW_CLASS}>
|
||||
<span
|
||||
class={STORAGE_DETAIL_LINKED_DISK_PATH_CLASS}
|
||||
title={device.name}
|
||||
>
|
||||
{device.name}
|
||||
</span>
|
||||
<Show when={device.type}>
|
||||
<span class={STORAGE_DETAIL_MUTED_TEXT_CLASS}>{device.type}</span>
|
||||
</Show>
|
||||
<span class={getZfsDeviceStateTextClass(device.state)}>
|
||||
{device.state}
|
||||
</span>
|
||||
<Show when={device.errorSummary}>
|
||||
<span class={getZfsErrorTextClass()}>{device.errorSummary}</span>
|
||||
</Show>
|
||||
<Show when={device.message}>
|
||||
<span class={`${STORAGE_DETAIL_MUTED_TEXT_CLASS} italic`}>
|
||||
{device.message}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+21
-1
@@ -37,7 +37,17 @@ const buildRecord = (overrides: Partial<StorageRecord> = {}): 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([
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -319,6 +319,7 @@ type APIResource = {
|
||||
nodes?: string[];
|
||||
pool?: string;
|
||||
path?: string;
|
||||
zfsPool?: unknown;
|
||||
zfsPoolState?: string;
|
||||
zfsReadErrors?: number;
|
||||
zfsWriteErrors?: number;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"): {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user