mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Show retained disk temperatures as last known in drawers and tables
Normalization keeps a disk's last temperature when the current observation did not collect one, such as a disk in standby or a host agent past its reporting lease. PR #2603 taught the Storage presenters to show that value as last known. Four surfaces still read the raw number as a current reading: - The TrueNAS disk drawer's Temperature row took the 55C warning tone from a retained reading, and its summary listed it as a reading. The row now says "(last known)" with the reason as title and no tone. The summary drops it because it has no room for the reason. - The drawer History fallback fed a retained reading into smart_temp and labelled it "current" when no points were stored. It now goes through the deferred-metric slot, so the legend says "last known" and the reason shows above the chart. The disk's value is read from physicalDisk beside its collection state, because websocket rows carry no top-level temperature for a disk and showed "-" instead. - The TrueNAS storage table's Temp cell showed a retained reading plainly, and hottest-first sorted it to the top. It now renders muted with its reason, and the sort ranks it as no reading. - The Proxmox outdated sensor setup notice counted a retained reading as a temperature arriving, so it could hide for a SATA disk Pulse cannot read now. All four reuse getPhysicalDiskTemperaturePresentation or isPhysicalDiskTemperatureCurrent instead of adding a predicate.
This commit is contained in:
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
+60
@@ -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',
|
||||
|
||||
+37
@@ -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
|
||||
|
||||
+32
-1
@@ -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<string, GuestDrawerHistoryDeferredMetric> => {
|
||||
@@ -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<string, number | undefined> => {
|
||||
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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Resource> & { 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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<Show
|
||||
when={reading()?.current === false}
|
||||
fallback={<PlatformTableTemperatureValue value={value()} emptyText="-" />}
|
||||
>
|
||||
<span
|
||||
class={PHYSICAL_DISK_TEMPERATURE_LAST_KNOWN_CLASS}
|
||||
title={reading()?.title}
|
||||
data-temperature-reading="last-known"
|
||||
>
|
||||
<PlatformTableTemperatureValue value={value()} />
|
||||
<span class="sr-only">, last known</span>
|
||||
</span>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
const ISSUE_TONES: Record<TrueNASStorageIssue['status'], PlatformIssueTone> = {
|
||||
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`}
|
||||
>
|
||||
<PlatformTableTemperatureValue
|
||||
value={
|
||||
row.kind === 'disk'
|
||||
? row.resource.physicalDisk?.temperature
|
||||
: undefined
|
||||
}
|
||||
emptyText="-"
|
||||
/>
|
||||
<DiskTemperatureCell row={row} />
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden`}
|
||||
|
||||
@@ -262,6 +262,69 @@ describe('TrueNASStorageTopologyTable', () => {
|
||||
).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(() => (
|
||||
<TrueNASStorageTopologyTable
|
||||
resources={resources}
|
||||
scope={resources}
|
||||
emptyIcon={<span />}
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user