diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index bf1638080e..d837f96e3d 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -7883,6 +7883,23 @@ When the cell's value is a retained host-agent SMART temperature, it renders muted with a dotted underline and screen-reader "last known", as does a retained value in the guest card. Tooltip and Thermals rows say "(last known)", and the Thermals row carries the collection reason as its title. +The shared drawer History reads the same decision. For a physical disk, +`resourceDetailDrawerMetricsHistoryModel.ts` takes the reading from +`physicalDisk.temperature`, beside the collection state that qualifies it, +because websocket rows carry no top-level `temperature` for a disk. When that +state is set and is not `available` (an empty or absent state predates the +contract and stays current), `getResourceMetricsHistoryCurrentMetrics` offers +no current `smart_temp`. A positive retained reading then reaches +`getResourceMetricsHistoryDeferredMetrics` as a deferred metric: with no +history points the legend says "last known", never "current", and the reason +renders above the chart. +TrueNAS disk rows follow it too: the drawer's Temperature row reads +"(last known)" with the reason as title and no heat tone, and the one-line +summary leaves a retained reading out because it has no room for the reason. +The Proxmox outdated sensor setup notice (`features/platformPage/sensorSetup.ts`) +counts a SATA or SAS disk as having a temperature only when the reading is +current, so a retained one cannot hide the notice for a disk Pulse cannot read +now. The focused browser proofs are `frontend-modern/src/features/patrol/__tests__/patrolRunAcceptance.test.ts`, diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index bc3d781246..25fb124809 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -1122,6 +1122,13 @@ for the Physical Disks verdict. The heat reason follows any native TrueNAS alert text, and a hot disk's status dot turns warning though its source state stays `online`, because phones show only the dot. `truenasPageModel.test.ts` and `TrueNASStorageTopologyTable.test.tsx` pin it. +The Temp column reads the same collection state through +`getPhysicalDiskTemperaturePresentation`. A disk temperature that is not +current, such as a host agent's last reading kept on a merged disk row after +the agent stopped reporting, renders as last known: muted, with its reason as +the title and screen-reader "last known" text. The Temp sort ranks it as no +reading, and the tree sort orders each sibling group, so a retained value +never leads its group hottest first. The vSphere Datastores and Networks tables follow the same exception-first Health column. `getVmwareDatastoreIssue` and `getVmwareNetworkIssue` return nothing for a green row; otherwise the reasons are, in order, an impaired diff --git a/frontend-modern/browser-verification/294409f678589ef2.json b/frontend-modern/browser-verification/294409f678589ef2.json new file mode 100644 index 0000000000..726e7fa021 --- /dev/null +++ b/frontend-modern/browser-verification/294409f678589ef2.json @@ -0,0 +1,52 @@ +{ + "version": 1, + "base_sha": "41cea2ffab5ca2d39240d14138975bc4e902bb61", + "verified_at": "2026-10-07T11:28:58Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts", + "frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts", + "frontend-modern/src/features/platformPage/sensorSetup.ts", + "frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts": "e6e8b46a156df4a1d1b391de19f041c0a1f74eb7e2ecdb78fdfa773a4c336262", + "frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts": "fa1c743eaec09f424c80aa5d3aee9082817e54353a853b542620eb45792f5887", + "frontend-modern/src/features/platformPage/sensorSetup.ts": "5b92f1fc07b513a11f0e8f2654b0717baa4b99d742eae76368844731a5f89eb9", + "frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx": "dd7c5189a950adba606a7ea4d0d40233d158e965883135b6fe82b772a6dcf5cd" + }, + "routes": [ + "/truenas/storage?kind=disks", + "/proxmox/storage", + "/proxmox" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Vite dev server on this tree (origin/main 41cea2ffab plus the change) against a private PULSE_MOCK_MODE backend (8-node estate) built with a local-only go build -overlay (never committed). The overlay stamps TrueNAS disk sdb with physicalDisk.temperature 61 and collection.temperature unavailable 'host agent stopped reporting' (the shape a TrueNAS disk merged with a silent host agent row reaches), pins sdd to a current 58, gives pve3 SATA disk /dev/nvme2 the same retained state (51) and marks pve3's sensor payload legacySensorsFormat; /api/resources and the websocket rows carried those states", + "Before control in the same session (the four runtime files swapped to origin/main under the running Vite, then restored and compared byte for byte): sdb Temp cell plain 61.0°C sorting first in hottest-first, drawer Temperature row 61°C in the amber warning tone, TrueNAS summary 'SATA, Passed, 16.0 TB, 61°C', History with no smart_temp points showing '-', and no outdated sensor setup notice for pve3", + "After, TrueNAS storage disks view: sdb Temp cell '61.0°C' muted (rgb 71,85,105) with a dotted underline, title 'Last known reading, not current: host agent stopped reporting', data-temperature-reading=last-known and screen-reader ', last known'; current disks (sdd 58.0°C, sda 46.0°C) unchanged; no Attention reason for sdb", + "After, Temp sort: descending sdd 58, sdf 47, nvme0n1 46, sda 46, sde 42, nvme1n1 41, sdc 39, then sdb last; ascending sdc 39 ... sdd 58, then sdb last (a retained reading sorts as no reading)", + "After, sdb row drawer (Platform details > Show TrueNAS): Health Temperature row '61°C (last known)' in text-muted with the reason as its title; the collapsed TrueNAS summary reads 'SATA, Passed, 16.0 TB' with no temperature", + "After, sdb drawer History tab: with seeded points the legend shows the stored 53°C and the line 'Disk reading: Last known reading, not current: host agent stopped reporting.'; with the smart_temp series emptied by a Playwright route the legend reads 'Disk 61°C last known' (data-history-last-known=smart_temp, no 'current') with the same line; current disk sdd with no points reads 'Disk 58°C current'", + "After, /proxmox/storage: 'pve3 is using an older temperature monitoring setup that cannot read SATA/SAS disk temperatures. Re-run the node setup script to upgrade it.' notice shows at both widths (hidden on main); /proxmox Overview loads no physical disks so the notice cannot show there on main or after (pre-existing, chipped separately)", + "No page errors at 1440x900 or 390x844", + "Re-run after review edits (History model reads a disk's value only from physicalDisk, never a top-level fallback): every reading above was unchanged at both widths" + ], + "interactions": [ + "Opened /truenas/storage?kind=disks at 1440x900 and 390x844 and read every disk row's Temp cell (text, title, data attribute, computed colour and decoration)", + "Clicked the Temp column header twice at 1440x900 and read the disk row order for descending and ascending", + "Opened the sdb row drawer with 'Expand details for sdb', opened Platform details and clicked 'Show TrueNAS', read the summary and the Health Temperature row at both widths", + "Opened the drawer History tab with stored points, returned to Overview, then reopened History with the smart_temp series emptied, and read the Temperature legend and reason line at both widths; repeated for current disk sdd", + "Opened /proxmox and /proxmox/storage at both widths and read the outdated sensor setup notice", + "Ran the same Playwright pass against the origin/main runtime files as the before control, restored the change and confirmed the full git diff was byte-identical" + ] +} diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.branchcov0712.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.branchcov0712.test.ts index bae3d26c0e..9fe9a0ac3b 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.branchcov0712.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.branchcov0712.test.ts @@ -513,6 +513,66 @@ describe('getResourceMetricsHistoryCurrentMetrics branch coverage', () => { }); }); + it('offers a retained physical disk temperature only as last known, with its reason', () => { + // useUnifiedResources copies physicalDisk.temperature onto the resource. + // A disk in standby keeps its last reading under an unavailable state, so + // with no history points the chart must not label it current. + const disk = (state?: 'available' | 'unavailable', temperature = 47) => + baseResource({ + type: 'physical_disk', + metricsTarget: { resourceType: 'disk', resourceId: 'SERIAL-1' }, + temperature, + physicalDisk: { + devPath: '/dev/sdb', + temperature, + collection: state + ? { temperature: { state, source: 'smartctl', reason: 'disk is in standby' } } + : undefined, + }, + }); + + expect(getResourceMetricsHistoryCurrentMetrics(disk('unavailable'))).toMatchObject({ + smart_temp: undefined, + temperature: undefined, + }); + expect(getResourceMetricsHistoryDeferredMetrics(disk('unavailable')).smart_temp).toEqual({ + lastKnownValue: 47, + message: 'Last known reading, not current: disk is in standby.', + }); + // An unavailable state with no retained reading has nothing to offer. + expect(getResourceMetricsHistoryCurrentMetrics(disk('unavailable', 0)).smart_temp).toBe( + undefined, + ); + expect(getResourceMetricsHistoryDeferredMetrics(disk('unavailable', 0)).smart_temp).toBe( + undefined, + ); + + for (const current of [disk('available'), disk()]) { + expect(getResourceMetricsHistoryCurrentMetrics(current).smart_temp).toBe(47); + expect(getResourceMetricsHistoryDeferredMetrics(current).smart_temp).toBeUndefined(); + } + + // Websocket rows carry the reading only on physicalDisk, beside its state. + const websocketRow = (state: 'available' | 'unavailable') => { + const row = disk(state); + delete row.temperature; + return row; + }; + expect(getResourceMetricsHistoryCurrentMetrics(websocketRow('available')).smart_temp).toBe(47); + expect( + getResourceMetricsHistoryCurrentMetrics(websocketRow('unavailable')).smart_temp, + ).toBeUndefined(); + expect( + getResourceMetricsHistoryDeferredMetrics(websocketRow('unavailable')).smart_temp, + ).toMatchObject({ lastKnownValue: 47 }); + + // A top-level value never stands in for the disk's own missing reading, + // which the disk's collection state could not qualify. + const unpaired = disk('available'); + delete unpaired.physicalDisk!.temperature; + expect(getResourceMetricsHistoryCurrentMetrics(unpaired).smart_temp).toBeUndefined(); + }); + it('uses the source-aware host subset for API-only Proxmox node targets', () => { const resource = baseResource({ type: 'agent', diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerTrueNASModel.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerTrueNASModel.test.ts index 539aee7b89..1afd532170 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerTrueNASModel.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerTrueNASModel.test.ts @@ -361,6 +361,43 @@ describe('resourceDetailDrawerTrueNASModel', () => { ]); }); + it('shows a retained disk temperature as last known, with its reason and no heat tone', () => { + // A TrueNAS disk merged with a host agent's row keeps the agent's last + // reading after the agent stops reporting. It is not current, so it must + // not read as a disk running hot now, nor sit in the summary as a reading. + const disk = (state: 'available' | 'unavailable') => + baseResource({ + type: 'physical_disk', + displayName: 'sdc', + platformScopes: ['truenas'], + physicalDisk: { + devPath: '/dev/sdc', + diskType: 'sata', + sizeBytes: 24 * 1024 ** 4, + health: 'PASSED', + temperature: 61, + collection: { + temperature: { state, source: 'agent', reason: 'host agent stopped reporting' }, + }, + }, + }); + const temperatureRow = (resource: Resource) => + buildTrueNASDetailSections(resource) + .find((section) => section.label === 'Health') + ?.rows.find((row) => row.label === 'Temperature'); + + expect(temperatureRow(disk('unavailable'))).toMatchObject({ + value: '61°C (last known)', + tone: 'muted', + title: 'Last known reading, not current: host agent stopped reporting', + }); + expect(buildTrueNASDetailsSummary(disk('unavailable'))).toBe('SATA, Passed, 24.0 TB'); + + expect(temperatureRow(disk('available'))).toMatchObject({ value: '61°C', tone: 'warning' }); + expect(temperatureRow(disk('available'))?.title).toBeUndefined(); + expect(buildTrueNASDetailsSummary(disk('available'))).toBe('SATA, Passed, 24.0 TB, 61°C'); + }); + it('shows the vdev layout as the storage kind while topology stays the pool discriminator', () => { // Regression guard for 599c8e634. `topology` is the cross-provider // discriminator ('pool' | 'dataset' | ...); the ZFS layout belongs in diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts index 2fc02a3e4c..8d4ee55a4b 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts @@ -14,6 +14,10 @@ import { import type { HostGPUSensor } from '@/types/api'; import type { Resource } from '@/types/resource'; import { getDiskPercent, getMemoryPercent } from '@/types/resource'; +import { + getPhysicalDiskTemperaturePresentation, + isPhysicalDiskTemperatureCurrent, +} from '@/features/storageBackups/diskTemperaturePresentation'; import { asTrimmedString } from '@/utils/stringUtils'; import { getMemoryObservationPresentation } from '@/utils/memoryObservation'; import { getWorkloadGuestDiskStatusMessage } from '@/utils/workloadGuestPresentation'; @@ -174,6 +178,18 @@ const diskReadReason = (resource: Resource): string | undefined => ? resource.proxmox?.diskStatusReason || undefined : undefined; +// A physical disk's reading and its collection state both live on +// physicalDisk, so read them only together: websocket rows carry no top-level +// temperature for a disk, which only the REST mapper copies across. The +// reading may be a last known one kept while the current observation could +// not collect it, such as a disk in standby or a host agent past its +// reporting lease, and is then never current. +const resourceTemperature = (resource: Resource): number | undefined => + resource.physicalDisk ? resource.physicalDisk.temperature : resource.temperature; + +const isResourceTemperatureCurrent = (resource: Resource): boolean => + isPhysicalDiskTemperatureCurrent(resource.physicalDisk?.collection); + export const getResourceMetricsHistoryDeferredMetrics = ( resource: Resource, ): Record => { @@ -197,6 +213,19 @@ export const getResourceMetricsHistoryDeferredMetrics = ( message: getWorkloadGuestDiskStatusMessage(reason), }; } + if (!isResourceTemperatureCurrent(resource)) { + const temperature = resourceTemperature(resource); + const retained = getPhysicalDiskTemperaturePresentation({ + temperature: temperature ?? 0, + collection: resource.physicalDisk?.collection, + }); + if (retained?.title) { + metrics.smart_temp = { + lastKnownValue: temperature, + message: `${retained.title}.`, + }; + } + } return metrics; }; @@ -204,7 +233,9 @@ export const getResourceMetricsHistoryCurrentMetrics = ( resource: Resource, ): Record => { const diskPercent = resource.disk ? finiteMetric(getDiskPercent(resource)) : undefined; - const temperature = finiteMetric(resource.temperature); + const temperature = isResourceTemperatureCurrent(resource) + ? finiteMetric(resourceTemperature(resource)) + : undefined; const memory = memoryReading(resource); return { cpu: finiteMetric(resource.cpu?.current), diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts index f8a666aa5d..57d3fca8c9 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts @@ -20,6 +20,7 @@ import { type DetailSection, type DetailValueTone, } from '@/components/shared/detailSectionModel'; +import { getPhysicalDiskTemperaturePresentation } from '@/features/storageBackups/diskTemperaturePresentation'; import { hasImpairedResourceSource } from '@/utils/resourceSourceHealth'; export type ResourceDetailDrawerTrueNASRowTone = DetailValueTone; @@ -51,11 +52,6 @@ const formatPercent = (percent?: number): string | null => { return `${percent.toFixed(percent >= 10 ? 1 : 2)}%`; }; -const formatTemperature = (celsius?: number): string | null => { - if (typeof celsius !== 'number' || !Number.isFinite(celsius) || celsius <= 0) return null; - return `${celsius.toFixed(0)}°C`; -}; - const formatDurationSeconds = (seconds?: number): string | null => { const value = asPositiveNumber(seconds); if (!value) return null; @@ -396,6 +392,35 @@ const formatDiskHours = (hours?: number): string | null => { return `${formatDetailIntegerValue(value) ?? value.toFixed(0)}h`; }; +const diskTemperature = (disk: ResourcePhysicalDiskMeta) => + getPhysicalDiskTemperaturePresentation({ + temperature: disk.temperature ?? 0, + collection: disk.collection, + }); + +// A retained reading, such as a silent host agent's on a merged disk row, is +// not current: it takes no heat tone and its title says why. +const diskTemperatureRow = ( + disk: ResourcePhysicalDiskMeta, +): ResourceDetailDrawerTrueNASRow | null => { + const reading = diskTemperature(disk); + if (!reading) return null; + if (!reading.current) { + return row('Temperature', `${reading.label} (last known)`, { + title: reading.title, + tone: 'muted', + }); + } + return row('Temperature', reading.label, { + tone: (disk.temperature ?? 0) >= 55 ? 'warning' : 'default', + }); +}; + +const currentDiskTemperatureLabel = (disk: ResourcePhysicalDiskMeta): string | null => { + const reading = diskTemperature(disk); + return reading?.current ? reading.label : null; +}; + const buildTrueNASDiskSections = ( resource: Resource, disk: ResourcePhysicalDiskMeta, @@ -414,9 +439,7 @@ const buildTrueNASDiskSections = ( const healthRows = compactRows([ row('Health', normalizeDelimitedLabel(disk.health), { tone: diskStateTone(disk) }), - row('Temperature', formatTemperature(disk.temperature), { - tone: disk.temperature && disk.temperature >= 55 ? 'warning' : 'default', - }), + diskTemperatureRow(disk), row( 'Wearout', disk.wearout === undefined || disk.wearout < 0 ? null : formatPercent(disk.wearout), @@ -795,7 +818,8 @@ export const buildTrueNASDetailsSummary = (resource: Resource): string | null => diskTypeLabel(disk.diskType), normalizeDelimitedLabel(disk.health), formatDetailBytesValue(disk.sizeBytes), - formatTemperature(disk.temperature), + // The summary has no room to say a retained reading is not current. + currentDiskTemperatureLabel(disk), ].filter((value): value is string => Boolean(value)); return summary.length > 0 ? summary.join(', ') : null; } diff --git a/frontend-modern/src/features/platformPage/sensorSetup.test.ts b/frontend-modern/src/features/platformPage/sensorSetup.test.ts index 9d42cbecc9..3b2ada61d9 100644 --- a/frontend-modern/src/features/platformPage/sensorSetup.test.ts +++ b/frontend-modern/src/features/platformPage/sensorSetup.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { Resource } from '@/types/resource'; +import type { PhysicalDiskCollectionStatus, Resource } from '@/types/resource'; import { collectOutdatedSensorSetupNodes } from './sensorSetup'; const node = (over: Partial & { proxmox?: Resource['proxmox'] }): Resource => @@ -11,6 +11,7 @@ const disk = (over: { nodeName?: string; diskType?: string; temperature?: number; + collection?: PhysicalDiskCollectionStatus; }): Resource => ({ id: over.id ?? 'disk-1', @@ -20,6 +21,7 @@ const disk = (over: { physicalDisk: { diskType: over.diskType ?? 'sata', temperature: over.temperature ?? 0, + collection: over.collection, }, platformData: over.nodeName ? { proxmox: { nodeName: over.nodeName } } : {}, }) as unknown as Resource; @@ -80,6 +82,28 @@ describe('collectOutdatedSensorSetupNodes', () => { ).toEqual([]); }); + it('treats a retained temperature that is not current as missing', () => { + // Normalization keeps a disk's last known temperature when the current + // observation did not collect one, such as after a linked host agent + // stopped reporting. The disk cannot be read now, so the notice stays. + const retained = (state: 'available' | 'unavailable' | 'missing') => + disk({ + nodeName: 'pve1', + diskType: 'sata', + temperature: 34, + collection: { + temperature: { state, source: 'agent', reason: 'host agent stopped reporting' }, + }, + }); + expect(collectOutdatedSensorSetupNodes([legacyNode()], [retained('unavailable')])).toEqual([ + { id: 'node-1', name: 'pve1' }, + ]); + expect(collectOutdatedSensorSetupNodes([legacyNode()], [retained('missing')])).toEqual([ + { id: 'node-1', name: 'pve1' }, + ]); + expect(collectOutdatedSensorSetupNodes([legacyNode()], [retained('available')])).toEqual([]); + }); + it('ignores legacy nodes with only NVMe disks missing temperatures', () => { expect( collectOutdatedSensorSetupNodes( diff --git a/frontend-modern/src/features/platformPage/sensorSetup.ts b/frontend-modern/src/features/platformPage/sensorSetup.ts index 04c1378370..af8ff285b2 100644 --- a/frontend-modern/src/features/platformPage/sensorSetup.ts +++ b/frontend-modern/src/features/platformPage/sensorSetup.ts @@ -1,5 +1,6 @@ import type { Resource } from '@/types/resource'; import { matchesPhysicalDiskNode } from '@/components/Storage/diskResourceUtils'; +import { isPhysicalDiskTemperatureCurrent } from '@/features/storageBackups/diskTemperaturePresentation'; // Disk transports whose temperatures only arrive via SMART (the pulse-sensors // wrapper). NVMe temps come from kernel hwmon and work even on legacy setups. @@ -18,7 +19,9 @@ const isSMARTOnlyDiskWithoutTemperature = (disk: Resource): boolean => { if (!meta) return false; const diskType = (meta.diskType || '').trim().toLowerCase(); if (!SMART_ONLY_DISK_TYPES.has(diskType)) return false; - return (meta.temperature ?? 0) <= 0; + // A last known temperature kept for a disk that could not be read now, such + // as after a linked host agent stopped reporting, is not one arriving. + return (meta.temperature ?? 0) <= 0 || !isPhysicalDiskTemperatureCurrent(meta.collection); }; // Flags PVE nodes whose SSH temperature monitoring still runs the pre-rc.6 diff --git a/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx b/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx index 27c9caf59a..baa629330f 100644 --- a/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx +++ b/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx @@ -37,6 +37,10 @@ import { createPlatformResourceLabelResolver, getPlatformResourceDetailRowClass, } from '@/features/platformPage/PlatformResourceDetailTableRow'; +import { + PHYSICAL_DISK_TEMPERATURE_LAST_KNOWN_CLASS, + getPhysicalDiskTemperaturePresentation, +} from '@/features/storageBackups/diskTemperaturePresentation'; import { useAlertsActivation } from '@/stores/alertsActivation'; import type { Resource } from '@/types/resource'; import { @@ -200,6 +204,40 @@ const DiskEnduranceCell: Component<{ row: TrueNASStorageTopologyRow }> = (props) ); }; +// A disk row may carry a last known temperature it did not collect now, such +// as a host agent's reading after the agent stopped reporting on a merged disk. +const diskTemperatureReading = (row: TrueNASStorageTopologyRow) => { + const disk = row.kind === 'disk' ? row.resource.physicalDisk : undefined; + return disk + ? getPhysicalDiskTemperaturePresentation({ + temperature: disk.temperature ?? 0, + collection: disk.collection, + }) + : null; +}; + +// A retained reading reads as last known, never as the disk's temperature now. +const DiskTemperatureCell: Component<{ row: TrueNASStorageTopologyRow }> = (props) => { + const reading = () => diskTemperatureReading(props.row); + const value = () => + props.row.kind === 'disk' ? props.row.resource.physicalDisk?.temperature : undefined; + return ( + } + > + + + , last known + + + ); +}; + const ISSUE_TONES: Record = { attention: 'warning', offline: 'danger', @@ -253,9 +291,10 @@ const getTrueNASStorageSortValue = ( case 'endurance': return row.kind === 'disk' ? (diskEndurance(row)?.value ?? null) : null; case 'temp': { - if (row.kind !== 'disk') return null; - const temperature = row.resource.physicalDisk?.temperature; - return typeof temperature === 'number' && Number.isFinite(temperature) ? temperature : null; + // Hottest first ranks current readings only; a last known one sorts as + // no reading. + const reading = diskTemperatureReading(row); + return reading?.current ? (row.resource.physicalDisk?.temperature ?? null) : null; } case 'health': return HEALTH_SORT_RANK[ @@ -575,14 +614,7 @@ export const TrueNASStorageTopologyTable: Component<{ kindFilter() === 'disks' ? 'table-cell' : 'hidden lg:table-cell' } text-base-content`} > - + { ).toEqual(['sda', 'nvme0n1']); }); + it('shows a retained disk temperature as last known and sorts it as no reading', async () => { + // A TrueNAS disk merged with a host agent's row keeps the agent's last + // reading after the agent stops reporting, under an unavailable state. + const disk = (id: string, temperature: number, state: 'available' | 'unavailable') => + makeStorageResource({ + id, + type: 'physical_disk', + name: id, + storage: undefined, + physicalDisk: { + devPath: `/dev/${id}`, + serial: `serial-${id}`, + diskType: 'sata', + temperature, + collection: { + temperature: { state, source: 'agent', reason: 'host agent stopped reporting' }, + }, + }, + }); + const resources = [ + disk('sda', 64, 'unavailable'), + disk('sdb', 41, 'available'), + disk('sdc', 38, 'available'), + ]; + const { container } = render(() => ( + } + emptyTitle="No storage" + emptyDescription="No storage" + showToolbar={false} + /> + )); + const row = (id: string) => container.querySelector(`[data-truenas-storage-resource="${id}"]`); + const order = () => + [...container.querySelectorAll('[data-truenas-storage-resource]')].map((element) => + element.getAttribute('data-truenas-storage-resource'), + ); + + try { + const retained = row('sda')?.querySelector('[data-temperature-reading="last-known"]'); + expect(retained).toHaveTextContent('64.0°C, last known'); + expect(retained).toHaveAttribute( + 'title', + 'Last known reading, not current: host agent stopped reporting', + ); + expect(retained).toHaveClass('text-muted'); + // 64C is over the factory SATA trigger, but a retained reading is not heat. + expect(row('sda')?.querySelector('[data-truenas-storage-health]')).toBeNull(); + expect(row('sdb')?.querySelector('[data-temperature-reading="last-known"]')).toBeNull(); + expect(row('sdb')).toHaveTextContent('41.0°C'); + + // Hottest first ranks current readings only; the retained one stays last. + await fireEvent.click(screen.getByRole('columnheader', { name: /Temp/ })); + expect(order()).toEqual(['sdb', 'sdc', 'sda']); + await fireEvent.click(screen.getByRole('columnheader', { name: /Temp/ })); + expect(order()).toEqual(['sdc', 'sdb', 'sda']); + } finally { + window.localStorage.clear(); + } + }); + it('follows a disk temperature trigger the user raised in Alerts', async () => { const getConfig = vi.spyOn(AlertsAPI, 'getConfig').mockResolvedValue({ enabled: true,