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 (#2659)
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. #2603 taught the Storage presenters to show that value as last known. Four surfaces still read the raw number as a current reading. Each now goes through the canonical `getPhysicalDiskTemperaturePresentation` / `isPhysicalDiskTemperatureCurrent` instead of adding a predicate. - **TrueNAS disk drawer** (`resourceDetailDrawerTrueNASModel.ts`): the Temperature row took the 55°C warning tone from a retained reading, and the summary listed it as a reading. It now reads "61°C (last known)" with the reason as title and no tone, and the summary leaves it out. A current reading keeps the existing tone. Replacing the fixed 55 with the alert policy is a separate follow-up. - **Drawer History fallback** (`resourceDetailDrawerMetricsHistoryModel.ts`): with no stored points, a retained reading was labelled "current". It now goes through the deferred-metric slot, so the legend says "last known" and the reason shows above the chart. A disk's value is read from `physicalDisk` beside its collection state. Websocket rows carry no top-level `temperature` for a disk, so on the TrueNAS page the fallback had shown "-" even for a current disk. - **TrueNAS storage table** (`TrueNASStorageTopologyTable.tsx`): the Temp cell showed a retained reading plainly, and hottest-first sorted it to the top of its group. It now renders muted with its reason, and the sort ranks it as no reading. - **Proxmox outdated sensor setup notice** (`sensorSetup.ts`): a retained reading counted as a temperature arriving, so the notice could hide for a SATA disk Pulse cannot read now. Verification: a regression test per surface fails on main and passes here. A private mock stack with a local-only overlay stamped one TrueNAS disk and one pve3 SATA disk with an unavailable temperature state ("host agent stopped reporting") and marked pve3's sensor payload legacy. Playwright at 1440×900 and 390×844 compared the change against the same session with main's runtime files. The receipt is `frontend-modern/browser-verification/294409f678589ef2.json`.
This commit is contained in:
@@ -7886,6 +7886,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`,
|
||||
|
||||
@@ -1121,6 +1121,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