Show the outdated sensor setup notice on every Proxmox tab (#2668)

Since the per-tab Proxmox queries landed, only the Storage tab loads
`physical_disk` rows. So the outdated sensor setup notice, which judged
nodes from the active tab's disk rows, never showed on Overview.

The registry now derives the verdict on each Proxmox node
(`proxmox.sensorSetupOutdated`, an explicit true or false on every node)
right after child counts resolve each disk's canonical parent. A node is
flagged when its temperature payload is available and in the legacy
`sensors -j` format and a SATA/SAS disk under it has no reading
collected now (`diskinventory.TemperatureCollected`). The page lists
flagged nodes from the rows Overview, Storage, Replication and Backups
already load, so no tab hydrates disks for it.

Rule fixes that came with the move:
- Proxmox's disk inventory types SATA/SAS disks as `hdd`/`ssd`, so the
old `sata`/`sas`-only check never matched a node without a host agent.
- A retained reading no longer counts as current (carried over from
#2659's frontend rule).
- The verdict is sent as explicit false, because the browser's
field-by-field facet merge would otherwise keep an earlier true after
the condition clears.

Stacked on #2659 (shares its `sensorSetup.ts` edit, which this
replaces).

Verified on a private mock stack at 1440px and 390px:
- Overview, Storage, Replication and Backups show the notice for
legacy-format nodes with an `hdd` disk without a reading, a retained
SATA reading, and a SATA disk without a reading.
- Overview makes no `physical_disk` request.
- With `main`'s frontend swapped in, Overview showed nothing and Storage
named one node of three.
- A node whose disk starts reporting drops out on the next websocket
update without a reload.
This commit is contained in:
rcourtman
2026-10-07 15:38:27 +01:00
committed by GitHub
19 changed files with 559 additions and 378 deletions
@@ -6666,6 +6666,11 @@ icon/content layout, and action-link chrome. The
outdated-sensor notices, and the
`platform-inline-notice-local-amber-shell` pattern guard blocks future
`platformPage` files from reintroducing page-local amber notice shells.
A notice's render predicate reads what its page hydrates on every tab the
notice renders on. `collectOutdatedSensorSetupNodes` lists the PVE nodes that
carry the registry's `proxmox.sensorSetupOutdated` verdict and takes no disk
rows, because only the Proxmox Storage tab loads them; a predicate over them
left the notice silent on Overview.
Alert incident-event filter containers, labels, and chips must now route
through the shared presentation helpers in
@@ -7899,10 +7904,12 @@ 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 Proxmox outdated sensor setup notice reads the registry's per-node
`proxmox.sensorSetupOutdated` verdict, which counts a SATA or SAS disk as having
a temperature only when the reading was collected now
(`diskinventory.TemperatureCollected` in
`internal/unifiedresources/proxmox_sensor_setup.go`), 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`,
@@ -944,6 +944,15 @@ Patrol coverage, schedule, finding, and approval state belongs on Patrol-owned
surfaces or explicit Patrol affordances; Proxmox overview must not treat it as
backup coverage, restore readiness, PBS verification/protection proof, or a
replacement for the Proxmox Backups tab and workload Backup column.
The outdated sensor setup notice above the Proxmox tabs is temperature
guidance, not storage or recovery evidence. It reads the registry's per-node
`proxmox.sensorSetupOutdated` verdict from the node rows the active tab already
loads (Overview, Storage, Replication and Backups load nodes; Ceph and Mail do
not, so it does not render there), so the Overview query must not add
`physical_disk` rows to feed it; the derived `ProxmoxData` flag opens no storage
or recovery path.
`ProxmoxPageSurface.contract.test.tsx` pins the notice on Overview with no disk
rows and an Overview query without `physical_disk`.
The Proxmox inline estate totals are likewise an adjacent frontend-primitives
and unified-resource projection, not backup or recovery evidence.
`ProxmoxPageSurface` must flow the already-loaded workload inventory into the
@@ -2987,6 +2987,36 @@ or force every provider type back into the active route payload. Docker,
Kubernetes, TrueNAS, and VMware surface contract tests pin their route query
maps, and the shared hook test pins facet normalization.
### Proxmox node verdicts over disks arrive on the node
The Proxmox page hydrates one query per workflow, and only its Storage tab
loads `physical_disk` rows, so a node verdict that depends on the node's disks
is derived in the registry and carried on the node, never rebuilt in the page
from disk rows. `ProxmoxData.SensorSetupOutdated` (`proxmox.sensorSetupOutdated`
on REST and websocket rows) is that verdict for the outdated sensor setup
notice. `refreshProxmoxSensorSetupLocked`
(`internal/unifiedresources/proxmox_sensor_setup.go`) runs after
`buildChildCounts` on every ingest path and gives every Proxmox node an
explicit true or false (other resources omit it), because browser facet merges
read an omitted field as a partial snapshot and would keep an earlier true. It
is true when the node's temperature payload is available and in the legacy
`sensors -j` format (`models.Temperature.LegacySensorsFormat`) and a disk whose
canonical parent is the node has no reading collected now
(`diskinventory.TemperatureCollected`) and a type whose temperature a PVE node
gets only through SMART: `sata`, `sas`, or the `hdd` and `ssd` form factors
Proxmox's own inventory reports. Proxmox also types a non-rotational USB device
`ssd`, so a USB device without SMART counts. A retained reading does not count
as current, including one kept while a host agent reports the disk in standby;
a reading a linked host agent collects now clears the disk. NVMe disks never
set it, because kernel hwmon reports them even on a legacy setup, and a host
agent the registry has not linked to the node contributes no disks. The
verdict reflects the registry's node and disk state at each ingest.
`TestProxmoxNodeSensorSetupOutdatedFromItsDisks` and
`TestProxmoxNodeSensorSetupOutdatedFollowsWhereTheReadingArrives` in
`internal/unifiedresources/registry_test.go` pin the rule, the linked-agent
case, the explicit false on the wire and the presented JSON;
`resourceStateAdapters.test.ts` pins that a false clears a merged true.
### Canonical REST facets preserve realtime workload evidence
The frontend REST projection retains the complete source-authored Proxmox
@@ -0,0 +1,46 @@
{
"version": 1,
"base_sha": "e02edd9989c88fbe6e5459afdf488c73538ddd24",
"verified_at": "2026-10-07T12:56:24Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/platformPage/sensorSetup.ts",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx",
"frontend-modern/src/hooks/useUnifiedResources.ts",
"frontend-modern/src/types/resource.ts"
],
"content_sha256": {
"frontend-modern/src/features/platformPage/sensorSetup.ts": "18e4da01eb9206bce83a2e7d2fbb9f97313685c5655d620092a9e0ca51804ef5",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "db074960c69c59bb030814a04028a50ee5b347b7cf16b04b4c6edea708ffd782",
"frontend-modern/src/hooks/useUnifiedResources.ts": "77cc56a11213f951e051ceabd05bc2bf3161adedadc3586f2badee98bf1056c3",
"frontend-modern/src/types/resource.ts": "37e3ddb2e1334472a280e0ef93053292002567acabf84aace9fed1735a763b57"
},
"routes": [
"/proxmox",
"/proxmox/storage",
"/proxmox/replication",
"/proxmox/backups"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Private mock stack (8-node estate) built from this tree with a local-only generator overlay: pve2, pve3 and pve5 report the legacy sensors -j payload; pve2 has a Proxmox-inventory hdd disk with no temperature, pve3 a SATA disk whose reading is retained (host agent stopped reporting), pve5 a SATA disk with no temperature.",
"Overview, Storage, Replication and Backups each show the warning notice naming pve2, pve3 and pve5 at both viewports. The Overview query is type=agent,vm,system-container,oci-container and no physical_disk request is made on Overview, Replication or Backups; only Storage requests disk rows.",
"Before control on the same stack with origin/main's ProxmoxPageSurface.tsx and sensorSetup.ts swapped in: no notice on Overview, Replication or Backups, and Storage names only pve5 (hdd type and retained reading missed).",
"Websocket frames carry sensorSetupOutdated true on every tab. Fresh desktop load: no console errors and no 5xx responses. Notice text fits the 390px viewport without overflow; the node table stays below it in reading order.",
"Mock data only; not a real pre-rc.6 SSH node, installed upgrade or release acceptance."
],
"interactions": [
"Navigated each tab by URL at 1440x900 and 390x844 and waited 20s on Overview to outlast websocket row merges; the notice stayed.",
"Live clearing without reload: switching pve5's disk to a current 37C reading dropped pve5 from the notice (3 nodes to pve2, pve3) on the next websocket update. Control with the earlier omitempty backend kept pve5 for 90s.",
"Open Infrastructure settings link targets /settings/infrastructure (contract test)."
]
}
@@ -1,208 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource';
import { collectOutdatedSensorSetupNodes } from '../sensorSetup';
// Branch-coverage tests for the two UNEXPORTED helpers inside sensorSetup.ts:
// - getNodeName (3-way `||` fallback chain + `.trim()`)
// - isSMARTOnlyDiskWithoutTemperature (no-meta guard, diskType normalize,
// SMART-set membership, `temperature ?? 0`)
// Both helpers are private, so every case drives them through the public
// `collectOutdatedSensorSetupNodes` and asserts the concrete observable shape.
//
// Conventions mirror the sibling `sensorSetup.test.ts` fixtures. Disks are
// linked to nodes either by `platformData.proxmox.nodeName` (the name-matching
// path, used to prove `getNodeName` resolution) or by `parentId` (which makes
// `matchesPhysicalDiskNode` short-circuit true, used to isolate the SMART-only
// predicate from name resolution).
type LegacyNodeOver = {
id?: string;
name?: string;
proxmox?: Partial<Resource['proxmox']>;
};
const legacyNode = (over: LegacyNodeOver = {}): Resource =>
({
id: over.id ?? 'node-1',
name: over.name ?? '',
displayName: over.name ?? '',
type: 'node',
proxmox: {
temperatureDetails: { available: true, legacySensorsFormat: true },
...(over.proxmox ?? {}),
},
}) as unknown as Resource;
type DiskOver = {
id?: string;
parentId?: string;
nodeName?: string;
diskType?: string;
temperature?: number;
noMeta?: boolean;
};
const disk = (over: DiskOver = {}): Resource =>
({
id: over.id ?? 'disk-1',
name: 'disk',
displayName: 'disk',
type: 'physical_disk',
parentId: over.parentId,
physicalDisk: over.noMeta
? undefined
: {
diskType: over.diskType,
temperature: over.temperature,
},
platformData: over.nodeName ? { proxmox: { nodeName: over.nodeName } } : {},
}) as unknown as Resource;
describe('getNodeName (via collectOutdatedSensorSetupNodes)', () => {
it('falls back to proxmox.nodeName when proxmox.node is absent', () => {
const node = legacyNode({ id: 'n-nodeName', proxmox: { nodeName: 'bravo' } });
const result = collectOutdatedSensorSetupNodes(
[node],
// Disk resolves to the node by the nodeName-derived identity, proving
// getNodeName returned 'bravo' (not '' and not node.name).
[disk({ id: 'd1', nodeName: 'bravo', diskType: 'sata', temperature: 0 })],
);
expect(result).toStrictEqual([{ id: 'n-nodeName', name: 'bravo' }]);
});
it('falls back to the resource name when both proxmox.node and nodeName are absent', () => {
const node = legacyNode({ id: 'n-name', name: 'charlie' });
const result = collectOutdatedSensorSetupNodes(
[node],
[disk({ id: 'd2', nodeName: 'charlie', diskType: 'sata', temperature: 0 })],
);
expect(result).toStrictEqual([{ id: 'n-name', name: 'charlie' }]);
});
it('trims surrounding whitespace from proxmox.node before using it as the node identity', () => {
const node = legacyNode({ id: 'n-trim', proxmox: { node: ' delta ' } });
const result = collectOutdatedSensorSetupNodes(
[node],
// matchesPhysicalDiskNode lowercases/trims both sides, so 'delta' must be
// the trimmed getNodeName output for the disk to match.
[disk({ id: 'd3', nodeName: 'delta', diskType: 'sata', temperature: 0 })],
);
expect(result).toStrictEqual([{ id: 'n-trim', name: 'delta' }]);
});
it('returns the empty string when every name source is blank, observable via a parentId-linked disk', () => {
// No proxmox.node, no proxmox.nodeName, name is whitespace -> getNodeName
// hits the final `|| ''` arm and trims to ''. The disk is matched by
// parentId so matchesPhysicalDiskNode short-circuits true regardless of the
// (empty) name, letting the flagged node surface with name: ''.
const node = legacyNode({ id: 'n-empty', name: ' ' });
const result = collectOutdatedSensorSetupNodes(
[node],
[disk({ id: 'd4', parentId: 'n-empty', diskType: 'sata', temperature: 0 })],
);
expect(result).toStrictEqual([{ id: 'n-empty', name: '' }]);
});
});
describe('isSMARTOnlyDiskWithoutTemperature (via collectOutdatedSensorSetupNodes)', () => {
it('returns false for a disk with no physicalDisk metadata, so the node is not flagged', () => {
// Disk is linked to the node by parentId (match guaranteed true); the only
// reason the node drops out is the `if (!meta) return false` guard.
const node = legacyNode({ id: 'n-nometa', proxmox: { node: 'pve' } });
const result = collectOutdatedSensorSetupNodes(
[node],
[disk({ id: 'd-nometa', parentId: 'n-nometa', noMeta: true })],
);
expect(result).toStrictEqual([]);
});
it('returns false when diskType is missing, so a matched disk does not flag the node', () => {
// diskType undefined -> `(meta.diskType || '')` -> '' not in the SMART-only
// set. parentId link isolates the cause to the diskType check.
const node = legacyNode({ id: 'n-notype', proxmox: { node: 'pve' } });
const result = collectOutdatedSensorSetupNodes(
[node],
[disk({ id: 'd-notype', parentId: 'n-notype', temperature: 0 })],
);
expect(result).toStrictEqual([]);
});
it('normalizes a whitespace-padded uppercase SAS diskType and flags the node', () => {
// Exercises `diskType.trim().toLowerCase()` -> 'sas' (in the SMART-only
// set) and temperature 0 -> `(0 ?? 0) <= 0` true.
const node = legacyNode({ id: 'n-sas', proxmox: { node: 'pve' } });
const result = collectOutdatedSensorSetupNodes(
[node],
[
disk({
id: 'd-sas',
parentId: 'n-sas',
diskType: ' SAS ',
temperature: 0,
}),
],
);
expect(result).toStrictEqual([{ id: 'n-sas', name: 'pve' }]);
});
it('uses 0 in place of an undefined temperature and flags the node', () => {
// temperature is omitted entirely -> `(meta.temperature ?? 0)` takes the
// nullish right-hand side (0) -> `<= 0` true.
const node = legacyNode({ id: 'n-undef-temp', proxmox: { node: 'pve' } });
const result = collectOutdatedSensorSetupNodes(
[node],
[disk({ id: 'd-undef-temp', parentId: 'n-undef-temp', diskType: 'sata' })],
);
expect(result).toStrictEqual([{ id: 'n-undef-temp', name: 'pve' }]);
});
it('uses 0 in place of an explicit null temperature and flags the node', () => {
// Deliberately-malformed payload: temperature typed as number | undefined
// but carrying null. The `?? 0` coalesces it to 0 -> `<= 0` true.
const node = legacyNode({ id: 'n-null-temp', proxmox: { node: 'pve' } });
const nullTempDisk = {
id: 'd-null-temp',
name: 'disk',
displayName: 'disk',
type: 'physical_disk',
parentId: 'n-null-temp',
physicalDisk: { diskType: 'sata', temperature: null },
} as unknown as Resource;
const result = collectOutdatedSensorSetupNodes([node], [nullTempDisk]);
expect(result).toStrictEqual([{ id: 'n-null-temp', name: 'pve' }]);
});
it('flags the node for a SATA disk reporting a negative temperature', () => {
// Concrete negative value exercises the `<= 0` true arm (not the ?? arm).
const node = legacyNode({ id: 'n-neg-temp', proxmox: { node: 'pve' } });
const result = collectOutdatedSensorSetupNodes(
[node],
[disk({ id: 'd-neg-temp', parentId: 'n-neg-temp', diskType: 'sata', temperature: -5 })],
);
expect(result).toStrictEqual([{ id: 'n-neg-temp', name: 'pve' }]);
});
it('does not flag the node when a SMART-only disk reports a positive temperature', () => {
// SAS at 41 degrees -> `(41 ?? 0) <= 0` is false.
const node = legacyNode({ id: 'n-warm', proxmox: { node: 'pve' } });
const result = collectOutdatedSensorSetupNodes(
[node],
[disk({ id: 'd-warm', parentId: 'n-warm', diskType: 'sas', temperature: 41 })],
);
expect(result).toStrictEqual([]);
});
it('evaluates the no-meta guard on the first disk but still flags the node via a second matching disk', () => {
// Drives the `!meta` branch on d-bad, then `.some()` advances to d-good
// which is SMART-only without a temperature -> node flagged.
const node = legacyNode({ id: 'n-mixed', proxmox: { node: 'pve' } });
const result = collectOutdatedSensorSetupNodes(
[node],
[
disk({ id: 'd-bad', parentId: 'n-mixed', noMeta: true }),
disk({ id: 'd-good', parentId: 'n-mixed', diskType: 'sata', temperature: 0 }),
],
);
expect(result).toStrictEqual([{ id: 'n-mixed', name: 'pve' }]);
});
});
@@ -1,150 +1,57 @@
import { describe, expect, it } from 'vitest';
import type { PhysicalDiskCollectionStatus, Resource } from '@/types/resource';
import type { Resource } from '@/types/resource';
import { collectOutdatedSensorSetupNodes } from './sensorSetup';
const node = (over: Partial<Resource> & { proxmox?: Resource['proxmox'] }): Resource =>
({ id: 'node-1', name: 'pve1', type: 'node', ...over }) as Resource;
({ id: 'node-1', name: 'pve1', type: 'agent', ...over }) as Resource;
const disk = (over: {
id?: string;
parentId?: string;
nodeName?: string;
diskType?: string;
temperature?: number;
collection?: PhysicalDiskCollectionStatus;
}): Resource =>
({
id: over.id ?? 'disk-1',
name: 'disk',
type: 'physical_disk',
parentId: over.parentId,
physicalDisk: {
diskType: over.diskType ?? 'sata',
temperature: over.temperature ?? 0,
collection: over.collection,
},
platformData: over.nodeName ? { proxmox: { nodeName: over.nodeName } } : {},
}) as unknown as Resource;
const legacyNode = (over: Partial<Resource> = {}): Resource =>
const flaggedNode = (over: Partial<Resource> = {}): Resource =>
node({
proxmox: {
node: 'pve1',
temperatureDetails: { available: true, legacySensorsFormat: true },
sensorSetupOutdated: true,
},
...over,
});
describe('collectOutdatedSensorSetupNodes', () => {
it('flags a legacy-format node with a SATA disk missing a temperature', () => {
const result = collectOutdatedSensorSetupNodes(
[legacyNode()],
[disk({ nodeName: 'pve1', diskType: 'sata' })],
);
expect(result).toEqual([{ id: 'node-1', name: 'pve1' }]);
it('lists a node carrying the registry verdict without any disk rows', () => {
expect(collectOutdatedSensorSetupNodes([flaggedNode()])).toEqual([
{ id: 'node-1', name: 'pve1' },
]);
});
it('matches disks to nodes by parentId as well as node name', () => {
const result = collectOutdatedSensorSetupNodes(
[legacyNode()],
[disk({ parentId: 'node-1', diskType: 'sas' })],
);
expect(result).toEqual([{ id: 'node-1', name: 'pve1' }]);
});
it('ignores nodes whose payload is the wrapper format', () => {
const wrapperNode = node({
proxmox: { node: 'pve1', temperatureDetails: { available: true } },
});
expect(collectOutdatedSensorSetupNodes([wrapperNode], [disk({ nodeName: 'pve1' })])).toEqual(
[],
);
});
it('ignores legacy nodes when temperature collection did not succeed', () => {
const unavailableNode = node({
it('leaves a legacy-format node the registry did not flag', () => {
// The registry clears the verdict when every SATA/SAS disk under the node
// has a current temperature, for example from a linked host agent.
const reading = node({
proxmox: {
node: 'pve1',
temperatureDetails: { available: false, legacySensorsFormat: true },
temperatureDetails: { available: true, legacySensorsFormat: true },
},
});
expect(
collectOutdatedSensorSetupNodes([unavailableNode], [disk({ nodeName: 'pve1' })]),
).toEqual([]);
expect(collectOutdatedSensorSetupNodes([reading])).toEqual([]);
});
it('ignores legacy nodes whose SATA/SAS disks already have temperatures', () => {
expect(
collectOutdatedSensorSetupNodes(
[legacyNode()],
[disk({ nodeName: 'pve1', diskType: 'sata', temperature: 34 })],
),
).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' },
it('names a node by its Proxmox node, then node name, then resource name, trimmed', () => {
const nodes = [
node({ id: 'by-node', proxmox: { node: ' alpha ', sensorSetupOutdated: true } }),
node({ id: 'by-node-name', proxmox: { nodeName: 'bravo', sensorSetupOutdated: true } }),
node({ id: 'by-name', name: 'charlie', proxmox: { sensorSetupOutdated: true } }),
];
expect(collectOutdatedSensorSetupNodes(nodes)).toEqual([
{ id: 'by-node', name: 'alpha' },
{ id: 'by-node-name', name: 'bravo' },
{ id: 'by-name', name: 'charlie' },
]);
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(
[legacyNode()],
[disk({ nodeName: 'pve1', diskType: 'nvme' })],
),
).toEqual([]);
});
it('ignores disks that belong to a different node', () => {
expect(collectOutdatedSensorSetupNodes([legacyNode()], [disk({ nodeName: 'pve2' })])).toEqual(
[],
);
});
it('sorts multiple affected nodes by name', () => {
const nodes = [
node({
id: 'node-b',
name: 'pve-b',
proxmox: {
node: 'pve-b',
temperatureDetails: { available: true, legacySensorsFormat: true },
},
}),
node({
id: 'node-a',
name: 'pve-a',
proxmox: {
node: 'pve-a',
temperatureDetails: { available: true, legacySensorsFormat: true },
},
}),
flaggedNode({ id: 'node-b', proxmox: { node: 'pve-b', sensorSetupOutdated: true } }),
flaggedNode({ id: 'node-a', proxmox: { node: 'pve-a', sensorSetupOutdated: true } }),
];
const disks = [
disk({ id: 'disk-a', nodeName: 'pve-a' }),
disk({ id: 'disk-b', nodeName: 'pve-b' }),
];
expect(collectOutdatedSensorSetupNodes(nodes, disks).map((n) => n.name)).toEqual([
'pve-a',
'pve-b',
]);
expect(collectOutdatedSensorSetupNodes(nodes).map((n) => n.name)).toEqual(['pve-a', 'pve-b']);
});
});
@@ -1,10 +1,4 @@
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.
const SMART_ONLY_DISK_TYPES = new Set(['sata', 'sas']);
export type OutdatedSensorSetupNode = {
id: string;
@@ -14,44 +8,16 @@ export type OutdatedSensorSetupNode = {
const getNodeName = (node: Resource): string =>
(node.proxmox?.node || node.proxmox?.nodeName || node.name || '').trim();
const isSMARTOnlyDiskWithoutTemperature = (disk: Resource): boolean => {
const meta = disk.physicalDisk;
if (!meta) return false;
const diskType = (meta.diskType || '').trim().toLowerCase();
if (!SMART_ONLY_DISK_TYPES.has(diskType)) return false;
// 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
// Lists PVE nodes whose SSH temperature monitoring still runs the pre-rc.6
// setup (authorized_keys locked to `sensors -j`). Such a payload parses fine
// and delivers CPU/NVMe temps, but SMART (SATA/SAS) disk temperatures can
// never arrive, so they silently stay blank. Data-gated three ways: the last
// collection succeeded, the payload was legacy-format, and the node actually
// has SATA/SAS disks sitting without a temperature. Nodes whose disk temps
// arrive elsewhere (e.g. a linked host agent) fall out via the last check.
export function collectOutdatedSensorSetupNodes(
nodes: Resource[],
physicalDisks: Resource[],
): OutdatedSensorSetupNode[] {
if (!nodes.length || !physicalDisks.length) return [];
// never arrive, so they silently stay blank. The registry derives the verdict
// on the node (`proxmox.sensorSetupOutdated`) from the node's last collection,
// its legacy payload and the disks under it, so every Proxmox tab that lists
// nodes can show the notice without loading the disk inventory.
export function collectOutdatedSensorSetupNodes(nodes: Resource[]): OutdatedSensorSetupNode[] {
return nodes
.filter((node) => {
const details = node.proxmox?.temperatureDetails;
if (!details?.available || !details.legacySensorsFormat) return false;
const nodeName = getNodeName(node);
return physicalDisks.some(
(disk) =>
isSMARTOnlyDiskWithoutTemperature(disk) &&
matchesPhysicalDiskNode(disk, {
id: node.id,
name: nodeName,
instance: node.proxmox?.instance,
}),
);
})
.filter((node) => node.proxmox?.sensorSetupOutdated === true)
.map((node) => ({ id: node.id, name: getNodeName(node) }))
.sort((a, b) => a.name.localeCompare(b.name));
}
@@ -286,11 +286,10 @@ export function ProxmoxPageSurface() {
const serverVersionDisplay = createMemo(() =>
formatAgentVersionDisplay(agentUpdateTargetVersion()),
);
// The node carries the registry's verdict, so the notice reads the same on
// every tab that lists nodes; no tab hydrates disks for it.
const outdatedSensorSetupNodes = createMemo(() =>
collectOutdatedSensorSetupNodes(
model().pveNodes,
model().resources.filter((resource) => resource.type === 'physical_disk'),
),
collectOutdatedSensorSetupNodes(model().pveNodes),
);
// The hosts table at the top and the embedded WorkloadsSurface below share
@@ -375,6 +375,44 @@ describe('ProxmoxPageSurface contract', () => {
expect(screen.queryByTestId('platform-outdated-agent-notice')).not.toBeInTheDocument();
});
it('shows the outdated sensor setup notice on Overview without hydrating disks', () => {
const node = makeResource({
id: 'agent:pve3',
name: 'pve3',
displayName: 'pve3',
type: 'agent',
proxmox: {
nodeName: 'pve3',
temperatureDetails: { available: true, legacySensorsFormat: true },
sensorSetupOutdated: true,
},
});
// Like production, the Overview query returns nodes and guests only: the
// verdict has to arrive on the node, because no physical_disk rows do.
mockUseUnifiedResources.mockImplementation((options: { cacheKey?: string }) => ({
resources: () => (options.cacheKey === 'proxmox-overview' ? [node] : []),
loading: () => false,
error: () => null,
refetch: vi.fn(),
}));
renderSurface();
const overviewOptions = mockUseUnifiedResources.mock.calls
.map(([options]) => options as { cacheKey?: string; query?: string })
.find((options) => options.cacheKey === 'proxmox-overview');
expect(overviewOptions?.query).toBeDefined();
expect(overviewOptions?.query).not.toContain('physical_disk');
expect(screen.getByTestId('platform-section-tabs')).toHaveAttribute('data-active', 'overview');
expect(screen.getByTestId('platform-outdated-sensor-setup-notice')).toHaveTextContent(
'pve3 is using an older temperature monitoring setup that cannot read SATA/SAS disk temperatures.',
);
expect(screen.getByRole('link', { name: 'Open Infrastructure settings' })).toHaveAttribute(
'href',
'/settings/infrastructure',
);
});
it('folds estate topology into the existing nodes table header contract', () => {
setResources([
makeResource({
@@ -1943,7 +1943,7 @@ describe('useUnifiedResources', () => {
dispose();
});
it('passes proxmox temperatureDetails through for the outdated-sensor-setup gate', async () => {
it('passes the proxmox temperature payload and sensor setup verdict through', async () => {
apiFetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
@@ -1956,6 +1956,7 @@ describe('useUnifiedResources', () => {
nodeName: 'pve1',
temperature: 58.4,
temperatureDetails: { available: true, legacySensorsFormat: true },
sensorSetupOutdated: true,
},
},
],
@@ -1974,6 +1975,9 @@ describe('useUnifiedResources', () => {
available: true,
legacySensorsFormat: true,
});
// The outdated sensor setup notice reads the registry's verdict on the
// node, so it must survive the projection that rows on every tab go through.
expect(result!.resources()[0].proxmox?.sensorSetupOutdated).toBe(true);
dispose();
});
@@ -261,6 +261,7 @@ type APIResource = {
available?: boolean;
legacySensorsFormat?: boolean;
};
sensorSetupOutdated?: boolean;
template?: boolean;
containerType?: string;
isOci?: boolean;
+4
View File
@@ -773,6 +773,10 @@ export interface ResourceProxmoxMeta {
available?: boolean;
legacySensorsFormat?: boolean;
};
// Registry-derived on Proxmox nodes: the legacy setup above is in use and a
// SATA/SAS disk under the node has no current temperature. It arrives on the
// node so the outdated sensor setup notice never needs the disk inventory.
sensorSetupOutdated?: boolean;
}
export interface ResourceVirtualMachineMeta {
@@ -1379,6 +1379,29 @@ describe('resourceStateAdapters unavailable memory contract', () => {
});
describe('incremental canonical resource snapshots', () => {
it('clears a node sensor setup verdict when the next snapshot sends false', () => {
// Facets merge field by field and an omitted field is read as a partial
// snapshot, so the registry sends every Proxmox node an explicit verdict.
const node = (sensorSetupOutdated?: boolean) =>
({
id: 'agent-pve3',
type: 'agent',
name: 'pve3',
status: 'online',
sources: ['proxmox'],
proxmox: { nodeName: 'pve3', sensorSetupOutdated },
}) as unknown as Resource;
const [cleared] = mergeCanonicalResourceSnapshot([node(false)], [node(true)]);
expect(cleared?.proxmox?.sensorSetupOutdated).toBe(false);
const [omitted] = mergeCanonicalResourceSnapshot(
[{ ...node(), proxmox: { nodeName: 'pve3' } } as unknown as Resource],
[node(true)],
);
expect(omitted?.proxmox?.sensorSetupOutdated).toBe(true);
});
it('preserves untouched row identity while refreshing changed resources', () => {
const unchanged = {
id: 'vm-unchanged',
+1
View File
@@ -198,6 +198,7 @@ func cloneProxmoxData(in *ProxmoxData) *ProxmoxData {
out.DockerCheckedAt = cloneTimePtr(in.DockerCheckedAt)
out.TemperatureMonitoringEnabled = cloneBoolPtr(in.TemperatureMonitoringEnabled)
out.PendingUpdatesCheckedAt = cloneTimePtr(in.PendingUpdatesCheckedAt)
out.SensorSetupOutdated = cloneBoolPtr(in.SensorSetupOutdated)
return &out
}
+19
View File
@@ -518,6 +518,25 @@ func TestCloneProxmoxData_TemperatureIsolation(t *testing.T) {
}
}
// List and presentation readers get detached clones of a node, so the
// registry-derived sensor setup verdict travels with the clone and stays put
// when the live node's verdict changes afterwards.
func TestCloneProxmoxDataKeepsDerivedSensorSetupVerdict(t *testing.T) {
outdated := true
original := &ProxmoxData{NodeName: "pve", SensorSetupOutdated: &outdated}
cloned := cloneProxmoxData(original)
if cloned.SensorSetupOutdated == nil || !*cloned.SensorSetupOutdated {
t.Fatal("clone dropped the derived sensor setup verdict")
}
if cloned.SensorSetupOutdated == original.SensorSetupOutdated {
t.Fatal("clone shares the live node's verdict pointer")
}
*original.SensorSetupOutdated = false
if !*cloned.SensorSetupOutdated {
t.Fatal("changing the live node's verdict changed a clone already handed out")
}
}
func TestCloneProxmoxData_NetworkInterfaceIsolation(t *testing.T) {
original := &ProxmoxData{
NetworkInterfaces: []NetworkInterface{
@@ -0,0 +1,79 @@
package unifiedresources
import (
"strings"
"github.com/rcourtman/pulse-go-rewrite/pkg/diskinventory"
)
// smartTemperatureDiskTypes are the disk types whose temperature reaches a
// Proxmox node only through SMART: the transports a host agent or the
// pulse-sensors wrapper reports (sata, sas) and the form factors Proxmox's own
// disk inventory reports for those same disks (hdd, ssd). Proxmox also types
// a non-rotational USB device ssd. NVMe temperatures come from kernel hwmon
// and arrive even on a legacy setup.
var smartTemperatureDiskTypes = map[string]struct{}{
"sata": {},
"sas": {},
"hdd": {},
"ssd": {},
}
// refreshProxmoxSensorSetupLocked derives ProxmoxData.SensorSetupOutdated on
// every Proxmox node, as an explicit true or false. SSH temperature monitoring
// set up before v6.0.0-rc.6 locks the node's key to `sensors -j`: the payload
// still parses and carries CPU and NVMe temperatures, but SMART disk
// temperatures can never arrive, so they stay blank with no error anywhere.
// The flag is data-gated three ways: the node's last temperature collection
// succeeded, its payload was the legacy format, and a disk under it waits on a
// SMART temperature it does not have now. A last known temperature kept for a
// disk that could not be read now (standby, a host agent past its lease) is not
// a current one (diskinventory.TemperatureCollected), while a disk whose
// temperature arrives another way, such as a linked host agent, falls out.
// Disks count through their canonical parent, so an agent the registry has not
// linked to the node contributes none. Deriving the flag here lets every
// surface that lists nodes read it without loading the node's disk inventory.
// It must run after buildChildCounts, which resolves each disk's canonical
// parent node.
func (rr *ResourceRegistry) refreshProxmoxSensorSetupLocked() {
waiting := make(map[string]struct{})
for _, resource := range rr.resources {
if resource == nil {
continue
}
if resource.Proxmox != nil {
// A fresh pointer every time: a facet copied by an earlier merge or
// clone must not see this generation's verdict.
resource.Proxmox.SensorSetupOutdated = nil
if CanonicalResourceType(resource.Type) == ResourceTypeAgent {
resource.Proxmox.SensorSetupOutdated = sensorSetupVerdict(false)
}
}
if resource.ParentID != nil && physicalDiskWaitsOnSMARTTemperature(resource) {
waiting[*resource.ParentID] = struct{}{}
}
}
for parentID := range waiting {
node := rr.resources[parentID]
if node == nil || node.Proxmox == nil || CanonicalResourceType(node.Type) != ResourceTypeAgent {
continue
}
details := node.Proxmox.TemperatureDetails
node.Proxmox.SensorSetupOutdated = sensorSetupVerdict(details != nil && details.Available && details.LegacySensorsFormat)
}
}
func sensorSetupVerdict(outdated bool) *bool {
return &outdated
}
func physicalDiskWaitsOnSMARTTemperature(resource *Resource) bool {
if CanonicalResourceType(resource.Type) != ResourceTypePhysicalDisk || resource.PhysicalDisk == nil {
return false
}
disk := resource.PhysicalDisk
if _, ok := smartTemperatureDiskTypes[strings.ToLower(strings.TrimSpace(disk.DiskType))]; !ok {
return false
}
return !diskinventory.TemperatureCollected(disk.Temperature, disk.Collection)
}
+3
View File
@@ -602,6 +602,7 @@ func (rr *ResourceRegistry) ingestSnapshot(snapshot models.StateSnapshot, thresh
rr.refreshStoragePostureLocked()
rr.refreshIncidentRollupsLocked()
rr.buildChildCounts()
rr.refreshProxmoxSensorSetupLocked()
rr.refreshCanonicalIdentitiesLocked()
rr.markStaleLocked(time.Now().UTC(), thresholds)
rr.invalidateViewsLocked()
@@ -653,6 +654,7 @@ func (rr *ResourceRegistry) ingestRecords(source DataSource, records []IngestRec
rr.refreshStoragePostureLocked()
rr.refreshIncidentRollupsLocked()
rr.buildChildCounts()
rr.refreshProxmoxSensorSetupLocked()
rr.refreshCanonicalIdentitiesLocked()
rr.invalidateViewsLocked()
rr.mu.Unlock()
@@ -849,6 +851,7 @@ func (rr *ResourceRegistry) ingestResources(resources []Resource, thresholds map
rr.refreshStoragePostureLocked()
rr.refreshIncidentRollupsLocked()
rr.buildChildCounts()
rr.refreshProxmoxSensorSetupLocked()
rr.refreshCanonicalIdentitiesLocked()
rr.markStaleLocked(time.Now().UTC(), thresholds)
rr.invalidateViewsLocked()
+245
View File
@@ -7445,3 +7445,248 @@ func TestPhysicalDiskRiskNeverJudgesTemperature(t *testing.T) {
t.Fatalf("meta recompute risk = %+v, want none for a collected 72C", risk)
}
}
// The Proxmox page's Overview lists nodes without their disk inventory, so the
// outdated sensor setup verdict has to arrive on the node itself. The registry
// derives it from the node's legacy-format temperature payload and the disks
// it parents, with the same reading rule every disk temperature consumer uses.
func TestProxmoxNodeSensorSetupOutdatedFromItsDisks(t *testing.T) {
now := time.Now()
legacy := &models.Temperature{Available: true, LegacySensorsFormat: true}
inventoryOnly := &diskinventory.CollectionStatus{
Temperature: diskinventory.Unsupported("proxmox_disks", "Proxmox disk inventory does not expose temperature"),
}
node := func(name string, temperature *models.Temperature) models.Node {
return models.Node{
ID: "homelab-" + name,
Name: name,
Instance: "homelab",
Status: "online",
LastSeen: now,
Temperature: temperature,
}
}
disk := func(nodeName, devPath, diskType string, temperature int, collection *diskinventory.CollectionStatus) models.PhysicalDisk {
return models.PhysicalDisk{
ID: ProxmoxPhysicalDiskSourceID("homelab", nodeName, devPath, "", ""),
Node: nodeName,
Instance: "homelab",
DevPath: devPath,
Model: "Disk " + devPath,
Serial: "SERIAL-" + nodeName + devPath,
Type: diskType,
Health: "PASSED",
Wearout: -1,
Temperature: temperature,
Collection: collection,
LastChecked: now,
}
}
cases := []struct {
name string
nodes []models.Node
disks []models.PhysicalDisk
want bool
}{
{
name: "SATA disk without a temperature",
nodes: []models.Node{node("pve", legacy)},
disks: []models.PhysicalDisk{disk("pve", "/dev/sda", "sata", 0, inventoryOnly)},
want: true,
},
{
// Proxmox's own disk inventory types these disks by form factor.
name: "Proxmox hdd and ssd inventory types",
nodes: []models.Node{node("pve", legacy)},
disks: []models.PhysicalDisk{disk("pve", "/dev/sda", "hdd", 0, inventoryOnly), disk("pve", "/dev/sdb", "ssd", 0, inventoryOnly)},
want: true,
},
{
name: "SAS disk whose reading is retained, not current",
nodes: []models.Node{node("pve", legacy)},
disks: []models.PhysicalDisk{disk("pve", "/dev/sda", "sas", 41, &diskinventory.CollectionStatus{
Temperature: diskinventory.Unavailable("host_agent", "host agent stopped reporting"),
})},
want: true,
},
{
name: "SATA disk whose reading the source marks missing",
nodes: []models.Node{node("pve", legacy)},
disks: []models.PhysicalDisk{disk("pve", "/dev/sda", "sata", 38, &diskinventory.CollectionStatus{
Temperature: diskinventory.Missing("proxmox_node_smart", "temperature was not reported"),
})},
want: true,
},
{
name: "SATA disk with a current reading",
nodes: []models.Node{node("pve", legacy)},
disks: []models.PhysicalDisk{disk("pve", "/dev/sda", "sata", 36, &diskinventory.CollectionStatus{
Temperature: diskinventory.Available("proxmox_node_smart"),
})},
want: false,
},
{
name: "reading from a source that predates collection state",
nodes: []models.Node{node("pve", legacy)},
disks: []models.PhysicalDisk{disk("pve", "/dev/sda", "sata", 36, nil)},
want: false,
},
{
// NVMe temperatures arrive through kernel hwmon even on a legacy setup.
name: "NVMe disk without a temperature",
nodes: []models.Node{node("pve", legacy)},
disks: []models.PhysicalDisk{disk("pve", "/dev/nvme0n1", "nvme", 0, inventoryOnly)},
want: false,
},
{
name: "current setup payload",
nodes: []models.Node{node("pve", &models.Temperature{Available: true})},
disks: []models.PhysicalDisk{disk("pve", "/dev/sda", "sata", 0, inventoryOnly)},
want: false,
},
{
name: "failed temperature collection",
nodes: []models.Node{node("pve", &models.Temperature{Available: false, LegacySensorsFormat: true})},
disks: []models.PhysicalDisk{disk("pve", "/dev/sda", "sata", 0, inventoryOnly)},
want: false,
},
{
name: "waiting disk belongs to another node",
nodes: []models.Node{node("pve", legacy), node("pve2", &models.Temperature{Available: true})},
disks: []models.PhysicalDisk{disk("pve2", "/dev/sda", "sata", 0, inventoryOnly)},
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
registry := NewRegistry(nil)
registry.IngestSnapshot(models.StateSnapshot{Nodes: tc.nodes, PhysicalDisks: tc.disks})
got := proxmoxNodeSensorSetupOutdated(t, registry.List(), "pve")
if got != tc.want {
t.Fatalf("pve sensorSetupOutdated = %v, want %v", got, tc.want)
}
})
}
}
// A linked host agent that reads the disk's SMART temperature itself answers
// what the legacy SSH setup cannot, so the node is not flagged; the verdict is
// derived on every ingest, so a later snapshot clears an earlier flag.
func TestProxmoxNodeSensorSetupOutdatedFollowsWhereTheReadingArrives(t *testing.T) {
now := time.Now()
nodes := []models.Node{{
ID: "homelab-pve",
Name: "pve",
Instance: "homelab",
LinkedAgentID: "host-pve",
Status: "online",
LastSeen: now,
Temperature: &models.Temperature{Available: true, LegacySensorsFormat: true},
}}
inventory := []models.PhysicalDisk{{
ID: ProxmoxPhysicalDiskSourceID("homelab", "pve", "/dev/sda", "", ""),
Node: "pve",
Instance: "homelab",
DevPath: "/dev/sda",
Model: "WDC WD80EFAX",
Serial: "SERIAL-SDA",
Type: "hdd",
Health: "PASSED",
Wearout: -1,
Collection: &diskinventory.CollectionStatus{Temperature: diskinventory.Unsupported("proxmox_disks", "Proxmox disk inventory does not expose temperature")},
LastChecked: now,
}}
agent := func(collection *diskinventory.CollectionStatus) []models.Host {
return []models.Host{{
ID: "host-pve",
Hostname: "pve",
LinkedNodeID: "homelab-pve",
Status: "online",
LastSeen: now,
Sensors: models.HostSensorSummary{SMART: []models.HostDiskSMART{{
Device: "/dev/sda",
Model: "WDC WD80EFAX",
Serial: "SERIAL-SDA",
Type: "sata",
Temperature: 34,
Health: "PASSED",
Collection: collection,
}}},
}}
}
registry := NewRegistry(nil)
registry.IngestSnapshot(models.StateSnapshot{Nodes: nodes, PhysicalDisks: inventory})
if !proxmoxNodeSensorSetupOutdated(t, registry.List(), "pve") {
t.Fatal("legacy node with an unread hdd: sensorSetupOutdated = false, want true")
}
registry.IngestSnapshot(models.StateSnapshot{
Nodes: nodes,
PhysicalDisks: inventory,
Hosts: agent(&diskinventory.CollectionStatus{Temperature: diskinventory.Available("host_agent")}),
})
if proxmoxNodeSensorSetupOutdated(t, registry.List(), "pve") {
t.Fatal("linked agent reads the disk now: sensorSetupOutdated = true, want the earlier flag cleared")
}
// Clients merge a facet field by field, so the cleared verdict must be on
// the wire as false, not omitted.
assertProxmoxNodeSensorSetupJSON(t, registry.ListForPresentation(), "pve", `"sensorSetupOutdated":false`)
registry.IngestSnapshot(models.StateSnapshot{
Nodes: nodes,
PhysicalDisks: inventory,
Hosts: agent(&diskinventory.CollectionStatus{Temperature: diskinventory.Unavailable("host_agent", "host agent stopped reporting")}),
})
presented := registry.ListForPresentation()
if !proxmoxNodeSensorSetupOutdated(t, presented, "pve") {
t.Fatal("agent reading retained, not current: sensorSetupOutdated = false, want true")
}
assertProxmoxNodeSensorSetupJSON(t, presented, "pve", `"sensorSetupOutdated":true`)
}
func assertProxmoxNodeSensorSetupJSON(t *testing.T, resources []Resource, nodeName, want string) {
t.Helper()
for _, resource := range resources {
if resource.Proxmox == nil || resource.Proxmox.NodeName != nodeName || CanonicalResourceType(resource.Type) != ResourceTypeAgent {
continue
}
payload, err := json.Marshal(resource.Proxmox)
if err != nil {
t.Fatalf("marshal node proxmox facet: %v", err)
}
if !strings.Contains(string(payload), want) {
t.Fatalf("node proxmox payload = %s, want %s for list readers", payload, want)
}
return
}
t.Fatalf("node %q not presented", nodeName)
}
func proxmoxNodeSensorSetupOutdated(t *testing.T, resources []Resource, nodeName string) bool {
t.Helper()
var found *Resource
for i := range resources {
resource := &resources[i]
if CanonicalResourceType(resource.Type) != ResourceTypeAgent || resource.Proxmox == nil || resource.Proxmox.NodeName != nodeName {
continue
}
if found != nil {
t.Fatalf("node %q presented twice: %s and %s", nodeName, found.ID, resource.ID)
}
found = resource
}
if found == nil {
t.Fatalf("node %q not found among %d resources", nodeName, len(resources))
}
for _, resource := range resources {
if CanonicalResourceType(resource.Type) != ResourceTypeAgent && resource.Proxmox != nil && resource.Proxmox.SensorSetupOutdated != nil {
t.Fatalf("%s %s carries the node verdict", resource.Type, resource.ID)
}
}
if found.Proxmox.SensorSetupOutdated == nil {
t.Fatalf("node %q has no explicit sensor setup verdict", nodeName)
}
return *found.Proxmox.SensorSetupOutdated
}
+8
View File
@@ -414,6 +414,14 @@ type ProxmoxData struct {
MemoryCache int64 `json:"memoryCache,omitempty"`
Memory *models.Memory `json:"memory,omitempty"`
Lock string `json:"lock,omitempty"` // Proxmox lock state (e.g. "backup", "migrate", "snapshot")
// SensorSetupOutdated is derived by the registry, never ingested: the
// node's SSH temperature monitoring still runs the pre-rc.6 setup and a
// disk under it waits on a SMART temperature that setup cannot deliver
// (refreshProxmoxSensorSetupLocked). Every Proxmox node carries an
// explicit true or false, so clients that merge facets field by field
// replace a cleared verdict instead of keeping an earlier true; other
// resources omit it.
SensorSetupOutdated *bool `json:"sensorSetupOutdated,omitempty"`
// Internal link hint to a host agent resource.
LinkedAgentID string `json:"-"`
}