diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 30fe40df19..ab0525f836 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 plus the reason line under the name already carry a disk's +condition, and the 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 @@ -711,7 +713,10 @@ scrolling exposes it. Touch scrolling must remain compositor-native: windowed renderers must not attach touch listeners or replace keyed rows before the browser moves the page, and must update their runway only from the passive native scroll event. Spacer geometry is structural only: no feature may present -it as loading, pagination, or an intentionally blank data region. Settings +it as loading, pagination, or an intentionally blank data region. Table rows +may differ in height (an exception row with a reason line under its name), +because a table runway sizes its spacers from the average mounted row rather +than its tallest leading row. Settings resource pickers, Availability target lists, Actions, alerts, and every provider-native platform table share this contract. For table rows with unique logical ids, or an explicit unique key extractor, `PlatformWindowedRows` owns a diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 1a49ebcb1e..bb4d490869 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -2555,11 +2555,15 @@ sparse live snapshots update fields without remounting an open inline drawer or discarding its tab and form state. Rows without a unique logical ID retain reference-keyed rendering rather than paying for guessed index identity. Spacer geometry and the inverse scroll-to-index mapping must share one -representative item height. The controller samples several leading siblings and -keeps the tallest, because a short leading group header sampled alone collapses -the estimate and lets the mounted window outrun the real scroll position, -turning a small scroll into many rows of unmounting and making an estate appear -to lose rows. +representative item height. A table runway, anchored on its top spacer row, +measures every mounted row up to the bottom spacer and divides by the mounted +item count, so a few taller rows (a reason line under an exception's name, an +open detail row) count once instead of sizing every unmounted row and making +the window jump as they scroll out. Other anchors sample several leading +siblings and keep the tallest, because a short leading group header sampled +alone collapses the estimate and lets the mounted window outrun the real scroll +position, turning a small scroll into many rows of unmounting and making an +estate appear to lose rows. The workload guest-row path now follows the same pattern: the render shell stays in `frontend-modern/src/components/Workloads/GuestRow.tsx`, tooltip-backed cell presentation lives in `frontend-modern/src/components/Workloads/GuestRowCells.tsx`, diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 737fdef6f7..4326ce42f2 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -960,7 +960,17 @@ 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 under the name on every width and the full list stays +in the title, so a phone user learns why a row is amber without the Health +column. 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/ea1ee06c9d27c8b5.json b/frontend-modern/browser-verification/ea1ee06c9d27c8b5.json new file mode 100644 index 0000000000..0c193b814e --- /dev/null +++ b/frontend-modern/browser-verification/ea1ee06c9d27c8b5.json @@ -0,0 +1,47 @@ +{ + "version": 1, + "base_sha": "94ac386abfe13a8eb0109ac1eea4c087390f9717", + "verified_at": "2026-10-05T04:55:33Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts", + "frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx", + "frontend-modern/src/features/truenas/truenasPageModel.ts" + ], + "content_sha256": { + "frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts": "3ce382df4466178015acbc4e28eb85743e66e89fac2ef75b4616107145101431", + "frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx": "206003ff23c53f6b41f893130c5f29c17f2b91a1dfab7f7742cd3094b774f46f", + "frontend-modern/src/features/truenas/truenasPageModel.ts": "b1955834430d00fca3fc4176614a83f4a8387c8c29a85f0f17c6fd1360faf514" + }, + "routes": [ + "/truenas/storage", + "/truenas/storage?kind=disks", + "/docker/containers" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 820, + "height": 700 + }, + { + "width": 390, + "height": 760 + } + ], + "states": [ + "Storage at 1440: 18 healthy rows leave Health empty, the three exceptions keep an Attention pill (archive pool, archive/cold dataset, sdc disk), where before every row had a pill and the pool said Warning while the dataset said Attention", + "Reason under the name: archive shows the TrueNAS volume alert with +1 (the copied SMART alert), archive/cold shows Dataset is read-only, sdc shows Device /dev/sdc has SMART test failures with no +1 now that derived risk summaries only stand in when TrueNAS raised no alert", + "Phone 390: reason wraps to two lines with the +1 outside the clamp, so the pool reads Pool archive is DEGRADED, where before the amber rows gave no reason at all", + "Disk scope on phone: Health column is hidden and the pill no longer clips past the right edge, endurance and temperature stay visible", + "Dark mode at 1440 and tablet at 820 render the reason lines and pills legibly", + "Docker containers at 390 (44 rows, windowed): scrolled to the end in 250px wheel steps, each probed row moved about one scroll step as the window shifted, no spacer reached the viewport, estimate stayed 32px on the uniform table" + ], + "interactions": [ + "Switched the storage type scope to Physical disks on desktop and phone", + "Wheel-scrolled the windowed Docker containers table on phone and probed the rows at mid and lower viewport each step" + ] +} diff --git a/frontend-modern/src/features/platformPage/__tests__/PlatformWindowedRows.test.tsx b/frontend-modern/src/features/platformPage/__tests__/PlatformWindowedRows.test.tsx index 6aa10beb60..109ffa7e9e 100644 --- a/frontend-modern/src/features/platformPage/__tests__/PlatformWindowedRows.test.tsx +++ b/frontend-modern/src/features/platformPage/__tests__/PlatformWindowedRows.test.tsx @@ -263,4 +263,63 @@ describe('PlatformWindowedRows', () => { } } }); + + it('averages the mounted table rows so a few taller rows do not size every unmounted row', () => { + // Exception rows can carry a reason line under the name and sort first. + // Taking the tallest of the leading rows as every row's height made the + // spacers overshoot and the window jump as rows scrolled out. + const items = () => Array.from({ length: 41 }, (_, index) => index); + const originalRect = Element.prototype.getBoundingClientRect; + const originalVisibility = (Element.prototype as Element & { checkVisibility?: () => boolean }) + .checkVisibility; + Element.prototype.getBoundingClientRect = function (this: Element) { + const raw = (this as HTMLElement).getAttribute?.('data-height'); + const height = raw ? Number(raw) : 0; + return { + height, + width: 0, + top: 0, + left: 0, + right: 0, + bottom: height, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect; + }; + (Element.prototype as Element & { checkVisibility?: () => boolean }).checkVisibility = () => + true; + + try { + const { container } = render(() => ( + + + + {(item) => } + + +
+ )); + + const bottomSpacer = container.querySelector( + '[data-platform-window-spacer="bottom"]', + ) as HTMLElement; + // 8 mounted rows measure 2 x 60 + 6 x 30 = 300px, 37.5px each, so the + // 33 unmounted rows reserve 1237.5px rather than 33 x 60. + expect(bottomSpacer.style.height).toBe('1237.5px'); + } finally { + Element.prototype.getBoundingClientRect = originalRect; + if (originalVisibility) { + (Element.prototype as Element & { checkVisibility?: () => boolean }).checkVisibility = + originalVisibility; + } else { + Reflect.deleteProperty(Element.prototype, 'checkVisibility'); + } + } + }); }); diff --git a/frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts b/frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts index 673794ef0a..636bca23a8 100644 --- a/frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts +++ b/frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts @@ -23,6 +23,37 @@ const PHONE_WINDOW_SIZE = 36; // collapsing the estimate and desynchronising the window from the scroll // position, while uniform tables still measure their real row height. const MEASUREMENT_SAMPLE_SIZE = 3; + +const measureTallestSampledSibling = (anchor: HTMLElement): number => { + let measuredHeight = 0; + let sibling = anchor.nextElementSibling; + for (let sampled = 0; sampled < MEASUREMENT_SAMPLE_SIZE && sibling; sampled += 1) { + const height = sibling.getBoundingClientRect().height; + if (height > measuredHeight) measuredHeight = height; + sibling = sibling.nextElementSibling; + } + return measuredHeight; +}; + +// A table runway sits between a top and a bottom spacer row, so the mounted +// rows can be measured as a whole. Averaging them per item lets a few taller +// rows (a reason line under the name, an open detail row) count once instead +// of setting the height of every row off screen, which made the spacers +// overshoot and the window jump as rows scrolled out. Returns 0 when the +// anchor is not such a runway, so other surfaces keep the sampled estimate. +const measureRenderedRunwayItemHeight = (anchor: HTMLElement, renderedItems: number): number => { + if (anchor.tagName !== 'TR' || anchor.dataset.platformWindowSpacer !== 'top') return 0; + if (renderedItems <= 0) return 0; + let total = 0; + let sibling = anchor.nextElementSibling; + while (sibling && (sibling as HTMLElement).dataset.platformWindowSpacer !== 'bottom') { + total += sibling.getBoundingClientRect().height; + sibling = sibling.nextElementSibling; + } + if (!sibling || total <= 0) return 0; + return total / renderedItems; +}; + export interface PlatformWindowedItemsOptions { items: Accessor; estimatedItemHeight?: number; @@ -73,13 +104,9 @@ export function usePlatformWindowedItems(options: PlatformWindowedItemsOpt if (!anchor || isWindowedSurfaceHidden(anchor)) return; if (measureItems) { - let measuredHeight = 0; - let sibling = anchor.nextElementSibling; - for (let sampled = 0; sampled < MEASUREMENT_SAMPLE_SIZE && sibling; sampled += 1) { - const height = sibling.getBoundingClientRect().height; - if (height > measuredHeight) measuredHeight = height; - sibling = sibling.nextElementSibling; - } + const measuredHeight = + measureRenderedRunwayItemHeight(anchor, visibleItems().length) || + measureTallestSampledSibling(anchor); if (measuredHeight > 0) setEstimatedItemHeight(measuredHeight); } diff --git a/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx b/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx index 2e5405b673..84ba5737b2 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,51 @@ 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 riskPillClass = (row: TrueNASStorageTopologyRow): string => { - const mapped = mapTrueNASStorageStatus(row.resource); - if (mapped === 'attention') { +const issuePillClass = (issue: TrueNASStorageIssue): string => { + if (issue.status === 'attention') { return 'border-amber-300/50 bg-amber-500/10 text-amber-700 dark:text-amber-300'; } - if (mapped === 'offline') { + if (issue.status === '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'; }; -const RiskPill: Component<{ row: TrueNASStorageTopologyRow }> = (props) => ( - - {riskLabel(props.row)} - +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'; +}; + +// The visible reason is clamped 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('. ')}.`; + +// Healthy rows leave the Health column empty: the status dot already says +// healthy, and a column of green pills buries the few rows that are not. +const IssuePill: Component<{ issue: TrueNASStorageIssue | null }> = (props) => ( + + {(issue) => ( + + {issue().label} + + )} + ); +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 +279,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; @@ -322,9 +329,11 @@ export const getTrueNASStorageTopologyIndentClass = (depth: number): string => { return 'pl-8 sm:pl-16'; }; -const ResourceCell: Component<{ row: TrueNASStorageTopologyRow; detailToggle?: JSX.Element }> = ( - props, -) => { +const ResourceCell: Component<{ + row: TrueNASStorageTopologyRow; + issue: TrueNASStorageIssue | null; + detailToggle?: JSX.Element; +}> = (props) => { const displayStatus = () => getTrueNASResourceDisplayStatus(props.row.resource); const indicator = () => getSimpleStatusIndicator(displayStatus()); const name = () => resourceName(props.row.resource); @@ -344,6 +353,28 @@ const ResourceCell: Component<{ row: TrueNASStorageTopologyRow; detailToggle?: J > {name()} + + {(issue) => ( +
+ + 1}> + + + {issueScreenReaderText(issue())} +
+ )} +
); @@ -505,7 +536,7 @@ export const TrueNASStorageTopologyTable: Component<{ kind="badge" sort={sort} sortKey="health" - class={`${kindFilter() === 'disks' ? 'table-cell' : 'platform-table-phone-hidden'} md:w-[14%]`} + class="platform-table-phone-hidden md:w-[14%]" > @@ -516,6 +547,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 ( @@ -531,6 +563,7 @@ 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..407122816b 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 under the name', () => { + 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 reason = poolRow?.querySelector('[data-truenas-storage-reason]'); + const visible = [...(reason?.querySelectorAll('[aria-hidden="true"]') ?? [])].map( + (node) => node.textContent, + ); + + expect(visible).toEqual(['Pool archive is DEGRADED: one member of mirror-0 is faulted.', '+1']); + expect(reason?.querySelector('.sr-only')).toHaveTextContent( + 'Attention: Pool archive is DEGRADED: one member of mirror-0 is faulted. Device /dev/sdc has SMART test failures.', + ); + expect(reason).toHaveAttribute( + 'title', + 'Pool archive is DEGRADED: one member of mirror-0 is faulted.\nDevice /dev/sdc has SMART test failures.', + ); + expect(poolRow?.querySelector('[data-truenas-storage-health="attention"]')).toHaveTextContent( + 'Attention', + ); + expect(healthyRow?.querySelector('[data-truenas-storage-reason]')).toBeNull(); + 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); }