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..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 @@ -810,6 +810,96 @@ 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 and zero-size devices and falls back to the device path without a serial', 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: '/dev/sdc', + 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..2be5b52c89 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.path, + 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..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 @@ -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,24 @@ 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', + interfaceType: DiskInterfaceType.SATA, + }, + ]); + 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 +134,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 +150,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 +164,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 +179,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 +194,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 +209,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) {