From 4324b87ceaec64abcdc289ff418c2a5305c67d85 Mon Sep 17 00:00:00 2001 From: Tirth Patel Date: Tue, 22 Sep 2026 23:37:05 -0500 Subject: [PATCH 1/2] fix(api): stop temperature metrics from spinning up array disks DiskSensorsService listed disks through getDisks(), which calls systeminformation's diskLayout(). On Linux that runs `smartctl -a` and `smartctl -H` on every disk without `-n standby`, waking any disk that is spun down on each temperature read. List disks with lsblk instead, which reads kernel metadata only. The existing `smartctl -n standby` temperature read then skips spun-down disks instead of waking them. Sensor ids and names are unchanged. Fixes #2017 --- .../resolvers/disks/disks.service.spec.ts | 85 +++++++++++++++++++ .../graph/resolvers/disks/disks.service.ts | 85 ++++++++++++++----- .../sensors/disk_sensors.service.spec.ts | 39 ++++++--- .../sensors/disk_sensors.service.ts | 2 +- 4 files changed, 175 insertions(+), 36 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts b/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts index 73d05e46d9..2cb7a2feb0 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts @@ -810,6 +810,91 @@ describe('DisksService', () => { }); // --- Test getTemperature --- + describe('getPhysicalDisks', () => { + const lsblkResult = (blockdevices: unknown[]) => + ({ + stdout: JSON.stringify({ blockdevices }), + stderr: '', + exitCode: 0, + }) as unknown as Awaited>; + + it('lists whole disks from lsblk without running SMART commands', async () => { + mockExeca.mockResolvedValue( + lsblkResult([ + { + path: '/dev/sdb', + type: 'disk', + size: 12000000000000, + serial: 'TESTSERIAL001 ', + model: 'Test HDD 12TB', + tran: 'sata', + }, + { + path: '/dev/nvme0n1', + type: 'disk', + size: 1000000000000, + serial: 'TESTSERIAL002', + model: 'Test NVMe 1TB', + tran: 'nvme', + }, + ]) + ); + + await expect(service.getPhysicalDisks()).resolves.toEqual([ + { + id: 'TESTSERIAL001', + device: '/dev/sdb', + name: 'Test HDD 12TB', + interfaceType: DiskInterfaceType.SATA, + }, + { + id: 'TESTSERIAL002', + device: '/dev/nvme0n1', + name: 'Test NVMe 1TB', + interfaceType: DiskInterfaceType.PCIE, + }, + ]); + expect(mockExeca).not.toHaveBeenCalledWith('smartctl', expect.anything()); + expect(mockDiskLayout).not.toHaveBeenCalled(); + }); + + it('skips loop, md, empty and zero-size devices', async () => { + mockExeca.mockResolvedValue( + lsblkResult([ + { + path: '/dev/loop0', + type: 'loop', + size: 1000000, + serial: null, + model: null, + tran: null, + }, + { + path: '/dev/md1p1', + type: 'md', + size: 10000000000000, + serial: null, + model: null, + tran: null, + }, + { path: '/dev/zram0', type: 'disk', size: 0, serial: null, model: null, tran: null }, + { + path: '/dev/sdc', + type: 'disk', + size: 500000000000, + serial: null, + model: null, + tran: 'usb', + }, + ]) + ); + + await expect(service.getPhysicalDisks()).resolves.toEqual([ + { id: '', device: '/dev/sdc', name: '', interfaceType: DiskInterfaceType.USB }, + ]); + }); + }); + describe('getTemperature', () => { it('should return temperature for a disk', async () => { mockExeca.mockResolvedValue({ diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.service.ts b/api/src/unraid-api/graph/resolvers/disks/disks.service.ts index aa49ed52c0..61dac044b1 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.service.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.service.ts @@ -45,6 +45,21 @@ const SmartDataSchema = z.object({ .optional() .nullable(), }); +const LsblkPhysicalDisksSchema = z.object({ + blockdevices: z.array( + z.object({ + path: z.string(), + type: z.string(), + size: z.coerce.number().nullable(), + serial: z.string().nullable(), + model: z.string().nullable(), + tran: z.string().nullable(), + }) + ), +}); + +export type PhysicalDisk = Pick; + interface EmhttpDeviceRecord { id?: unknown; device?: unknown; @@ -337,28 +352,6 @@ export class DisksService { (p): p is { name: string; fsType: DiskFsType; size: number } => p.fsType !== undefined ); - // Explicitly map interface types - let mappedInterfaceType: DiskInterfaceType; - switch (disk.interfaceType?.toUpperCase()) { - case 'SATA': - mappedInterfaceType = DiskInterfaceType.SATA; - break; - case 'SAS': - mappedInterfaceType = DiskInterfaceType.SAS; - break; - case 'USB': - mappedInterfaceType = DiskInterfaceType.USB; - break; - case 'NVME': // Map NVMe string to PCIE enum - mappedInterfaceType = DiskInterfaceType.PCIE; - break; - case 'PCIE': // Also handle PCIE string - mappedInterfaceType = DiskInterfaceType.PCIE; - break; - default: - mappedInterfaceType = DiskInterfaceType.UNKNOWN; - } - const arrayDisk = arrayDisks.find((d) => d.id.trim() === disk.serialNum.trim()); return { ...disk, @@ -366,12 +359,58 @@ export class DisksService { smartStatus: DiskSmartStatus[disk.smartStatus?.toUpperCase() as keyof typeof DiskSmartStatus] ?? DiskSmartStatus.UNKNOWN, - interfaceType: mappedInterfaceType, + interfaceType: this.mapInterfaceType(disk.interfaceType), partitions, isSpinning: arrayDisk?.isSpinning ?? false, }; } + private mapInterfaceType(value: string | null | undefined): DiskInterfaceType { + switch (value?.toUpperCase()) { + case 'SATA': + return DiskInterfaceType.SATA; + case 'SAS': + return DiskInterfaceType.SAS; + case 'USB': + return DiskInterfaceType.USB; + case 'NVME': + case 'PCIE': + return DiskInterfaceType.PCIE; + default: + return DiskInterfaceType.UNKNOWN; + } + } + + /** + * List physical disks from kernel metadata only. Unlike getDisks(), this never + * runs SMART commands, so it does not wake disks that are spun down. + */ + async getPhysicalDisks(): Promise { + const { stdout } = await execa('lsblk', [ + '-J', + '-d', + '-b', + '-o', + 'PATH,TYPE,SIZE,SERIAL,MODEL,TRAN', + ]); + const { blockdevices } = LsblkPhysicalDisksSchema.parse(JSON.parse(stdout)); + + return blockdevices + .filter( + (device) => + device.type === 'disk' && + (device.size ?? 0) > 0 && + !device.path.startsWith('/dev/loop') && + !device.path.startsWith('/dev/ram') + ) + .map((device) => ({ + id: device.serial?.trim() ?? '', + device: device.path, + name: device.model?.trim() ?? '', + interfaceType: this.mapInterfaceType(device.tran), + })); + } + /** * Get all disks. */ diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts index 191e8db74b..e7befc01ed 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts @@ -22,6 +22,7 @@ describe('DiskSensorsService', () => { provide: DisksService, useValue: { getDisks: vi.fn(), + getPhysicalDisks: vi.fn(), getTemperature: vi.fn(), }, }, @@ -42,14 +43,17 @@ describe('DiskSensorsService', () => { device: '/dev/sda', name: 'Test Disk', } as unknown as Disk; - const getDisks = vi.mocked(disksService.getDisks); + const getDisks = vi.mocked(disksService.getPhysicalDisks); getDisks.mockResolvedValue([disk]); }, ], - ['when no disks exist', () => vi.mocked(disksService.getDisks).mockResolvedValue([])], + [ + 'when no disks exist', + () => vi.mocked(disksService.getPhysicalDisks).mockResolvedValue([]), + ], [ 'when DisksService would throw', - () => vi.mocked(disksService.getDisks).mockRejectedValue(new Error('Failed')), + () => vi.mocked(disksService.getPhysicalDisks).mockRejectedValue(new Error('Failed')), ], ])('should return true without checking disks %s', async (_label, setupMock) => { setupMock(); @@ -57,13 +61,13 @@ describe('DiskSensorsService', () => { const available = await service.isAvailable(); expect(available).toBe(true); - expect(disksService.getDisks).not.toHaveBeenCalled(); + expect(disksService.getPhysicalDisks).not.toHaveBeenCalled(); }); }); describe('read', () => { it('should return disk temperatures', async () => { - vi.mocked(disksService.getDisks).mockResolvedValue([ + vi.mocked(disksService.getPhysicalDisks).mockResolvedValue([ { id: 'disk1', device: '/dev/sda', @@ -99,8 +103,19 @@ describe('DiskSensorsService', () => { }); }); + it('should not use getDisks, whose SMART queries wake spun-down disks', async () => { + vi.mocked(disksService.getPhysicalDisks).mockResolvedValue([ + { id: 'disk1', device: '/dev/sda', name: 'Disk 1' } as unknown as Disk, + ]); + vi.mocked(disksService.getTemperature).mockResolvedValue(35); + + await service.read(); + + expect(disksService.getDisks).not.toHaveBeenCalled(); + }); + it('should skip disks without temperature data', async () => { - vi.mocked(disksService.getDisks).mockResolvedValue([ + vi.mocked(disksService.getPhysicalDisks).mockResolvedValue([ { id: 'disk1', device: '/dev/sda', name: 'Disk 1' } as unknown as Disk, { id: 'disk2', device: '/dev/sdb', name: 'Disk 2' } as unknown as Disk, ]); @@ -114,7 +129,7 @@ describe('DiskSensorsService', () => { }); it('should handle getTemperature errors gracefully', async () => { - vi.mocked(disksService.getDisks).mockResolvedValue([ + vi.mocked(disksService.getPhysicalDisks).mockResolvedValue([ { id: 'disk1', device: '/dev/sda', name: 'Disk 1' } as unknown as Disk, { id: 'disk2', device: '/dev/sdb', name: 'Disk 2' } as unknown as Disk, ]); @@ -130,7 +145,7 @@ describe('DiskSensorsService', () => { }); it('should use device name as fallback when name is empty', async () => { - vi.mocked(disksService.getDisks).mockResolvedValue([ + vi.mocked(disksService.getPhysicalDisks).mockResolvedValue([ { id: 'disk1', device: '/dev/sda', name: '' } as unknown as Disk, ]); @@ -144,7 +159,7 @@ describe('DiskSensorsService', () => { describe('inferDiskType', () => { it('should return NVME for nvme interface', async () => { - vi.mocked(disksService.getDisks).mockResolvedValue([ + vi.mocked(disksService.getPhysicalDisks).mockResolvedValue([ { id: 'disk1', device: '/dev/nvme0n1', @@ -159,7 +174,7 @@ describe('DiskSensorsService', () => { }); it('should return NVME for pcie interface', async () => { - vi.mocked(disksService.getDisks).mockResolvedValue([ + vi.mocked(disksService.getPhysicalDisks).mockResolvedValue([ { id: 'disk1', device: '/dev/nvme0n1', @@ -174,7 +189,7 @@ describe('DiskSensorsService', () => { }); it('should return DISK for sata interface', async () => { - vi.mocked(disksService.getDisks).mockResolvedValue([ + vi.mocked(disksService.getPhysicalDisks).mockResolvedValue([ { id: 'disk1', device: '/dev/sda', @@ -189,7 +204,7 @@ describe('DiskSensorsService', () => { }); it('should return DISK for undefined interface', async () => { - vi.mocked(disksService.getDisks).mockResolvedValue([ + vi.mocked(disksService.getPhysicalDisks).mockResolvedValue([ { id: 'disk1', device: '/dev/sda', name: 'HDD' } as unknown as Disk, ]); vi.mocked(disksService.getTemperature).mockResolvedValue(35); diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.ts index 52b3216e28..8b99406252 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.ts @@ -22,7 +22,7 @@ export class DiskSensorsService implements TemperatureSensorProvider { } async read(): Promise { - const disks = await this.disksService.getDisks(); + const disks = await this.disksService.getPhysicalDisks(); const sensors: RawTemperatureSensor[] = []; for (const disk of disks) { From e83701e2abac5adc76e553dc7c21710dc1183fe0 Mon Sep 17 00:00:00 2001 From: Tirth Patel Date: Tue, 22 Sep 2026 23:52:17 -0500 Subject: [PATCH 2/2] use device path as sensor id for disks without a serial --- .../graph/resolvers/disks/disks.service.spec.ts | 9 +++++++-- .../unraid-api/graph/resolvers/disks/disks.service.ts | 2 +- .../temperature/sensors/disk_sensors.service.spec.ts | 7 ++++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts b/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts index 2cb7a2feb0..142a3bff91 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts @@ -858,7 +858,7 @@ describe('DisksService', () => { expect(mockDiskLayout).not.toHaveBeenCalled(); }); - it('skips loop, md, empty and zero-size devices', async () => { + it('skips loop, md and zero-size devices and falls back to the device path without a serial', async () => { mockExeca.mockResolvedValue( lsblkResult([ { @@ -890,7 +890,12 @@ describe('DisksService', () => { ); await expect(service.getPhysicalDisks()).resolves.toEqual([ - { id: '', device: '/dev/sdc', name: '', interfaceType: DiskInterfaceType.USB }, + { + id: '/dev/sdc', + device: '/dev/sdc', + name: '', + interfaceType: DiskInterfaceType.USB, + }, ]); }); }); diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.service.ts b/api/src/unraid-api/graph/resolvers/disks/disks.service.ts index 61dac044b1..2be5b52c89 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.service.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.service.ts @@ -404,7 +404,7 @@ export class DisksService { !device.path.startsWith('/dev/ram') ) .map((device) => ({ - id: device.serial?.trim() ?? '', + id: device.serial?.trim() || device.path, device: device.path, name: device.model?.trim() ?? '', interfaceType: this.mapInterfaceType(device.tran), diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts index e7befc01ed..e49f1fbc6a 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts @@ -105,7 +105,12 @@ describe('DiskSensorsService', () => { it('should not use getDisks, whose SMART queries wake spun-down disks', async () => { vi.mocked(disksService.getPhysicalDisks).mockResolvedValue([ - { id: 'disk1', device: '/dev/sda', name: 'Disk 1' } as unknown as Disk, + { + id: 'disk1', + device: '/dev/sda', + name: 'Disk 1', + interfaceType: DiskInterfaceType.SATA, + }, ]); vi.mocked(disksService.getTemperature).mockResolvedValue(35);