Fix Thresholds page crash on guest filesystems with omitted usage

The unified resources payload serializes guest filesystem numerics with
omitempty, so a disk whose usage is exactly zero arrives with the field
absent. The guest filesystem projection guarded with usage < 0, which
undefined passes, then dereferenced usage.toFixed and took down the whole
Alerts > Thresholds page behind the error boundary (#1663).

Treat absent numerics as zero and derive the percentage from used/total
when the serialized value is missing; a negative usage still means the
poller could not read the filesystem and stays excluded.
This commit is contained in:
rcourtman 2026-08-01 13:04:09 +01:00
parent a90d5c907a
commit 15bb0c217e
2 changed files with 74 additions and 2 deletions

View file

@ -72,6 +72,69 @@ describe('useThresholdsGuestData guest filesystems', () => {
});
});
it('projects filesystems whose serialized usage was omitted without crashing', () => {
createRoot((dispose) => {
const props = {
allGuests: () => [
{
id: 'cluster-a:node-1:101',
name: 'files',
platformId: 'cluster-a',
status: 'online',
type: 'ct',
lastSeen: 1,
proxmox: {
instance: 'cluster-a',
node: 'node-1',
vmid: 101,
// The unified resources payload drops zero-valued numerics, so
// an untouched filesystem arrives with no usage/used fields.
disks: [
{
device: 'rootfs',
mountpoint: '/',
total: 107374182400,
type: 'ext4',
},
{
device: 'mp0',
mountpoint: '/data',
total: 214748364800,
used: 107374182400,
free: 107374182400,
type: 'ext4',
},
],
},
},
],
backupDefaults: () => ({ enabled: false }),
guestDefaults: { disk: 85 },
nodes: [],
overrides: () => [],
snapshotDefaults: () => ({ enabled: false }),
} as unknown as ThresholdsTableProps;
const data = useThresholdsGuestData({
props,
editingId: () => null,
searchTerm: () => '',
});
expect(data.guestDisksWithOverrides()).toEqual([
expect.objectContaining({
name: '/',
subtitle: '0.0 / 100.0 GB · 0.0%',
}),
expect.objectContaining({
name: '/data',
subtitle: '100.0 / 200.0 GB · 50.0%',
}),
]);
dispose();
});
});
it('keeps a persisted filesystem override visible after its guest disappears', () => {
createRoot((dispose) => {
const props = {

View file

@ -117,7 +117,16 @@ export function useThresholdsGuestData(inputs: ThresholdsDataInputs) {
const guestIdentity = getGuestOverrideIdentity(guest);
disks.forEach((disk: Disk) => {
if (!disk || disk.total <= 0 || disk.usage < 0) return;
// The unified resources payload omits zero-valued numerics, so
// total/used/usage may all be absent; usage < 0 is the poller's
// "unknown" sentinel and stays excluded.
const total = disk?.total ?? 0;
const used = disk?.used ?? 0;
if (!disk || total <= 0 || (typeof disk.usage === 'number' && disk.usage < 0)) return;
const usagePercent =
typeof disk.usage === 'number' && Number.isFinite(disk.usage)
? disk.usage
: (used / total) * 100;
const candidates = guestDiskOverrideIdCandidates(guest, disk.mountpoint, disk.device);
const storageId = guestDiskOverrideStorageId(guest, disk.mountpoint, disk.device);
@ -153,7 +162,7 @@ export function useThresholdsGuestData(inputs: ThresholdsDataInputs) {
disabled: override?.disabled || false,
thresholds: override?.thresholds || {},
defaults: { disk: props.guestDefaults.disk },
subtitle: `${(disk.used / 1024 / 1024 / 1024).toFixed(1)} / ${(disk.total / 1024 / 1024 / 1024).toFixed(1)} GB · ${disk.usage.toFixed(1)}%`,
subtitle: `${(used / 1024 / 1024 / 1024).toFixed(1)} / ${(total / 1024 / 1024 / 1024).toFixed(1)} GB · ${usagePercent.toFixed(1)}%`,
} satisfies TableResource);
});
});