Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof execa>>;

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({
Expand Down
85 changes: 62 additions & 23 deletions api/src/unraid-api/graph/resolvers/disks/disks.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Disk, 'id' | 'device' | 'name' | 'interfaceType'>;

interface EmhttpDeviceRecord {
id?: unknown;
device?: unknown;
Expand Down Expand Up @@ -337,41 +352,65 @@ 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,
id: disk.serialNum, // Ensure id is set
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<PhysicalDisk[]> {
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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe('DiskSensorsService', () => {
provide: DisksService,
useValue: {
getDisks: vi.fn(),
getPhysicalDisks: vi.fn(),
getTemperature: vi.fn(),
},
},
Expand All @@ -42,28 +43,31 @@ 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();

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',
Expand Down Expand Up @@ -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,
]);
Expand All @@ -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,
]);
Expand All @@ -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,
]);

Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export class DiskSensorsService implements TemperatureSensorProvider {
}

async read(): Promise<RawTemperatureSensor[]> {
const disks = await this.disksService.getDisks();
const disks = await this.disksService.getPhysicalDisks();
const sensors: RawTemperatureSensor[] = [];

for (const disk of disks) {
Expand Down