diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index da6233e71d..97127ce02e 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -695,9 +695,11 @@ The TrueNAS Storage `Storage type` scope follows the same boundary with stable the current option through pressed-state semantics, preserves the scope in the URL across reload, and removes the query value for the default `all` state. On narrow screens the physical-disk scope may reprioritize its canonical table -columns to endurance, temperature, and health while retaining the shared table +columns to endurance and temperature while retaining the shared table overflow and touch-target behavior; it must not introduce a second mobile-only -filter or table shell. +filter or table shell. Its Health column stays desktop-only in every scope: +the status dot carries a disk's condition on a phone, the row drawer carries +the reason, and the old pill clipped past the phone container edge. Large-estate platform pages must keep one canonical inventory snapshot for the initial read and explicit refresh path. The Proxmox and VMware vSphere @@ -5278,7 +5280,10 @@ Platform table title-case fallback formatting follows the same rule. `formatPlatformTableTitleCaseValue` owns the repeated trimmed-string plus `Unknown` fallback behavior for state/status labels that need simple title case. TrueNAS platform tables must compose that helper instead of declaring -local `titleCase` helpers. +local `titleCase` helpers. The apps, shares, services and VMs tables are its +required consumers. The storage table left that list when its Health cell +switched from a title-cased risk-level pill to TrueNAS's own reason text, and +the scope-wide guard against a local `titleCase` helper still covers it. Platform table compact list summaries follow the same rule. `summarizePlatformTableValues` owns the repeated trimming, empty-marker label, visible-value count, `+N` overflow suffix, full-title text, and normalized diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index b4a36fc791..829cedf05e 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -1014,7 +1014,18 @@ before text and status filters so metadata from an excluded disk cannot retain its pool or dataset ancestor. Physical-disk presentation may prioritize the canonical SMART health, temperature, and endurance evidence; absent wearout or percentage-used telemetry remains unknown rather than being rendered as zero -remaining life. +remaining life. Storage health is exception-first: `getTrueNASStorageIssue` +returns nothing for healthy pools, datasets, and disks, so their Health cell +stays empty and the status dot alone says healthy. A non-healthy row carries +the reasons TrueNAS gave, in order: native TrueNAS alert text on that +resource, storage and physical-disk risk summaries, then the dataset state tag +(`state:locked`, `state:unmounted`, `state:readonly`, which is the only place +the provider says why a dataset is impaired). Without any of those it falls +back to the pool state, the disk health value, or an impaired TrueNAS source. +The first reason renders in the desktop Health column on the row's single line, +coloured by bucket, with a count for the rest and the full list in the title +and screen-reader text. Phones keep the status dot and reach the reason through +the row drawer. Health sorts by severity rank, not label text. That shell also owns responsive width composition. Source tables may declare desktop breakpoint floors, but the phone branch must fit its selected columns inside the available container without a horizontal rail. Each unified-resource diff --git a/frontend-modern/browser-verification/0911e211ce898d75.json b/frontend-modern/browser-verification/0911e211ce898d75.json new file mode 100644 index 0000000000..cc8854b12c --- /dev/null +++ b/frontend-modern/browser-verification/0911e211ce898d75.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "base_sha": "fdcffc64114bd9d7ff7a92324391a09702c3c26a", + "verified_at": "2026-10-05T05:37:16Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx", + "frontend-modern/src/features/truenas/truenasPageModel.ts" + ], + "content_sha256": { + "frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx": "19e1e5b705285464607804f8a8d7b5174e39c856290bd8c6ce5dc5e1212ff2af", + "frontend-modern/src/features/truenas/truenasPageModel.ts": "b1955834430d00fca3fc4176614a83f4a8387c8c29a85f0f17c6fd1360faf514" + }, + "routes": [ + "/truenas/storage", + "/truenas/storage?kind=disks" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 820, + "height": 700 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Storage at 1440: every row is single-line. 18 healthy rows leave Health empty, where before each carried a green Healthy pill. The archive pool reads Pool archive is DEGRADED: one member of archive mirror-0 is reporting checksum and SMART faults (truncated, +1 for the copied SMART alert, full list on hover), archive/cold reads Dataset is read-only, sdc reads Device /dev/sdc has SMART test failures, coloured amber in the Health column", + "Health sorts by severity rank (Attention, Offline, Unknown, Healthy), not by label text", + "Disk scope at 1440: Endurance and Temp visible, sdc reason in Health", + "Tablet at 820 and dark mode at 1440: reasons truncate cleanly with the +1 kept", + "Phone at 390: Resource, Kind, Usage, Disks with status dots, Health hidden, so the old pill no longer clips past the edge in the disk scope", + "Phone drawer: tapping archive shows DEGRADED and tapping sdc shows the SMART failure. The read-only dataset drawer still only says Degraded, a pre-existing drawer gap tracked for a follow-up", + "Rebuilt as one commit on current main after the services and VMs change landed. The two frontend files are byte-identical to the version verified above, and the page was reshot on this tree" + ], + "interactions": [ + "Switched to the Physical disks scope", + "Tapped archive, archive/cold and sdc on a 390 phone and read each drawer" + ] +} diff --git a/frontend-modern/browser-verification/26310d16c7eb281c.json b/frontend-modern/browser-verification/26310d16c7eb281c.json new file mode 100644 index 0000000000..f924cd129f --- /dev/null +++ b/frontend-modern/browser-verification/26310d16c7eb281c.json @@ -0,0 +1,33 @@ +{ + "version": 1, + "base_sha": "146595b8484aa742e22e218675ea141164b55d4a", + "verified_at": "2026-10-05T06:53:20Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/truenas/truenasPageModel.ts" + ], + "content_sha256": { + "frontend-modern/src/features/truenas/truenasPageModel.ts": "67c724aa3023666c689a06f5cc4b92f634ab632b0e8842ee6fc709f3a06ed9b6" + }, + "routes": [ + "/truenas/storage" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Storage Health cells after switching the dataset state reason to the shared util: the archive pool lists its volume alert and the copied SMART alert, archive/cold reads Dataset is read-only, sdc reads its SMART failure. Same text as before the switch, now from the mapping the drawer also uses", + "Phone at 390: the table keeps its dot-only projection, and tapping archive/cold opens a drawer that says Dataset is read-only" + ], + "interactions": [ + "Read every Health cell title on the storage table", + "Tapped archive/cold on a 390 phone" + ] +} diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index dcfe89af0c..04ad2b7184 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -2304,7 +2304,6 @@ { "path": "src/features/truenas/TrueNASAppsTable.tsx" }, { "path": "src/features/truenas/TrueNASNetworkSharesTable.tsx" }, { "path": "src/features/truenas/TrueNASServicesTable.tsx" }, - { "path": "src/features/truenas/TrueNASStorageTopologyTable.tsx" }, { "path": "src/features/truenas/TrueNASVirtualMachinesTable.tsx" } ], "forbiddenPatterns": [ @@ -2320,10 +2319,6 @@ "path": "src/features/truenas/TrueNASServicesTable.tsx", "patterns": ["const titleCase"] }, - { - "path": "src/features/truenas/TrueNASStorageTopologyTable.tsx", - "patterns": ["const titleCase"] - }, { "path": "src/features/truenas/TrueNASVirtualMachinesTable.tsx", "patterns": ["const titleCase"] diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index ad0a58029f..1769154c13 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -5895,7 +5895,9 @@ describe('shared primitive guardrails', () => { ['src/features/truenas/TrueNASAppsTable.tsx', truenasAppsTableSource], ['src/features/truenas/TrueNASNetworkSharesTable.tsx', truenasNetworkSharesTableSource], ['src/features/truenas/TrueNASServicesTable.tsx', truenasServicesTableSource], - ['src/features/truenas/TrueNASStorageTopologyTable.tsx', truenasStorageTopologyTableSource], + // The storage table no longer formats a raw provider value: its Health + // cell carries TrueNAS's own reason text. The scope-wide local-helper + // guard below still keeps a private titleCase out of it. ['src/features/truenas/TrueNASVirtualMachinesTable.tsx', truenasVirtualMachinesTableSource], ]; const truenasTitleCaseConsumerPaths = truenasTitleCaseConsumers.map(([path]) => path); diff --git a/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx b/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx index 2e5405b673..492c1c4c8e 100644 --- a/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx +++ b/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx @@ -18,7 +18,6 @@ import { createPlatformTableFilterState, createPlatformTableSortState, formatPlatformTableBytesValue, - formatPlatformTableTitleCaseValue, getPlatformTableCellClassForKind, type PlatformTableFilterOption, type PlatformTableSortState, @@ -39,7 +38,8 @@ import { filterTrueNASStorageTopologyRows, filterTrueNASStorageTopologyRowsByKind, getTrueNASResourceDisplayStatus, - mapTrueNASStorageStatus, + getTrueNASStorageIssue, + type TrueNASStorageIssue, type TrueNASStorageStatusFilter, type TrueNASStorageKindFilter, type TrueNASStorageTopologyKind, @@ -193,44 +193,52 @@ const DiskEnduranceCell: Component<{ row: TrueNASStorageTopologyRow }> = (props) ); }; -const riskLabel = (row: TrueNASStorageTopologyRow): string => { - const risk = - asTrimmedString(row.resource.storage?.risk?.level) || - asTrimmedString(row.resource.physicalDisk?.risk?.level); - if (risk) return formatPlatformTableTitleCaseValue(risk); - const incidentCount = row.resource.incidentCount ?? row.resource.incidents?.length ?? 0; - if (incidentCount > 0) return `${incidentCount} alert${incidentCount === 1 ? '' : 's'}`; - const mapped = mapTrueNASStorageStatus(row.resource); - if (mapped === 'attention') return 'Attention'; - if (mapped === 'offline') return 'Offline'; - if (mapped === 'healthy') return 'Healthy'; - return 'Unknown'; +const issueReasonClass = (issue: TrueNASStorageIssue): string => { + if (issue.status === 'attention') return 'text-amber-700 dark:text-amber-300'; + if (issue.status === 'offline') return 'text-red-600 dark:text-red-300'; + return 'text-muted'; }; -const riskPillClass = (row: TrueNASStorageTopologyRow): string => { - const mapped = mapTrueNASStorageStatus(row.resource); - if (mapped === 'attention') { - return 'border-amber-300/50 bg-amber-500/10 text-amber-700 dark:text-amber-300'; - } - if (mapped === 'offline') { - return 'border-red-300/50 bg-red-500/10 text-red-700 dark:text-red-300'; - } - if (mapped === 'healthy') { - return 'border-emerald-300/50 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'; - } - return 'border-border bg-surface-alt text-muted'; -}; +// The visible reason is truncated and abbreviates the rest to a count, so +// assistive technology gets the bucket and every reason instead. +const issueScreenReaderText = (issue: TrueNASStorageIssue): string => + `${issue.label}: ${issue.reasons.map((reason) => reason.replace(/[.\s]+$/, '')).join('. ')}.`; -const RiskPill: Component<{ row: TrueNASStorageTopologyRow }> = (props) => ( - - {riskLabel(props.row)} - +// Healthy rows leave the Health column empty: the status dot already says +// healthy, and a column of green pills buried the few rows that are not. An +// exception shows why, on the row's single line (the shared platform-table +// rhythm), with every reason on hover and in the row drawer. The colour +// carries the bucket, so a pill repeating Attention would only take room +// from the reason. +const IssueSummary: Component<{ issue: TrueNASStorageIssue | null }> = (props) => ( + + {(issue) => ( +
+ + 1}> + + + {issueScreenReaderText(issue())} +
+ )} +
); +const HEALTH_SORT_RANK: Record = { + attention: 0, + offline: 1, + unknown: 2, + healthy: 3, +}; + // Columns a user can sort by. Usage / Size orders pools and datasets on their // used percentage and disks on their raw size, so same-kind siblings compare // on the number their cell actually shows. @@ -272,7 +280,7 @@ const getTrueNASStorageSortValue = ( return typeof temperature === 'number' && Number.isFinite(temperature) ? temperature : null; } case 'health': - return riskLabel(row); + return HEALTH_SORT_RANK[getTrueNASStorageIssue(row.resource)?.status ?? 'healthy']; default: key satisfies never; return null; @@ -481,7 +489,7 @@ export const TrueNASStorageTopologyTable: Component<{ kind="metric-bar" sort={sort} sortKey="usage" - class="platform-table-mobile-w-25 md:w-[28%]" + class="platform-table-mobile-w-25 md:w-[20%]" > @@ -489,7 +497,7 @@ export const TrueNASStorageTopologyTable: Component<{ kind="numeric-value" sort={sort} sortKey={kindFilter() === 'disks' ? 'endurance' : 'disks'} - class="platform-table-mobile-w-15 md:w-[8%]" + class="platform-table-mobile-w-15 md:w-[7%]" > {kindFilter() === 'disks' ? 'Endurance' : 'Disks'} @@ -497,15 +505,15 @@ export const TrueNASStorageTopologyTable: Component<{ kind="numeric-value" sort={sort} sortKey="temp" - class={`${kindFilter() === 'disks' ? 'table-cell' : 'hidden lg:table-cell'} md:w-[8%]`} + class={`${kindFilter() === 'disks' ? 'table-cell' : 'hidden lg:table-cell'} md:w-[7%]`} > Temp @@ -516,6 +524,7 @@ export const TrueNASStorageTopologyTable: Component<{ {(row) => { const resource = () => row.resource; + const issue = createMemo(() => getTrueNASStorageIssue(resource())); const detailRowId = () => drawer.detailRowId(resource()); const isExpanded = () => drawer.isExpanded(resource()); return ( @@ -581,13 +590,9 @@ export const TrueNASStorageTopologyTable: Component<{ /> - + { ]); }); + it('sorts Health by severity rank rather than label text', () => { + const resources: Resource[] = [ + makeStorageResource({ + id: 'pool-alpha', + name: 'alpha', + displayName: 'alpha', + storage: { topology: 'pool', platform: 'truenas', path: 'alpha' }, + }), + makeStorageResource({ + id: 'dataset-healthy', + name: 'alpha/healthy', + displayName: 'alpha/healthy', + storage: { topology: 'dataset', platform: 'truenas', path: '/mnt/alpha/healthy' }, + }), + makeStorageResource({ + id: 'dataset-offline', + name: 'alpha/offline', + displayName: 'alpha/offline', + status: 'offline', + storage: { topology: 'dataset', platform: 'truenas', path: '/mnt/alpha/offline' }, + }), + makeStorageResource({ + id: 'dataset-warning', + name: 'alpha/warning', + displayName: 'alpha/warning', + status: 'warning', + storage: { topology: 'dataset', platform: 'truenas', path: '/mnt/alpha/warning' }, + }), + ]; + const { container } = render(() => ( + } + emptyTitle="No storage" + emptyDescription="No storage" + showToolbar={false} + /> + )); + + // Alphabetical labels would put Healthy before Offline. Severity puts + // Attention first, then Offline, with healthy rows last. + fireEvent.click(screen.getByRole('columnheader', { name: /Health/ })); + expect(screen.getByRole('columnheader', { name: /Health/ })).toHaveAttribute( + 'aria-sort', + 'ascending', + ); + expect(visibleRowOrder(container)).toEqual([ + 'pool:pool-alpha', + 'dataset:dataset-warning', + 'dataset:dataset-offline', + 'dataset:dataset-healthy', + ]); + + fireEvent.click(screen.getByRole('columnheader', { name: /Health/ })); + expect(screen.getByRole('columnheader', { name: /Health/ })).toHaveAttribute( + 'aria-sort', + 'descending', + ); + expect(visibleRowOrder(container)).toEqual([ + 'pool:pool-alpha', + 'dataset:dataset-healthy', + 'dataset:dataset-offline', + 'dataset:dataset-warning', + ]); + }); + it('persists the chosen sort across a remount', () => { const first = renderTable(); fireEvent.click(headerFor('Resource')); diff --git a/frontend-modern/src/features/truenas/__tests__/TrueNASStorageTopologyTable.test.tsx b/frontend-modern/src/features/truenas/__tests__/TrueNASStorageTopologyTable.test.tsx index d66cfe2b5b..57982bab5f 100644 --- a/frontend-modern/src/features/truenas/__tests__/TrueNASStorageTopologyTable.test.tsx +++ b/frontend-modern/src/features/truenas/__tests__/TrueNASStorageTopologyTable.test.tsx @@ -143,7 +143,11 @@ describe('TrueNASStorageTopologyTable', () => { expect(screen.getByRole('columnheader', { name: /Endurance/ })).toBeInTheDocument(); expect(screen.getByText('68% left')).toBeInTheDocument(); expect(screen.getByRole('columnheader', { name: /Temp/ })).toHaveClass('table-cell'); - expect(screen.getByRole('columnheader', { name: /Health/ })).toHaveClass('table-cell'); + // The status dot and reason line carry health on phones, so the column + // stays desktop-only even when it is the disk view's main signal. + expect(screen.getByRole('columnheader', { name: /Health/ })).toHaveClass( + 'platform-table-phone-hidden', + ); await fireEvent.click(screen.getByRole('button', { name: 'Volumes, 2' })); expect(container.querySelectorAll('[data-truenas-storage-row]')).toHaveLength(2); @@ -151,4 +155,64 @@ describe('TrueNASStorageTopologyTable', () => { expect(container.querySelector('[data-truenas-storage-kind="dataset"]')).not.toBeNull(); expect(container.querySelector('[data-truenas-storage-kind="disk"]')).toBeNull(); }); + + it('shows health only for exceptions, with the reason in the Health cell', () => { + const pool = makeStorageResource({ + id: 'pool-archive', + name: 'archive', + status: 'warning', + incidents: [ + { + code: 'truenas_volume_status', + severity: 'warning', + summary: 'Pool archive is DEGRADED: one member of mirror-0 is faulted.', + }, + { + code: 'truenas_smart', + severity: 'warning', + summary: 'Device /dev/sdc has SMART test failures.', + }, + ], + storage: { topology: 'pool', platform: 'truenas', zfsPoolState: 'DEGRADED' }, + }); + const healthy = makeStorageResource({ + id: 'dataset-backups', + name: 'archive/backups', + parentId: 'pool-archive', + storage: { topology: 'dataset', platform: 'truenas' }, + }); + const resources = [pool, healthy]; + const { container } = render(() => ( + } + emptyTitle="No storage" + emptyDescription="No storage" + showToolbar={false} + /> + )); + + const poolRow = container.querySelector('[data-truenas-storage-resource="pool-archive"]'); + const healthyRow = container.querySelector('[data-truenas-storage-resource="dataset-backups"]'); + const health = poolRow?.querySelector('[data-truenas-storage-health="attention"]'); + const visible = [...(health?.querySelectorAll('[aria-hidden="true"]') ?? [])].map( + (node) => node.textContent, + ); + + // The reason sits in the Health cell on the row's single line. The name + // cell carries only the name, per the shared platform-table rhythm. + expect(health?.closest('td')).toHaveClass('platform-table-phone-hidden'); + expect(visible).toEqual(['Pool archive is DEGRADED: one member of mirror-0 is faulted.', '+1']); + expect(health?.querySelector('.sr-only')).toHaveTextContent( + 'Attention: Pool archive is DEGRADED: one member of mirror-0 is faulted. Device /dev/sdc has SMART test failures.', + ); + expect(health).toHaveAttribute( + 'title', + 'Pool archive is DEGRADED: one member of mirror-0 is faulted.\nDevice /dev/sdc has SMART test failures.', + ); + expect(poolRow?.querySelector('td')).not.toHaveTextContent('DEGRADED'); + expect(healthyRow?.querySelector('[data-truenas-storage-health]')).toBeNull(); + expect(healthyRow).not.toHaveTextContent('Healthy'); + }); }); diff --git a/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts b/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts index fa48cc92e1..8f62772d95 100644 --- a/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts +++ b/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts @@ -19,6 +19,7 @@ import { filterTrueNASVMs, getTrueNASPageTabSpecs, getTrueNASResourceDisplayStatus, + getTrueNASStorageIssue, mapTrueNASAppStatus, mapTrueNASIncidentSeverity, mapTrueNASProtectionKind, @@ -645,6 +646,200 @@ describe('truenasPageModel', () => { expect(row && mapTrueNASStorageStatus(row.resource)).toBe('healthy'); }); + it('explains non-healthy storage with TrueNAS alert text first and stays silent when healthy', () => { + const healthyPool = makeResource({ + id: 'pool-tank', + type: 'storage', + name: 'tank', + storage: { topology: 'pool', platform: 'truenas', zfsPoolState: 'ONLINE' }, + }); + expect(getTrueNASStorageIssue(healthyPool)).toBeNull(); + + const poolRisk = { + level: 'warning', + reasons: [ + { code: 'zfs_pool_state', severity: 'warning', summary: 'ZFS pool archive is DEGRADED' }, + ], + }; + // A pool carries its own alert plus the disk alerts the provider copies onto it. + const degradedPool = makeResource({ + id: 'pool-archive', + type: 'storage', + name: 'archive', + status: 'warning', + incidents: [ + { + code: 'truenas_volume_status', + severity: 'warning', + summary: 'Pool archive is DEGRADED: one member of mirror-0 is faulted.', + }, + { + code: 'truenas_smart', + severity: 'warning', + summary: 'Device /dev/sdc has SMART test failures.', + }, + ], + storage: { topology: 'pool', platform: 'truenas', zfsPoolState: 'DEGRADED', risk: poolRisk }, + }); + expect(getTrueNASStorageIssue(degradedPool)).toEqual({ + status: 'attention', + label: 'Attention', + reasons: [ + 'Pool archive is DEGRADED: one member of mirror-0 is faulted.', + 'Device /dev/sdc has SMART test failures.', + ], + }); + + // Without a TrueNAS alert, Pulse's own risk summary explains the row. + const riskOnlyPool = makeResource({ + id: 'pool-risk', + type: 'storage', + name: 'archive', + status: 'warning', + storage: { topology: 'pool', platform: 'truenas', zfsPoolState: 'DEGRADED', risk: poolRisk }, + }); + expect(getTrueNASStorageIssue(riskOnlyPool)?.reasons).toEqual(['ZFS pool archive is DEGRADED']); + + const failingDisk = makeResource({ + id: 'disk-sdc', + type: 'physical_disk', + name: 'sdc', + status: 'warning', + incidents: [ + { + code: 'truenas_smart', + severity: 'warning', + summary: 'Device /dev/sdc has SMART test failures.', + }, + { + code: 'truenas_smart', + severity: 'warning', + summary: 'Device /dev/sdc has SMART test failures', + }, + ], + physicalDisk: { + devPath: '/dev/sdc', + health: 'DEGRADED', + risk: { + level: 'warning', + reasons: [ + { + code: 'truenas_disk_state', + severity: 'warning', + summary: 'TrueNAS disk sdc is DEGRADED', + }, + ], + }, + }, + }); + // The repeat differs only in spacing and its full stop, so it folds in, and + // the derived disk-state summary waits behind the native alert. + expect(getTrueNASStorageIssue(failingDisk)?.reasons).toEqual([ + 'Device /dev/sdc has SMART test failures.', + ]); + + const readOnlyDataset = makeResource({ + id: 'dataset-cold', + type: 'storage', + name: 'archive/cold', + status: 'warning', + tags: ['truenas', 'dataset', 'zfs', 'state:readonly'], + storage: { topology: 'dataset', platform: 'truenas' }, + }); + expect(getTrueNASStorageIssue(readOnlyDataset)?.reasons).toEqual(['Dataset is read-only']); + + // The provider raises its own incident for a locked dataset, which wins + // over the state tag instead of being repeated by it. + const lockedDataset = makeResource({ + id: 'dataset-vault', + type: 'storage', + name: 'vault/secrets', + status: 'offline', + tags: ['truenas', 'dataset', 'zfs', 'state:locked'], + incidents: [ + { + code: 'zfs_dataset_locked', + severity: 'warning', + summary: 'ZFS dataset vault/secrets is locked and unavailable', + }, + ], + storage: { topology: 'dataset', platform: 'truenas' }, + }); + expect(getTrueNASStorageIssue(lockedDataset)).toEqual({ + status: 'offline', + label: 'Offline', + reasons: ['ZFS dataset vault/secrets is locked and unavailable'], + }); + const lockedWithoutIncident = makeResource({ + id: 'dataset-vault-tag', + type: 'storage', + name: 'vault/keys', + status: 'offline', + tags: ['truenas', 'dataset', 'zfs', 'state:locked'], + storage: { topology: 'dataset', platform: 'truenas' }, + }); + expect(getTrueNASStorageIssue(lockedWithoutIncident)?.reasons).toEqual(['Dataset is locked']); + + const bareDegradedPool = makeResource({ + id: 'pool-bare', + type: 'storage', + name: 'bare', + storage: { topology: 'pool', platform: 'truenas', zfsPoolState: 'degraded' }, + }); + expect(getTrueNASStorageIssue(bareDegradedPool)?.reasons).toEqual(['Pool state DEGRADED']); + + // Stale TrueNAS data is the cause even when the pool also has other news. + const staleConnectionPool = makeResource({ + id: 'pool-stale', + type: 'storage', + name: 'stale', + storage: { + topology: 'pool', + platform: 'truenas', + zfsPoolState: 'ONLINE', + risk: { + level: 'info', + reasons: [ + { + code: 'zfs_scan_running', + severity: 'info', + summary: 'ZFS pool stale scrub is running', + }, + ], + }, + }, + platformData: { sourceStatus: { truenas: { status: 'stale' } } }, + }); + expect(getTrueNASStorageIssue(staleConnectionPool)?.reasons).toEqual([ + 'TrueNAS has not updated this recently', + 'ZFS pool stale scrub is running', + ]); + + const unknownDisk = makeResource({ + id: 'disk-unknown', + type: 'physical_disk', + name: 'ada0', + status: 'unknown', + physicalDisk: { devPath: '/dev/ada0', health: 'UNKNOWN' }, + }); + expect(getTrueNASStorageIssue(unknownDisk)).toEqual({ + status: 'unknown', + label: 'Unknown', + reasons: ['TrueNAS has not reported a state for this'], + }); + + const silentOfflinePool = makeResource({ + id: 'pool-gone', + type: 'storage', + name: 'gone', + status: 'offline', + storage: { topology: 'pool', platform: 'truenas' }, + }); + expect(getTrueNASStorageIssue(silentOfflinePool)?.reasons).toEqual([ + 'Offline, and TrueNAS gave no reason', + ]); + }); + it('filters apps using native TrueNAS app.query metadata', () => { const nextcloud = makeResource({ id: 'app-nextcloud', diff --git a/frontend-modern/src/features/truenas/truenasPageModel.ts b/frontend-modern/src/features/truenas/truenasPageModel.ts index 925e1a2405..8279c78afe 100644 --- a/frontend-modern/src/features/truenas/truenasPageModel.ts +++ b/frontend-modern/src/features/truenas/truenasPageModel.ts @@ -1,6 +1,7 @@ import { resolveResourcePlatformType } from '@/utils/sourcePlatforms'; import { asTrimmedString } from '@/utils/stringUtils'; import { hasImpairedResourceSource } from '@/utils/resourceSourceHealth'; +import { getTrueNASDatasetStateSummary } from '@/utils/truenasDatasetState'; import type { Resource, ResourceIncident, @@ -722,6 +723,81 @@ export function mapTrueNASStorageStatus( return 'unknown'; } +export type TrueNASStorageIssue = { + status: Exclude | 'unknown'; + label: string; + reasons: string[]; +}; + +const TRUENAS_STORAGE_ISSUE_LABELS: Record = { + attention: 'Attention', + offline: 'Offline', + unknown: 'Unknown', +}; + +const TRUENAS_STORAGE_SILENT_REASONS: Record = { + attention: 'TrueNAS flagged this without a reason', + offline: 'Offline, and TrueNAS gave no reason', + unknown: 'TrueNAS has not reported a state for this', +}; + +const reasonKey = (text: string): string => + text + .toLowerCase() + .replace(/\s+/g, ' ') + .replace(/[.\s]+$/, ''); + +// Healthy storage needs no explanation, so this returns null for it. Anything +// else carries the reasons TrueNAS gave, most specific first, so a row can say +// why it is amber instead of only that it is. TrueNAS's own alert text names +// the failing member or test, so it leads. Pulse's derived risk summaries +// restate the same pool and disk state, so they only stand in when TrueNAS +// raised no alert; the row drawer still lists every one of them. +export function getTrueNASStorageIssue(resource: Resource): TrueNASStorageIssue | null { + const status = mapTrueNASStorageStatus(resource); + if (status === 'healthy') return null; + + const reasons: string[] = []; + const push = (value: unknown) => { + const text = asTrimmedString(value); + if (!text) return; + const key = reasonKey(text); + if (reasons.some((existing) => reasonKey(existing) === key)) return; + reasons.push(text); + }; + + // Stale TrueNAS data is why the row left Healthy, whatever else it says. + if (hasImpairedResourceSource(resource, 'truenas')) { + push('TrueNAS has not updated this recently'); + } + const leading = reasons.length; + for (const incident of resource.incidents ?? []) push(incident.summary); + if (reasons.length === leading) { + for (const reason of resource.storage?.risk?.reasons ?? []) push(reason.summary); + for (const reason of resource.physicalDisk?.risk?.reasons ?? []) push(reason.summary); + } + // A read-only dataset raises no incident, so its state tag is the only + // reason the provider gives. The drawer reads the same shared mapping. + if (reasons.length === leading && resource.storage?.topology === 'dataset') { + push(getTrueNASDatasetStateSummary(resource.tags)); + } + if (reasons.length === leading) { + const zfsState = asTrimmedString(resource.storage?.zfsPoolState); + const diskHealth = asTrimmedString(resource.physicalDisk?.health); + if (zfsState && !['online', 'healthy'].includes(zfsState.toLowerCase())) { + push(`Pool state ${zfsState.toUpperCase()}`); + } else if ( + diskHealth && + !['passed', 'healthy', 'ok', 'unknown', 'unavailable'].includes(diskHealth.toLowerCase()) + ) { + push(`Disk health ${diskHealth.toUpperCase()}`); + } + } + if (reasons.length === 0) push(TRUENAS_STORAGE_SILENT_REASONS[status]); + + return { status, label: TRUENAS_STORAGE_ISSUE_LABELS[status], reasons }; +} + export function mapTrueNASProtectionStatus(point: RecoveryPoint): TrueNASProtectionStatusBucket { return normalizeProtectionOutcome(point.outcome); } diff --git a/tests/integration/tests/27-truenas-storage-disk-history.spec.ts b/tests/integration/tests/27-truenas-storage-disk-history.spec.ts index e567469bf9..7b388fe871 100644 --- a/tests/integration/tests/27-truenas-storage-disk-history.spec.ts +++ b/tests/integration/tests/27-truenas-storage-disk-history.spec.ts @@ -119,7 +119,12 @@ test.describe("TrueNAS storage disk history", () => { await expect(search).toBeVisible({ timeout: 60_000 }); await search.fill("sdc"); - const diskRow = page.locator("tr").filter({ hasText: "sdc" }).first(); + // The archive pool row also mentions /dev/sdc, because its Health cell + // carries the copied SMART alert, so pick the disk row by its kind. + const diskRow = page + .locator('tr[data-truenas-storage-kind="disk"]') + .filter({ hasText: "sdc" }) + .first(); await expect(diskRow).toBeVisible(); await diskRow.getByRole("button").first().click();