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:
Eli Bosley
2025-09-08 12:07:15 -04:00
committed by GitHub
parent 116ee88fcf
commit 193be3df36
9 changed files with 311 additions and 37 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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({

View File

@@ -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;
}

View File

@@ -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',

View File

@@ -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;
}
}

View File

@@ -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 () => {

View File

@@ -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;
}