mirror of
https://github.com/unraid/api.git
synced 2025-12-30 13:09:52 -06:00
feat(disks): add isSpinning field to Disk type (#1527)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a new "isSpinning" status to disks, allowing users to see whether each disk is currently spinning. * **Bug Fixes** * Improved accuracy of disk metadata by integrating external configuration data. * **Tests** * Enhanced test setup to better simulate application state for disk-related features. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
This commit is contained in:
@@ -211,6 +211,7 @@ test('After init returns values from cfg file for all fields', { timeout: 30000
|
||||
"fsUsed": null,
|
||||
"id": "ST18000NM000J-2TV103_ZR585CPY",
|
||||
"idx": 0,
|
||||
"isSpinning": true,
|
||||
"name": "parity",
|
||||
"numErrors": 0,
|
||||
"numReads": 0,
|
||||
@@ -235,6 +236,7 @@ test('After init returns values from cfg file for all fields', { timeout: 30000
|
||||
"fsUsed": 4116003021,
|
||||
"id": "ST18000NM000J-2TV103_ZR5B1W9X",
|
||||
"idx": 1,
|
||||
"isSpinning": true,
|
||||
"name": "disk1",
|
||||
"numErrors": 0,
|
||||
"numReads": 0,
|
||||
@@ -259,6 +261,7 @@ test('After init returns values from cfg file for all fields', { timeout: 30000
|
||||
"fsUsed": 11904860828,
|
||||
"id": "WDC_WD120EDAZ-11F3RA0_5PJRD45C",
|
||||
"idx": 2,
|
||||
"isSpinning": true,
|
||||
"name": "disk2",
|
||||
"numErrors": 0,
|
||||
"numReads": 0,
|
||||
@@ -283,6 +286,7 @@ test('After init returns values from cfg file for all fields', { timeout: 30000
|
||||
"fsUsed": 6478056481,
|
||||
"id": "WDC_WD120EMAZ-11BLFA0_5PH8BTYD",
|
||||
"idx": 3,
|
||||
"isSpinning": true,
|
||||
"name": "disk3",
|
||||
"numErrors": 0,
|
||||
"numReads": 0,
|
||||
@@ -307,6 +311,7 @@ test('After init returns values from cfg file for all fields', { timeout: 30000
|
||||
"fsUsed": 137273827,
|
||||
"id": "Samsung_SSD_850_EVO_250GB_S2R5NX0H643734Z",
|
||||
"idx": 30,
|
||||
"isSpinning": true,
|
||||
"name": "cache",
|
||||
"numErrors": 0,
|
||||
"numReads": 0,
|
||||
@@ -331,6 +336,7 @@ test('After init returns values from cfg file for all fields', { timeout: 30000
|
||||
"fsUsed": null,
|
||||
"id": "KINGSTON_SA2000M8250G_50026B7282669D9E",
|
||||
"idx": 31,
|
||||
"isSpinning": true,
|
||||
"name": "cache2",
|
||||
"numErrors": 0,
|
||||
"numReads": 0,
|
||||
@@ -355,6 +361,7 @@ test('After init returns values from cfg file for all fields', { timeout: 30000
|
||||
"fsUsed": 851325,
|
||||
"id": "Cruzer",
|
||||
"idx": 32,
|
||||
"isSpinning": true,
|
||||
"name": "flash",
|
||||
"numErrors": 0,
|
||||
"numReads": 0,
|
||||
|
||||
@@ -28,6 +28,7 @@ test('Returns parsed state file', async () => {
|
||||
"fsUsed": null,
|
||||
"id": "ST18000NM000J-2TV103_ZR585CPY",
|
||||
"idx": 0,
|
||||
"isSpinning": true,
|
||||
"name": "parity",
|
||||
"numErrors": 0,
|
||||
"numReads": 0,
|
||||
@@ -52,6 +53,7 @@ test('Returns parsed state file', async () => {
|
||||
"fsUsed": 4116003021,
|
||||
"id": "ST18000NM000J-2TV103_ZR5B1W9X",
|
||||
"idx": 1,
|
||||
"isSpinning": true,
|
||||
"name": "disk1",
|
||||
"numErrors": 0,
|
||||
"numReads": 0,
|
||||
@@ -76,6 +78,7 @@ test('Returns parsed state file', async () => {
|
||||
"fsUsed": 11904860828,
|
||||
"id": "WDC_WD120EDAZ-11F3RA0_5PJRD45C",
|
||||
"idx": 2,
|
||||
"isSpinning": true,
|
||||
"name": "disk2",
|
||||
"numErrors": 0,
|
||||
"numReads": 0,
|
||||
@@ -100,6 +103,7 @@ test('Returns parsed state file', async () => {
|
||||
"fsUsed": 6478056481,
|
||||
"id": "WDC_WD120EMAZ-11BLFA0_5PH8BTYD",
|
||||
"idx": 3,
|
||||
"isSpinning": true,
|
||||
"name": "disk3",
|
||||
"numErrors": 0,
|
||||
"numReads": 0,
|
||||
@@ -124,6 +128,7 @@ test('Returns parsed state file', async () => {
|
||||
"fsUsed": 137273827,
|
||||
"id": "Samsung_SSD_850_EVO_250GB_S2R5NX0H643734Z",
|
||||
"idx": 30,
|
||||
"isSpinning": true,
|
||||
"name": "cache",
|
||||
"numErrors": 0,
|
||||
"numReads": 0,
|
||||
@@ -148,6 +153,7 @@ test('Returns parsed state file', async () => {
|
||||
"fsUsed": null,
|
||||
"id": "KINGSTON_SA2000M8250G_50026B7282669D9E",
|
||||
"idx": 31,
|
||||
"isSpinning": true,
|
||||
"name": "cache2",
|
||||
"numErrors": 0,
|
||||
"numReads": 0,
|
||||
@@ -172,6 +178,7 @@ test('Returns parsed state file', async () => {
|
||||
"fsUsed": 851325,
|
||||
"id": "Cruzer",
|
||||
"idx": 32,
|
||||
"isSpinning": true,
|
||||
"name": "flash",
|
||||
"numErrors": 0,
|
||||
"numReads": 0,
|
||||
|
||||
@@ -36,6 +36,7 @@ export type IniSlot = {
|
||||
size: string;
|
||||
sizeSb: string;
|
||||
slots: string;
|
||||
spundown: string;
|
||||
status: SlotStatus;
|
||||
temp: string;
|
||||
type: SlotType;
|
||||
@@ -82,6 +83,7 @@ export const parse: StateFileToIniParserMap['disks'] = (disksIni) =>
|
||||
fsType: slot.fsType ?? null,
|
||||
format: slot.format === '-' ? null : slot.format,
|
||||
transport: slot.transport ?? null,
|
||||
isSpinning: slot.spundown ? slot.spundown === '0' : null,
|
||||
};
|
||||
// @TODO Zod Parse This
|
||||
return result;
|
||||
|
||||
@@ -126,6 +126,9 @@ export class ArrayDisk extends Node {
|
||||
|
||||
@Field(() => ArrayDiskFsColor, { nullable: true })
|
||||
color?: ArrayDiskFsColor | null;
|
||||
|
||||
@Field(() => Boolean, { nullable: true, description: 'Whether the disk is currently spinning' })
|
||||
isSpinning?: boolean | null;
|
||||
}
|
||||
|
||||
@ObjectType({
|
||||
|
||||
@@ -3,7 +3,15 @@ import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
import { Node } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsEnum, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export enum DiskFsType {
|
||||
XFS = 'XFS',
|
||||
@@ -136,4 +144,8 @@ export class Disk extends Node {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => DiskPartition)
|
||||
partitions!: DiskPartition[];
|
||||
|
||||
@Field(() => Boolean, { description: 'Whether the disk is spinning or not' })
|
||||
@IsBoolean()
|
||||
isSpinning!: boolean;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ describe('DisksResolver', () => {
|
||||
smartStatus: DiskSmartStatus.OK,
|
||||
temperature: -1,
|
||||
partitions: [],
|
||||
isSpinning: false,
|
||||
},
|
||||
];
|
||||
mockDisksService.getDisks.mockResolvedValue(mockResult);
|
||||
@@ -92,6 +93,7 @@ describe('DisksResolver', () => {
|
||||
const mockDisk: Disk = {
|
||||
id: 'SERIAL123',
|
||||
device: '/dev/sda',
|
||||
isSpinning: false,
|
||||
type: 'SSD',
|
||||
name: 'Samsung SSD 860 EVO 1TB',
|
||||
vendor: 'Samsung',
|
||||
|
||||
@@ -33,4 +33,9 @@ export class DisksResolver {
|
||||
public async temperature(@Parent() disk: Disk) {
|
||||
return this.disksService.getTemperature(disk.device);
|
||||
}
|
||||
|
||||
@ResolveField(() => Boolean)
|
||||
public async isSpinning(@Parent() disk: Disk) {
|
||||
return disk.isSpinning;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import type { Systeminformation } from 'systeminformation';
|
||||
import { execa } from 'execa';
|
||||
import { blockDevices, diskLayout } from 'systeminformation';
|
||||
// Vitest imports
|
||||
import { beforeEach, describe, expect, it, Mock, MockedFunction, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
ArrayDisk,
|
||||
ArrayDiskStatus,
|
||||
ArrayDiskType,
|
||||
} from '@app/unraid-api/graph/resolvers/array/array.model.js';
|
||||
import {
|
||||
Disk,
|
||||
DiskFsType,
|
||||
@@ -33,6 +39,86 @@ const mockBatchProcess = batchProcess as any;
|
||||
|
||||
describe('DisksService', () => {
|
||||
let service: DisksService;
|
||||
let configService: ConfigService;
|
||||
|
||||
// Mock ArrayDisk data from state
|
||||
const mockArrayDisks: ArrayDisk[] = [
|
||||
{
|
||||
id: 'S4ENNF0N123456',
|
||||
device: 'sda',
|
||||
name: 'cache',
|
||||
size: 512110190592,
|
||||
idx: 30,
|
||||
type: ArrayDiskType.CACHE,
|
||||
status: ArrayDiskStatus.DISK_OK,
|
||||
isSpinning: null, // NVMe/SSD doesn't spin
|
||||
rotational: false,
|
||||
exportable: false,
|
||||
numErrors: 0,
|
||||
numReads: 1000,
|
||||
numWrites: 2000,
|
||||
temp: 42,
|
||||
comment: 'NVMe Cache',
|
||||
format: 'GPT: 4KiB-aligned',
|
||||
fsType: 'btrfs',
|
||||
transport: 'nvme',
|
||||
warning: null,
|
||||
critical: null,
|
||||
fsFree: null,
|
||||
fsSize: null,
|
||||
fsUsed: null,
|
||||
},
|
||||
{
|
||||
id: 'WD-WCC7K7YL9876',
|
||||
device: 'sdb',
|
||||
name: 'disk1',
|
||||
size: 4000787030016,
|
||||
idx: 1,
|
||||
type: ArrayDiskType.DATA,
|
||||
status: ArrayDiskStatus.DISK_OK,
|
||||
isSpinning: true, // Currently spinning
|
||||
rotational: true,
|
||||
exportable: false,
|
||||
numErrors: 0,
|
||||
numReads: 5000,
|
||||
numWrites: 3000,
|
||||
temp: 35,
|
||||
comment: 'Data Disk 1',
|
||||
format: 'GPT: 4KiB-aligned',
|
||||
fsType: 'xfs',
|
||||
transport: 'sata',
|
||||
warning: null,
|
||||
critical: null,
|
||||
fsFree: 1000000000,
|
||||
fsSize: 4000000000,
|
||||
fsUsed: 3000000000,
|
||||
},
|
||||
{
|
||||
id: 'WD-SPUNDOWN123',
|
||||
device: 'sdd',
|
||||
name: 'disk2',
|
||||
size: 4000787030016,
|
||||
idx: 2,
|
||||
type: ArrayDiskType.DATA,
|
||||
status: ArrayDiskStatus.DISK_OK,
|
||||
isSpinning: false, // Spun down
|
||||
rotational: true,
|
||||
exportable: false,
|
||||
numErrors: 0,
|
||||
numReads: 3000,
|
||||
numWrites: 1000,
|
||||
temp: 30,
|
||||
comment: 'Data Disk 2 (spun down)',
|
||||
format: 'GPT: 4KiB-aligned',
|
||||
fsType: 'xfs',
|
||||
transport: 'sata',
|
||||
warning: null,
|
||||
critical: null,
|
||||
fsFree: 2000000000,
|
||||
fsSize: 4000000000,
|
||||
fsUsed: 2000000000,
|
||||
},
|
||||
];
|
||||
|
||||
const mockDiskLayoutData: Systeminformation.DiskLayoutData[] = [
|
||||
{
|
||||
@@ -92,6 +178,25 @@ describe('DisksService', () => {
|
||||
smartStatus: 'unknown', // Simulate unknown status
|
||||
temperature: null,
|
||||
},
|
||||
{
|
||||
device: '/dev/sdd',
|
||||
type: 'HD',
|
||||
name: 'WD Spun Down',
|
||||
vendor: 'Western Digital',
|
||||
size: 4000787030016,
|
||||
bytesPerSector: 512,
|
||||
totalCylinders: 486401,
|
||||
totalHeads: 255,
|
||||
totalSectors: 7814037168,
|
||||
totalTracks: 124032255,
|
||||
tracksPerCylinder: 255,
|
||||
sectorsPerTrack: 63,
|
||||
firmwareRevision: '82.00A82',
|
||||
serialNum: 'WD-SPUNDOWN123',
|
||||
interfaceType: 'SATA',
|
||||
smartStatus: 'Ok',
|
||||
temperature: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockBlockDeviceData: Systeminformation.BlockDevicesData[] = [
|
||||
@@ -174,17 +279,50 @@ describe('DisksService', () => {
|
||||
protocol: 'SATA', // Assume SATA even if interface type unknown for disk
|
||||
identifier: '/dev/sdc1',
|
||||
},
|
||||
// Partition for sdd
|
||||
{
|
||||
name: 'sdd1',
|
||||
type: 'part',
|
||||
fsType: 'xfs',
|
||||
mount: '/mnt/disk2',
|
||||
size: 4000787030016,
|
||||
physical: 'HDD',
|
||||
uuid: 'UUID-SDD1',
|
||||
label: 'Data2',
|
||||
model: 'WD Spun Down',
|
||||
serial: 'WD-SPUNDOWN123',
|
||||
removable: false,
|
||||
protocol: 'SATA',
|
||||
identifier: '/dev/sdd1',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset mocks before each test using vi
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create mock ConfigService
|
||||
const mockConfigService = {
|
||||
get: vi.fn().mockImplementation((key: string, defaultValue?: any) => {
|
||||
if (key === 'store.emhttp.disks') {
|
||||
return mockArrayDisks;
|
||||
}
|
||||
return defaultValue;
|
||||
}),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [DisksService],
|
||||
providers: [
|
||||
DisksService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: mockConfigService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<DisksService>(DisksService);
|
||||
configService = module.get<ConfigService>(ConfigService);
|
||||
|
||||
// Setup default mock implementations
|
||||
mockDiskLayout.mockResolvedValue(mockDiskLayoutData);
|
||||
@@ -207,46 +345,112 @@ describe('DisksService', () => {
|
||||
// --- Test getDisks ---
|
||||
|
||||
describe('getDisks', () => {
|
||||
it('should return disks without temperature', async () => {
|
||||
it('should return disks with spinning state from store', async () => {
|
||||
const disks = await service.getDisks();
|
||||
|
||||
expect(mockDiskLayout).toHaveBeenCalledTimes(1);
|
||||
expect(mockBlockDevices).toHaveBeenCalledTimes(1);
|
||||
expect(mockExeca).not.toHaveBeenCalled(); // Temperature should not be fetched
|
||||
expect(mockBatchProcess).toHaveBeenCalledTimes(1); // Still uses batchProcess for parsing
|
||||
expect(configService.get).toHaveBeenCalledWith('store.emhttp.disks', []);
|
||||
expect(mockBatchProcess).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(disks).toHaveLength(mockDiskLayoutData.length);
|
||||
expect(disks[0]).toMatchObject({
|
||||
id: 'S4ENNF0N123456',
|
||||
device: '/dev/sda',
|
||||
type: 'HD',
|
||||
name: 'SAMSUNG MZVLB512HBJQ-000L7',
|
||||
vendor: 'Samsung',
|
||||
size: 512110190592,
|
||||
interfaceType: DiskInterfaceType.PCIE,
|
||||
smartStatus: DiskSmartStatus.OK,
|
||||
temperature: null, // Temperature is now null by default
|
||||
partitions: [
|
||||
{ name: 'sda1', fsType: DiskFsType.VFAT, size: 536870912 },
|
||||
{ name: 'sda2', fsType: DiskFsType.EXT4, size: 511560000000 },
|
||||
],
|
||||
|
||||
// Check NVMe disk with null spinning state
|
||||
const nvmeDisk = disks.find((d) => d.id === 'S4ENNF0N123456');
|
||||
expect(nvmeDisk).toBeDefined();
|
||||
expect(nvmeDisk?.isSpinning).toBe(false); // null from state defaults to false
|
||||
expect(nvmeDisk?.interfaceType).toBe(DiskInterfaceType.PCIE);
|
||||
expect(nvmeDisk?.smartStatus).toBe(DiskSmartStatus.OK);
|
||||
expect(nvmeDisk?.partitions).toHaveLength(2);
|
||||
|
||||
// Check spinning disk
|
||||
const spinningDisk = disks.find((d) => d.id === 'WD-WCC7K7YL9876');
|
||||
expect(spinningDisk).toBeDefined();
|
||||
expect(spinningDisk?.isSpinning).toBe(true); // From state
|
||||
expect(spinningDisk?.interfaceType).toBe(DiskInterfaceType.SATA);
|
||||
|
||||
// Check spun down disk
|
||||
const spunDownDisk = disks.find((d) => d.id === 'WD-SPUNDOWN123');
|
||||
expect(spunDownDisk).toBeDefined();
|
||||
expect(spunDownDisk?.isSpinning).toBe(false); // From state
|
||||
|
||||
// Check disk not in state (defaults to not spinning)
|
||||
const unknownDisk = disks.find((d) => d.id === 'OTHER-SERIAL-123');
|
||||
expect(unknownDisk).toBeDefined();
|
||||
expect(unknownDisk?.isSpinning).toBe(false); // Not in state, defaults to false
|
||||
expect(unknownDisk?.interfaceType).toBe(DiskInterfaceType.UNKNOWN);
|
||||
expect(unknownDisk?.smartStatus).toBe(DiskSmartStatus.UNKNOWN);
|
||||
});
|
||||
|
||||
it('should handle empty state gracefully', async () => {
|
||||
vi.mocked(configService.get).mockImplementation((key: string, defaultValue?: any) => {
|
||||
if (key === 'store.emhttp.disks') {
|
||||
return [];
|
||||
}
|
||||
return defaultValue;
|
||||
});
|
||||
expect(disks[1]).toMatchObject({
|
||||
id: 'WD-WCC7K7YL9876',
|
||||
device: '/dev/sdb',
|
||||
interfaceType: DiskInterfaceType.SATA,
|
||||
smartStatus: DiskSmartStatus.OK,
|
||||
temperature: null,
|
||||
partitions: [{ name: 'sdb1', fsType: DiskFsType.XFS, size: 4000787030016 }],
|
||||
|
||||
const disks = await service.getDisks();
|
||||
|
||||
// All disks should default to not spinning when state is empty
|
||||
expect(disks).toHaveLength(mockDiskLayoutData.length);
|
||||
disks.forEach((disk) => {
|
||||
expect(disk.isSpinning).toBe(false);
|
||||
});
|
||||
expect(disks[2]).toMatchObject({
|
||||
id: 'OTHER-SERIAL-123',
|
||||
device: '/dev/sdc',
|
||||
interfaceType: DiskInterfaceType.UNKNOWN,
|
||||
smartStatus: DiskSmartStatus.UNKNOWN,
|
||||
temperature: null,
|
||||
partitions: [{ name: 'sdc1', fsType: DiskFsType.NTFS, size: 1000204886016 }],
|
||||
});
|
||||
|
||||
it('should handle trimmed serial numbers correctly', async () => {
|
||||
// Add disk with spaces in ID
|
||||
const disksWithSpaces = [...mockArrayDisks];
|
||||
disksWithSpaces[0] = {
|
||||
...disksWithSpaces[0],
|
||||
id: ' S4ENNF0N123456 ', // spaces around ID
|
||||
};
|
||||
|
||||
vi.mocked(configService.get).mockImplementation((key: string, defaultValue?: any) => {
|
||||
if (key === 'store.emhttp.disks') {
|
||||
return disksWithSpaces;
|
||||
}
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
const disks = await service.getDisks();
|
||||
const disk = disks.find((d) => d.id === 'S4ENNF0N123456');
|
||||
|
||||
expect(disk).toBeDefined();
|
||||
expect(disk?.isSpinning).toBe(false); // null becomes false
|
||||
});
|
||||
|
||||
it('should correctly map partitions to disks', async () => {
|
||||
const disks = await service.getDisks();
|
||||
|
||||
const disk1 = disks.find((d) => d.id === 'S4ENNF0N123456');
|
||||
expect(disk1?.partitions).toHaveLength(2);
|
||||
expect(disk1?.partitions[0]).toEqual({
|
||||
name: 'sda1',
|
||||
fsType: DiskFsType.VFAT,
|
||||
size: 536870912,
|
||||
});
|
||||
expect(disk1?.partitions[1]).toEqual({
|
||||
name: 'sda2',
|
||||
fsType: DiskFsType.EXT4,
|
||||
size: 511560000000,
|
||||
});
|
||||
|
||||
const disk2 = disks.find((d) => d.id === 'WD-WCC7K7YL9876');
|
||||
expect(disk2?.partitions).toHaveLength(1);
|
||||
expect(disk2?.partitions[0]).toEqual({
|
||||
name: 'sdb1',
|
||||
fsType: DiskFsType.XFS,
|
||||
size: 4000787030016,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use ConfigService to get state data', async () => {
|
||||
await service.getDisks();
|
||||
|
||||
// Verify we're accessing the state through ConfigService
|
||||
expect(configService.get).toHaveBeenCalledWith('store.emhttp.disks', []);
|
||||
});
|
||||
|
||||
it('should handle empty disk layout or block devices', async () => {
|
||||
@@ -267,6 +471,31 @@ describe('DisksService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- Test getDisk ---
|
||||
describe('getDisk', () => {
|
||||
it('should return a specific disk by id', async () => {
|
||||
const disk = await service.getDisk('S4ENNF0N123456');
|
||||
|
||||
expect(disk).toBeDefined();
|
||||
expect(disk.id).toBe('S4ENNF0N123456');
|
||||
expect(disk.isSpinning).toBe(false); // null becomes false
|
||||
});
|
||||
|
||||
it('should return spinning disk correctly', async () => {
|
||||
const disk = await service.getDisk('WD-WCC7K7YL9876');
|
||||
|
||||
expect(disk).toBeDefined();
|
||||
expect(disk.id).toBe('WD-WCC7K7YL9876');
|
||||
expect(disk.isSpinning).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent disk', async () => {
|
||||
await expect(service.getDisk('NONEXISTENT')).rejects.toThrow(
|
||||
'Disk with id NONEXISTENT not found'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Test getTemperature ---
|
||||
describe('getTemperature', () => {
|
||||
it('should return temperature for a disk', async () => {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import type { Systeminformation } from 'systeminformation';
|
||||
import { execa } from 'execa';
|
||||
import { blockDevices, diskLayout } from 'systeminformation';
|
||||
|
||||
import { ArrayDisk } from '@app/unraid-api/graph/resolvers/array/array.model.js';
|
||||
import {
|
||||
Disk,
|
||||
DiskFsType,
|
||||
@@ -14,6 +16,7 @@ import { batchProcess } from '@app/utils.js';
|
||||
|
||||
@Injectable()
|
||||
export class DisksService {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
public async getTemperature(device: string): Promise<number | null> {
|
||||
try {
|
||||
const { stdout } = await execa('smartctl', ['-A', device]);
|
||||
@@ -51,7 +54,8 @@ export class DisksService {
|
||||
|
||||
private async parseDisk(
|
||||
disk: Systeminformation.DiskLayoutData,
|
||||
partitionsToParse: Systeminformation.BlockDevicesData[]
|
||||
partitionsToParse: Systeminformation.BlockDevicesData[],
|
||||
arrayDisks: ArrayDisk[]
|
||||
): Promise<Omit<Disk, 'temperature'>> {
|
||||
const partitions = partitionsToParse
|
||||
// Only get partitions from this disk
|
||||
@@ -115,6 +119,8 @@ export class DisksService {
|
||||
mappedInterfaceType = DiskInterfaceType.UNKNOWN;
|
||||
}
|
||||
|
||||
const arrayDisk = arrayDisks.find((d) => d.id.trim() === disk.serialNum.trim());
|
||||
|
||||
return {
|
||||
...disk,
|
||||
id: disk.serialNum, // Ensure id is set
|
||||
@@ -123,6 +129,7 @@ export class DisksService {
|
||||
DiskSmartStatus.UNKNOWN,
|
||||
interfaceType: mappedInterfaceType,
|
||||
partitions,
|
||||
isSpinning: arrayDisk?.isSpinning ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,9 +140,9 @@ export class DisksService {
|
||||
const partitions = await blockDevices().then((devices) =>
|
||||
devices.filter((device) => device.type === 'part')
|
||||
);
|
||||
|
||||
const arrayDisks = this.configService.get<ArrayDisk[]>('store.emhttp.disks', []);
|
||||
const { data } = await batchProcess(await diskLayout(), async (disk) =>
|
||||
this.parseDisk(disk, partitions)
|
||||
this.parseDisk(disk, partitions, arrayDisks)
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user