diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index e1a0659ebf..b0f5f3b318 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -660,9 +660,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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index e1d02270e6..13c7e2f4c8 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -960,7 +960,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/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..09dfc3e2a7 100644 --- a/frontend-modern/src/features/truenas/truenasPageModel.ts +++ b/frontend-modern/src/features/truenas/truenasPageModel.ts @@ -722,6 +722,89 @@ 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', +}; + +// A read-only dataset raises no incident, so its state tag +// (datasetStateTag in internal/truenas/provider.go) is the only place the +// provider says why it is impaired. Locked and unmounted datasets also raise +// incidents, which win when present. +const TRUENAS_DATASET_STATE_REASONS: Record = { + 'state:locked': 'Dataset is locked', + 'state:unmounted': 'Dataset is not mounted', + 'state:readonly': 'Dataset is read-only', +}; + +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); + } + if (reasons.length === leading) { + for (const tag of resource.tags ?? []) push(TRUENAS_DATASET_STATE_REASONS[normalize(tag)]); + } + 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); }