From 4d7d4a375a9ff3493399cf5ad46f5e07b7285f92 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 4 Apr 2025 14:08:53 -0400 Subject: [PATCH 1/9] chore: move disks into the service --- api/generated-schema.graphql | 38 +- api/src/core/modules/get-disks.ts | 92 ---- api/src/graphql/generated/api/operations.ts | 1 + api/src/graphql/generated/api/types.ts | 39 +- .../graphql/schema/types/array/array.graphql | 19 +- .../graphql/schema/types/disks/disk.graphql | 2 + .../graph/resolvers/disks/disks.module.ts | 10 + .../resolvers/disks/disks.resolver.spec.ts | 67 ++- .../graph/resolvers/disks/disks.resolver.ts | 8 +- .../resolvers/disks/disks.service.spec.ts | 419 ++++++++++++++++++ .../graph/resolvers/disks/disks.service.ts | 156 +++++++ .../graph/resolvers/resolvers.module.ts | 5 +- 12 files changed, 721 insertions(+), 135 deletions(-) delete mode 100644 api/src/core/modules/get-disks.ts create mode 100644 api/src/unraid-api/graph/resolvers/disks/disks.module.ts create mode 100644 api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts create mode 100644 api/src/unraid-api/graph/resolvers/disks/disks.service.ts diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index a6451218a6..51187eaac5 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -120,6 +120,8 @@ type ArrayCapacity { } type ArrayDisk { + color: ArrayDiskFsColor + """ User comment on disk """ comment: String @@ -187,14 +189,35 @@ type ArrayDisk { } enum ArrayDiskFsColor { - """Disk is OK and not running""" - green_off + """New device, in standby mode (spun-down)""" + blue_blink + + """New device""" + blue_on - """Disk is OK and running""" + """Device is in standby mode (spun-down)""" + green_blink + + """Normal operation, device is active""" green_on + + """Device not present""" + grey_off + + """ + Device is missing (disabled) or contents emulated / Parity device is missing + """ red_off + + """Device is disabled or contents emulated / Parity device is disabled""" red_on - yellow_off + + """ + Device contents invalid or emulated / Parity is invalid, in standby mode (spun-down) + """ + yellow_blink + + """Device contents invalid or emulated / Parity is invalid""" yellow_on } @@ -519,6 +542,8 @@ type Disk { enum DiskFsType { btrfs + ext4 + ntfs vfat xfs zfs @@ -1084,9 +1109,6 @@ type Query { display: Display docker: Docker! - """All Docker containers""" - dockerContainers(all: Boolean): [DockerContainer!]! - """Docker network""" dockerNetwork(id: ID!): DockerNetwork! @@ -1322,8 +1344,6 @@ type Subscription { array: Array! config: Config! display: Display - dockerContainer(id: ID!): DockerContainer! - dockerContainers: [DockerContainer] dockerNetwork(id: ID!): DockerNetwork! dockerNetworks: [DockerNetwork]! flash: Flash! diff --git a/api/src/core/modules/get-disks.ts b/api/src/core/modules/get-disks.ts deleted file mode 100644 index 687f4b8771..0000000000 --- a/api/src/core/modules/get-disks.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { Systeminformation } from 'systeminformation'; -import { execa } from 'execa'; -import { blockDevices, diskLayout } from 'systeminformation'; - -import type { Disk } from '@app/graphql/generated/api/types.js'; -import { graphqlLogger } from '@app/core/log.js'; -import { DiskFsType, DiskInterfaceType, DiskSmartStatus } from '@app/graphql/generated/api/types.js'; -import { batchProcess } from '@app/utils.js'; - -const getTemperature = async (disk: Systeminformation.DiskLayoutData): Promise => { - try { - const stdout = await execa('smartctl', ['-A', disk.device]) - .then(({ stdout }) => stdout) - .catch(() => ''); - const lines = stdout.split('\n'); - const header = lines.find((line) => line.startsWith('ID#')) ?? ''; - const fields = lines.splice(lines.indexOf(header) + 1, lines.length); - const field = fields.find( - (line) => line.includes('Temperature_Celsius') || line.includes('Airflow_Temperature_Cel') - ); - - if (!field) { - return -1; - } - - if (field.includes('Min/Max')) { - return Number.parseInt(field.split(' - ')[1].trim().split(' ')[0], 10); - } - - const line = field.split(' '); - return Number.parseInt(line[line.length - 1], 10); - } catch (error) { - graphqlLogger.warn('Caught error fetching disk temperature: %o', error); - return -1; - } -}; - -const parseDisk = async ( - disk: Systeminformation.DiskLayoutData, - partitionsToParse: Systeminformation.BlockDevicesData[], - temperature = false -): Promise => { - const partitions = partitionsToParse - // Only get partitions from this disk - .filter((partition) => partition.name.startsWith(disk.device.split('/dev/')[1])) - // Remove unneeded fields - .map(({ name, fsType, size }) => ({ - name, - fsType: typeof fsType === 'string' ? DiskFsType[fsType] : undefined, - size, - })); - - return { - ...disk, - smartStatus: - typeof disk.smartStatus === 'string' - ? DiskSmartStatus[disk.smartStatus.toUpperCase()] - : undefined, - interfaceType: - typeof disk.interfaceType === 'string' - ? DiskInterfaceType[disk.interfaceType] - : DiskInterfaceType.UNKNOWN, - temperature: temperature ? await getTemperature(disk) : -1, - partitions, - id: disk.serialNum, - }; -}; - -/** - * Get all disks. - */ -export const getDisks = async (options?: { temperature: boolean }): Promise => { - // Return all fields but temperature - if (options?.temperature === false) { - const partitions = await blockDevices().then((devices) => - devices.filter((device) => device.type === 'part') - ); - const diskLayoutData = await diskLayout(); - const disks = await Promise.all(diskLayoutData.map((disk) => parseDisk(disk, partitions))); - - return disks; - } - - const partitions = await blockDevices().then((devices) => - devices.filter((device) => device.type === 'part') - ); - - const { data } = await batchProcess(await diskLayout(), async (disk) => - parseDisk(disk, partitions, true) - ); - return data; -}; diff --git a/api/src/graphql/generated/api/operations.ts b/api/src/graphql/generated/api/operations.ts index 5c2cb56de9..fe5c4cb6dd 100755 --- a/api/src/graphql/generated/api/operations.ts +++ b/api/src/graphql/generated/api/operations.ts @@ -195,6 +195,7 @@ export function ArrayCapacitySchema(): z.ZodObject> { export function ArrayDiskSchema(): z.ZodObject> { return z.object({ __typename: z.literal('ArrayDisk').optional(), + color: ArrayDiskFsColorSchema.nullish(), comment: z.string().nullish(), critical: z.number().nullish(), device: z.string().nullish(), diff --git a/api/src/graphql/generated/api/types.ts b/api/src/graphql/generated/api/types.ts index 0e28eb93be..9ebabaaafd 100644 --- a/api/src/graphql/generated/api/types.ts +++ b/api/src/graphql/generated/api/types.ts @@ -140,6 +140,7 @@ export type ArrayCapacity = { export type ArrayDisk = { __typename?: 'ArrayDisk'; + color?: Maybe; /** User comment on disk */ comment?: Maybe; /** (%) Disk space left for critical */ @@ -183,13 +184,23 @@ export type ArrayDisk = { }; export enum ArrayDiskFsColor { - /** Disk is OK and not running */ - GREEN_OFF = 'green_off', - /** Disk is OK and running */ + /** New device, in standby mode (spun-down) */ + BLUE_BLINK = 'blue_blink', + /** New device */ + BLUE_ON = 'blue_on', + /** Device is in standby mode (spun-down) */ + GREEN_BLINK = 'green_blink', + /** Normal operation, device is active */ GREEN_ON = 'green_on', + /** Device not present */ + GREY_OFF = 'grey_off', + /** Device is missing (disabled) or contents emulated / Parity device is missing */ RED_OFF = 'red_off', + /** Device is disabled or contents emulated / Parity device is disabled */ RED_ON = 'red_on', - YELLOW_OFF = 'yellow_off', + /** Device contents invalid or emulated / Parity is invalid, in standby mode (spun-down) */ + YELLOW_BLINK = 'yellow_blink', + /** Device contents invalid or emulated / Parity is invalid */ YELLOW_ON = 'yellow_on' } @@ -520,6 +531,8 @@ export type Disk = { export enum DiskFsType { BTRFS = 'btrfs', + EXT4 = 'ext4', + NTFS = 'ntfs', VFAT = 'vfat', XFS = 'xfs', ZFS = 'zfs' @@ -1210,8 +1223,6 @@ export type Query = { disks: Array>; display?: Maybe; docker: Docker; - /** All Docker containers */ - dockerContainers: Array; /** Docker network */ dockerNetwork: DockerNetwork; /** All Docker networks */ @@ -1263,11 +1274,6 @@ export type QuerydiskArgs = { }; -export type QuerydockerContainersArgs = { - all?: InputMaybe; -}; - - export type QuerydockerNetworkArgs = { id: Scalars['ID']['input']; }; @@ -1469,8 +1475,6 @@ export type Subscription = { array: ArrayType; config: Config; display?: Maybe; - dockerContainer: DockerContainer; - dockerContainers?: Maybe>>; dockerNetwork: DockerNetwork; dockerNetworks: Array>; flash: Flash; @@ -1500,11 +1504,6 @@ export type Subscription = { }; -export type SubscriptiondockerContainerArgs = { - id: Scalars['ID']['input']; -}; - - export type SubscriptiondockerNetworkArgs = { id: Scalars['ID']['input']; }; @@ -2355,6 +2354,7 @@ export type ArrayCapacityResolvers; export type ArrayDiskResolvers = ResolversObject<{ + color?: Resolver, ParentType, ContextType>; comment?: Resolver, ParentType, ContextType>; critical?: Resolver, ParentType, ContextType>; device?: Resolver, ParentType, ContextType>; @@ -2987,7 +2987,6 @@ export type QueryResolvers>, ParentType, ContextType>; display?: Resolver, ParentType, ContextType>; docker?: Resolver; - dockerContainers?: Resolver, ParentType, ContextType, Partial>; dockerNetwork?: Resolver>; dockerNetworks?: Resolver>, ParentType, ContextType, Partial>; extraAllowedOrigins?: Resolver, ParentType, ContextType>; @@ -3083,8 +3082,6 @@ export type SubscriptionResolvers; config?: SubscriptionResolver; display?: SubscriptionResolver, "display", ParentType, ContextType>; - dockerContainer?: SubscriptionResolver>; - dockerContainers?: SubscriptionResolver>>, "dockerContainers", ParentType, ContextType>; dockerNetwork?: SubscriptionResolver>; dockerNetworks?: SubscriptionResolver>, "dockerNetworks", ParentType, ContextType>; flash?: SubscriptionResolver; diff --git a/api/src/graphql/schema/types/array/array.graphql b/api/src/graphql/schema/types/array/array.graphql index 859fe83ad3..517be6a2ff 100644 --- a/api/src/graphql/schema/types/array/array.graphql +++ b/api/src/graphql/schema/types/array/array.graphql @@ -175,6 +175,7 @@ type ArrayDisk { format: String """ ata | nvme | usb | (others)""" transport: String + color: ArrayDiskFsColor } # type ArrayParityDisk {} @@ -192,12 +193,22 @@ enum ArrayDiskType { } enum ArrayDiskFsColor { - """Disk is OK and running""" + """Normal operation, device is active""" green_on - """Disk is OK and not running""" - green_off + """Device is in standby mode (spun-down)""" + green_blink + """New device""" + blue_on + """New device, in standby mode (spun-down)""" + blue_blink + """Device contents invalid or emulated / Parity is invalid""" yellow_on - yellow_off + """Device contents invalid or emulated / Parity is invalid, in standby mode (spun-down)""" + yellow_blink + """Device is disabled or contents emulated / Parity device is disabled""" red_on + """Device is missing (disabled) or contents emulated / Parity device is missing""" red_off + """Device not present""" + grey_off } \ No newline at end of file diff --git a/api/src/graphql/schema/types/disks/disk.graphql b/api/src/graphql/schema/types/disks/disk.graphql index b408578163..acb078873d 100644 --- a/api/src/graphql/schema/types/disks/disk.graphql +++ b/api/src/graphql/schema/types/disks/disk.graphql @@ -51,6 +51,8 @@ enum DiskFsType { btrfs vfat zfs + ext4 + ntfs } enum DiskInterfaceType { diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.module.ts b/api/src/unraid-api/graph/resolvers/disks/disks.module.ts new file mode 100644 index 0000000000..30e704d997 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/disks/disks.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; + +import { DisksResolver } from '@app/unraid-api/graph/resolvers/disks/disks.resolver.js'; +import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.service.js'; + +@Module({ + providers: [DisksResolver, DisksService], + exports: [DisksResolver], +}) +export class DisksModule {} diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/disks/disks.resolver.spec.ts index 729b0c0f07..70ba979f62 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.resolver.spec.ts @@ -1,22 +1,85 @@ import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Disk } from '@app/graphql/generated/api/types.js'; +import { DiskInterfaceType, DiskSmartStatus } from '@app/graphql/generated/api/types.js'; import { DisksResolver } from '@app/unraid-api/graph/resolvers/disks/disks.resolver.js'; +import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.service.js'; // Renamed from DiskService + +// Mock the DisksService +const mockDisksService = { + getDisks: vi.fn(), +}; describe('DisksResolver', () => { let resolver: DisksResolver; + let service: DisksService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [DisksResolver], + providers: [ + DisksResolver, + { + provide: DisksService, + useValue: mockDisksService, + }, + ], }).compile(); resolver = module.get(DisksResolver); + service = module.get(DisksService); + + // Reset mocks before each test + vi.clearAllMocks(); }); it('should be defined', () => { expect(resolver).toBeDefined(); }); + + describe('disks', () => { + it('should return an array of disks', async () => { + const mockResult: Disk[] = [ + { + id: 'SERIAL123', + device: '/dev/sda', + type: 'SSD', + name: 'Samsung SSD 860 EVO 1TB', + vendor: 'Samsung', + size: 1000204886016, + bytesPerSector: 512, + totalCylinders: 121601, + totalHeads: 255, + totalSectors: 1953525168, + totalTracks: 31008255, + tracksPerCylinder: 255, + sectorsPerTrack: 63, + firmwareRevision: 'RVT04B6Q', + serialNum: 'SERIAL123', + interfaceType: DiskInterfaceType.SATA, + smartStatus: DiskSmartStatus.OK, + temperature: -1, + partitions: [], + }, + ]; + mockDisksService.getDisks.mockResolvedValue(mockResult); + + const result = await resolver.disks(); + + expect(result).toEqual(mockResult); + expect(service.getDisks).toHaveBeenCalledTimes(1); + expect(service.getDisks).toHaveBeenCalledWith({ temperature: true }); + }); + + it('should call the service', async () => { + mockDisksService.getDisks.mockResolvedValue([]); // Return empty for simplicity + + await resolver.disks(); + + expect(service.getDisks).toHaveBeenCalledTimes(1); + expect(service.getDisks).toHaveBeenCalledWith({ temperature: true }); + }); + }); }); diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.resolver.ts b/api/src/unraid-api/graph/resolvers/disks/disks.resolver.ts index baa7c54710..f387ff6d59 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.resolver.ts @@ -2,11 +2,13 @@ import { Query, Resolver } from '@nestjs/graphql'; import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz'; -import { getDisks } from '@app/core/modules/get-disks.js'; import { Resource } from '@app/graphql/generated/api/types.js'; +import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.service.js'; @Resolver('Disks') export class DisksResolver { + constructor(private readonly disksService: DisksService) {} + @Query() @UsePermissions({ action: AuthActionVerb.READ, @@ -14,9 +16,7 @@ export class DisksResolver { possession: AuthPossession.ANY, }) public async disks() { - const disks = await getDisks({ - temperature: true, - }); + const disks = await this.disksService.getDisks({ temperature: true }); return disks; } } 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 new file mode 100644 index 0000000000..1871961c2e --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts @@ -0,0 +1,419 @@ +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 type { Disk } from '@app/graphql/generated/api/types.js'; +import { DiskFsType, DiskInterfaceType, DiskSmartStatus } from '@app/graphql/generated/api/types.js'; +import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.service.js'; +import { batchProcess } from '@app/utils.js'; + +// Mock the external dependencies using vi +vi.mock('execa'); +vi.mock('systeminformation'); +vi.mock('@app/utils.js', () => ({ + batchProcess: vi.fn().mockImplementation(async (items, processor) => { + const data = await Promise.all(items.map(processor)); + return { data, errors: [] }; + }), +})); + +// Remove explicit type assertions for mocks +const mockExeca = execa as any; // Using 'any' for simplicity with complex mock setups +const mockBlockDevices = blockDevices as any; +const mockDiskLayout = diskLayout as any; +const mockBatchProcess = batchProcess as any; + +describe('DisksService', () => { + let service: DisksService; + + const mockDiskLayoutData: Systeminformation.DiskLayoutData[] = [ + { + device: '/dev/sda', + type: 'HD', + name: 'SAMSUNG MZVLB512HBJQ-000L7', + vendor: 'Samsung', + size: 512110190592, + bytesPerSector: 512, + totalCylinders: 62260, + totalHeads: 255, + totalSectors: 1000215216, + totalTracks: 15876300, + tracksPerCylinder: 255, + sectorsPerTrack: 63, + firmwareRevision: 'EXF72L1Q', + serialNum: 'S4ENNF0N123456', + interfaceType: 'NVMe', + smartStatus: 'Ok', + temperature: null, // Systeminformation doesn't provide this directly + }, + { + device: '/dev/sdb', + type: 'HD', + name: 'WDC WD40EFRX-68N32N0', + vendor: 'Western Digital', + size: 4000787030016, + bytesPerSector: 512, + totalCylinders: 486401, + totalHeads: 255, + totalSectors: 7814037168, + totalTracks: 124032255, + tracksPerCylinder: 255, + sectorsPerTrack: 63, + firmwareRevision: '82.00A82', + serialNum: 'WD-WCC7K7YL9876', + interfaceType: 'SATA', + smartStatus: 'Ok', + temperature: null, + }, + { + device: '/dev/sdc', // Disk with unknown interface type + type: 'HD', + name: 'Some Other Disk', + vendor: 'OtherVendor', + size: 1000204886016, + bytesPerSector: 512, + totalCylinders: 121601, + totalHeads: 255, + totalSectors: 1953525168, + totalTracks: 30908255, + tracksPerCylinder: 255, + sectorsPerTrack: 63, + firmwareRevision: '1.0', + serialNum: 'OTHER-SERIAL-123', + interfaceType: '', // Simulate unknown type + smartStatus: 'unknown', // Simulate unknown status + temperature: null, + }, + ]; + + const mockBlockDeviceData: Systeminformation.BlockDevicesData[] = [ + // Partitions for sda + { + name: 'sda1', + type: 'part', + fsType: 'vfat', + mount: '/boot/efi', + size: 536870912, + physical: 'SSD', + uuid: 'UUID-SDA1', + label: 'EFI', + model: 'SAMSUNG MZVLB512HBJQ-000L7', + serial: 'S4ENNF0N123456', + removable: false, + protocol: 'NVMe', + identifier: '/dev/sda1', + }, + { + name: 'sda2', + type: 'part', + fsType: 'ext4', + mount: '/', + size: 511560000000, // Adjusted size + physical: 'SSD', + uuid: 'UUID-SDA2', + label: 'root', + model: 'SAMSUNG MZVLB512HBJQ-000L7', + serial: 'S4ENNF0N123456', + removable: false, + protocol: 'NVMe', + identifier: '/dev/sda2', + }, + // Partitions for sdb + { + name: 'sdb1', + type: 'part', + fsType: 'xfs', + mount: '/mnt/data', + size: 4000787030016, + physical: 'HDD', + uuid: 'UUID-SDB1', + label: 'Data', + model: 'WDC WD40EFRX-68N32N0', + serial: 'WD-WCC7K7YL9876', + removable: false, + protocol: 'SATA', + identifier: '/dev/sdb1', + }, + // Not a partition type, should be filtered out + { + name: 'loop0', + type: 'loop', + fsType: '', + mount: '/snap/core/123', + size: 100000000, + physical: '', + uuid: '', + label: '', + model: '', + serial: '', + removable: false, + protocol: '', + identifier: 'loop0', + }, + // Partition for sdc + { + name: 'sdc1', + type: 'part', + fsType: 'ntfs', // Example different fs type + mount: '/mnt/windows', + size: 1000204886016, + physical: 'HDD', + uuid: 'UUID-SDC1', + label: 'Windows', + model: 'Some Other Disk', + serial: 'OTHER-SERIAL-123', + removable: false, + protocol: 'SATA', // Assume SATA even if interface type unknown for disk + identifier: '/dev/sdc1', + }, + ]; + + beforeEach(async () => { + // Reset mocks before each test using vi + vi.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [DisksService], + }).compile(); + + service = module.get(DisksService); + + // Setup default mock implementations + mockDiskLayout.mockResolvedValue(mockDiskLayoutData); + mockBlockDevices.mockResolvedValue(mockBlockDeviceData); + mockExeca.mockResolvedValue({ + stdout: '', + stderr: '', + exitCode: 0, + failed: false, + command: '', + cwd: '', + isCanceled: false, + }); // Default successful execa + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + // --- Test getDisks --- + + describe('getDisks', () => { + it('should return disks without temperature when options.temperature is false', async () => { + const disks = await service.getDisks({ temperature: false }); + + expect(mockDiskLayout).toHaveBeenCalledTimes(1); + expect(mockBlockDevices).toHaveBeenCalledTimes(1); + expect(mockExeca).not.toHaveBeenCalled(); // Temperature should not be fetched + expect(mockBatchProcess).not.toHaveBeenCalled(); // Should not use batchProcess if temp is false + + 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: -1, // Expect default -1 when not fetched + partitions: [ + { name: 'sda1', fsType: DiskFsType.VFAT, size: 536870912 }, + { name: 'sda2', fsType: DiskFsType.EXT4, size: 511560000000 }, + ], + }); + expect(disks[1]).toMatchObject({ + id: 'WD-WCC7K7YL9876', + device: '/dev/sdb', + interfaceType: DiskInterfaceType.SATA, + smartStatus: DiskSmartStatus.OK, + temperature: -1, + partitions: [{ name: 'sdb1', fsType: DiskFsType.XFS, size: 4000787030016 }], + }); + expect(disks[2]).toMatchObject({ + id: 'OTHER-SERIAL-123', + device: '/dev/sdc', + interfaceType: DiskInterfaceType.UNKNOWN, + smartStatus: DiskSmartStatus.UNKNOWN, + temperature: -1, + partitions: [{ name: 'sdc1', fsType: DiskFsType.NTFS, size: 1000204886016 }], + }); + }); + + it('should return disks with temperature when options.temperature is true or omitted', async () => { + // Mock smartctl output for each disk + mockExeca + .mockResolvedValueOnce({ + // sda - NVMe often doesn't report via smartctl easily, simulate failure + stdout: '', + stderr: 'smartctl open device: /dev/sda failed: Unknown NVMe device', + exitCode: 1, + failed: true, + command: '', + cwd: '', + isCanceled: false, + }) + .mockResolvedValueOnce({ + // sdb - Standard Temp + stdout: `smartctl 7.2 2020-12-30 r5155 [x86_64-linux-5.10.0-8-amd64] (local build) +Copyright (C) 2002-20, Bruce Allen, Christian Franke, www.smartmontools.org + +=== START OF READ SMART DATA SECTION === +SMART Attributes Data Structure revision number: 16 +Vendor Specific SMART Attributes with Thresholds: +ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE + 1 Raw_Read_Error_Rate 0x000f 119 099 006 Pre-fail Always - 197032872 + ... +194 Temperature_Celsius 0x0022 114 091 000 Old_age Always - 36 (Min/Max 19/58) +199 UDMA_CRC_Error_Count 0x003e 200 200 000 Old_age Always - 0 +`, + stderr: '', + exitCode: 0, + failed: false, + command: '', + cwd: '', + isCanceled: false, + }) + .mockResolvedValueOnce({ + // sdc - Airflow Temp + Min/Max format + stdout: `ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE +190 Airflow_Temperature_Cel 0x0022 065 058 045 Old_age Always - 35 (Min/Max 30/42 #123) +`, + stderr: '', + exitCode: 0, + failed: false, + command: '', + cwd: '', + isCanceled: false, + }); + + const disks = await service.getDisks(); // Omit options, should default to temperature: true + + expect(mockDiskLayout).toHaveBeenCalledTimes(1); + expect(mockBlockDevices).toHaveBeenCalledTimes(1); + // Ensure batchProcess was called correctly + expect(mockBatchProcess).toHaveBeenCalledTimes(1); + expect(mockBatchProcess).toHaveBeenCalledWith(mockDiskLayoutData, expect.any(Function)); + + // Check that execa was called for each disk inside the batch processor + expect(mockExeca).toHaveBeenCalledTimes(mockDiskLayoutData.length); + expect(mockExeca).toHaveBeenCalledWith('smartctl', ['-A', '/dev/sda']); + expect(mockExeca).toHaveBeenCalledWith('smartctl', ['-A', '/dev/sdb']); + expect(mockExeca).toHaveBeenCalledWith('smartctl', ['-A', '/dev/sdc']); + + expect(disks).toHaveLength(mockDiskLayoutData.length); + expect(disks[0].temperature).toBe(-1); // Failed smartctl call + expect(disks[1].temperature).toBe(36); // Standard temp line + expect(disks[2].temperature).toBe(35); // Airflow temp line with Min/Max + + // Check other fields remain correct + expect(disks[1]).toMatchObject({ + id: 'WD-WCC7K7YL9876', + device: '/dev/sdb', + interfaceType: DiskInterfaceType.SATA, + smartStatus: DiskSmartStatus.OK, + partitions: [{ name: 'sdb1', fsType: DiskFsType.XFS, size: 4000787030016 }], + }); + }); + + it('should handle errors during temperature fetching gracefully', async () => { + mockExeca.mockRejectedValue(new Error('smartctl command failed')); + + const disks = await service.getDisks({ temperature: true }); // Explicitly true + + expect(mockBatchProcess).toHaveBeenCalledTimes(1); + expect(mockExeca).toHaveBeenCalledTimes(mockDiskLayoutData.length); // Still attempts for all + expect(disks).toHaveLength(mockDiskLayoutData.length); + // All temperatures should be -1 due to errors + expect(disks[0].temperature).toBe(-1); + expect(disks[1].temperature).toBe(-1); + expect(disks[2].temperature).toBe(-1); + }); + + it('should handle case where smartctl output has no temperature field', async () => { + mockExeca.mockResolvedValue({ + stdout: 'ID# ATTRIBUTE_NAME\n1 Some_Attribute 100 100 000 Old_age Always - 0', + stderr: '', + exitCode: 0, + failed: false, + command: '', + cwd: '', + isCanceled: false, + }); // No temp line + + const disks = await service.getDisks({ temperature: true }); + + expect(disks).toHaveLength(mockDiskLayoutData.length); + expect(disks[0].temperature).toBe(-1); + expect(disks[1].temperature).toBe(-1); + expect(disks[2].temperature).toBe(-1); + }); + + it('should handle case where smartctl output has Temperature_Celsius with Min/Max format', async () => { + mockExeca.mockResolvedValue({ + stdout: `ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE +194 Temperature_Celsius 0x0022 070 060 000 Old_age Always - 30 (Min/Max 25/45)`, + stderr: '', + exitCode: 0, + failed: false, + command: '', + cwd: '', + isCanceled: false, + }); + + const disks = await service.getDisks({ temperature: true }); + + expect(disks[0].temperature).toBe(30); + expect(disks[1].temperature).toBe(30); + expect(disks[2].temperature).toBe(30); + }); + + it('should handle empty disk layout or block devices', async () => { + mockDiskLayout.mockResolvedValue([]); + mockBlockDevices.mockResolvedValue([]); + + const disks = await service.getDisks({ temperature: true }); + expect(disks).toEqual([]); + expect(mockBatchProcess).toHaveBeenCalledWith([], expect.any(Function)); + + mockDiskLayout.mockResolvedValue(mockDiskLayoutData); // Restore for next check + mockBlockDevices.mockResolvedValue([]); + const disks2 = await service.getDisks({ temperature: false }); // Temp false path + expect(disks2).toHaveLength(mockDiskLayoutData.length); + expect(disks2[0].partitions).toEqual([]); + expect(disks2[1].partitions).toEqual([]); + expect(disks2[2].partitions).toEqual([]); + }); + }); + + // --- Test getTemperature (Indirectly via getDisks, but can add specific mocks here if needed) --- + // Most cases are covered by getDisks tests above. + // Add specific tests for edge cases in parsing if necessary. + describe('getTemperature parsing (indirectly tested)', () => { + // Example: Test specific parsing logic if needed, though covered above. + it('should correctly parse standard temperature line', async () => { + // Mock execa for a single disk scenario if testing private method logic + const singleDisk = mockDiskLayoutData[1]; // WDC disk + mockDiskLayout.mockResolvedValue([singleDisk]); + mockExeca.mockResolvedValue({ + stdout: `ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE +194 Temperature_Celsius 0x0022 114 091 000 Old_age Always - 42`, // Different temp + stderr: '', + exitCode: 0, + failed: false, + command: '', + cwd: '', + isCanceled: false, + }); + + const disks = await service.getDisks({ temperature: true }); + expect(disks).toHaveLength(1); + expect(disks[0].temperature).toBe(42); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.service.ts b/api/src/unraid-api/graph/resolvers/disks/disks.service.ts new file mode 100644 index 0000000000..033844468d --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/disks/disks.service.ts @@ -0,0 +1,156 @@ +import { Injectable } from '@nestjs/common'; + +import type { Systeminformation } from 'systeminformation'; +import { execa } from 'execa'; +import { blockDevices, diskLayout } from 'systeminformation'; + +import type { Disk } from '@app/graphql/generated/api/types.js'; +import { graphqlLogger } from '@app/core/log.js'; +import { DiskFsType, DiskInterfaceType, DiskSmartStatus } from '@app/graphql/generated/api/types.js'; +import { getters } from '@app/store/index.js'; +import { batchProcess } from '@app/utils.js'; + +@Injectable() +export class DisksService { + // Renamed from DiskService + private async getTemperature(disk: Systeminformation.DiskLayoutData): Promise { + try { + const stdout = await execa('smartctl', ['-A', disk.device]) + .then(({ stdout }) => stdout) + .catch(() => ''); + const lines = stdout.split('\n'); + const header = lines.find((line) => line.startsWith('ID#')) ?? ''; + const fields = lines.splice(lines.indexOf(header) + 1, lines.length); + const field = fields.find( + (line) => + line.includes('Temperature_Celsius') || line.includes('Airflow_Temperature_Cel') + ); + + if (!field) { + return -1; + } + + if (field.includes('Min/Max')) { + return Number.parseInt(field.split(' - ')[1].trim().split(' ')[0], 10); + } + + const line = field.split(' '); + return Number.parseInt(line[line.length - 1], 10); + } catch (error) { + graphqlLogger.warn('Caught error fetching disk temperature: %o', error); + return -1; + } + } + + private async parseDisk( + disk: Systeminformation.DiskLayoutData, + partitionsToParse: Systeminformation.BlockDevicesData[], + temperature = false + ): Promise { + const partitions = partitionsToParse + // Only get partitions from this disk + .filter((partition) => partition.name.startsWith(disk.device.split('/dev/')[1])) + // Remove unneeded fields + .map(({ name, fsType, size }) => { + let mappedFsType: DiskFsType | undefined; + // Explicitly map known fsTypes to the enum (UPPERCASE) + switch (fsType?.toLowerCase()) { + case 'xfs': + mappedFsType = DiskFsType.XFS; // Uppercase + break; + case 'btrfs': + mappedFsType = DiskFsType.BTRFS; // Uppercase + break; + case 'vfat': + mappedFsType = DiskFsType.VFAT; // Uppercase + break; + case 'zfs': + mappedFsType = DiskFsType.ZFS; // Uppercase + break; + case 'ext4': + mappedFsType = DiskFsType.EXT4; // Uppercase + break; + case 'ntfs': + mappedFsType = DiskFsType.NTFS; // Uppercase + break; + default: + mappedFsType = undefined; // Handle unknown types as undefined + } + return { + name, + fsType: mappedFsType, + size, + }; + }) + // Filter out partitions where fsType mapping failed + .filter( + (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; + } + + return { + ...disk, + id: disk.serialNum, // Ensure id is set + smartStatus: + typeof disk.smartStatus === 'string' + ? (DiskSmartStatus[disk.smartStatus.toUpperCase() as keyof typeof DiskSmartStatus] ?? + DiskSmartStatus.UNKNOWN) + : DiskSmartStatus.UNKNOWN, // Default to UNKNOWN if undefined + interfaceType: mappedInterfaceType, + temperature: temperature ? await this.getTemperature(disk) : -1, + partitions, // Now correctly typed after filter + }; + } + + /** + * Get all disks. + */ + async getDisks(options?: { temperature: boolean }): Promise { + const vars = getters.emhttp().var; + + // Return all fields but temperature + if (options?.temperature === false) { + const partitions = await blockDevices().then((devices) => + devices.filter((device) => device.type === 'part') + ); + const diskLayoutData = await diskLayout(); + // Pass unraidVar to parseDisk + const disks = await Promise.all( + diskLayoutData.map((disk) => this.parseDisk(disk, partitions)) + ); + + return disks; + } + + const partitions = await blockDevices().then((devices) => + devices.filter((device) => device.type === 'part') + ); + + const { data } = await batchProcess(await diskLayout(), async (disk) => + // Pass unraidVar and temperature flag to parseDisk + this.parseDisk(disk, partitions, true) + ); + return data; + } +} diff --git a/api/src/unraid-api/graph/resolvers/resolvers.module.ts b/api/src/unraid-api/graph/resolvers/resolvers.module.ts index eed57c0a2c..48e6649604 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -11,7 +11,7 @@ import { ArrayResolver } from '@app/unraid-api/graph/resolvers/array/array.resol import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js'; import { CloudResolver } from '@app/unraid-api/graph/resolvers/cloud/cloud.resolver.js'; import { ConfigResolver } from '@app/unraid-api/graph/resolvers/config/config.resolver.js'; -import { DisksResolver } from '@app/unraid-api/graph/resolvers/disks/disks.resolver.js'; +import { DisksModule } from '@app/unraid-api/graph/resolvers/disks/disks.module.js'; import { DisplayResolver } from '@app/unraid-api/graph/resolvers/display/display.resolver.js'; import { DockerModule } from '@app/unraid-api/graph/resolvers/docker/docker.module.js'; import { FlashResolver } from '@app/unraid-api/graph/resolvers/flash/flash.resolver.js'; @@ -34,7 +34,7 @@ import { ServicesResolver } from '@app/unraid-api/graph/services/services.resolv import { SharesResolver } from '@app/unraid-api/graph/shares/shares.resolver.js'; @Module({ - imports: [AuthModule, DockerModule], + imports: [AuthModule, DockerModule, DisksModule], providers: [ ApiKeyResolver, ArrayMutationsResolver, @@ -45,7 +45,6 @@ import { SharesResolver } from '@app/unraid-api/graph/shares/shares.resolver.js' ConnectResolver, ConnectService, ConnectSettingsService, - DisksResolver, DisplayResolver, FlashResolver, InfoResolver, From 2c30510531a219b4a993d0a4b5be198fde538d5f Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 4 Apr 2025 14:11:03 -0400 Subject: [PATCH 2/9] chore: fix type error --- api/src/core/modules/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/api/src/core/modules/index.ts b/api/src/core/modules/index.ts index 5f980459a0..00167fc60f 100644 --- a/api/src/core/modules/index.ts +++ b/api/src/core/modules/index.ts @@ -10,7 +10,6 @@ export * from './add-share.js'; export * from './add-user.js'; export * from './get-apps.js'; export * from './get-devices.js'; -export * from './get-disks.js'; export * from './get-parity-history.js'; export * from './get-services.js'; export * from './get-users.js'; From 54f831002a8451f1b6039dc67bb8b5bde075fc94 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 7 Apr 2025 13:56:03 -0400 Subject: [PATCH 3/9] chore: fix unit tests --- .../__test__/common/allowed-origins.test.ts | 64 ++----------------- .../core/utils/misc/get-key-file.test.ts | 6 +- .../core/utils/shares/get-shares.test.ts | 1 + api/src/__test__/setup.ts | 8 +++ api/src/__test__/setup/mock-fs-setup.ts | 45 +++++++++++++ api/src/__test__/setup/store-reset.ts | 8 +++ api/src/__test__/store/modules/config.test.ts | 41 +++++++++--- .../store/modules/registration.test.ts | 8 +-- api/src/common/allowed-origins.ts | 3 +- api/src/store/actions/reset-store.ts | 7 ++ api/src/store/index.ts | 24 +------ api/src/store/modules/config.ts | 5 +- api/src/store/modules/registration.ts | 2 + api/src/store/root-reducer.ts | 40 ++++++++++++ .../unraid-api/auth/cookie.service.spec.ts | 41 +++++++++++- api/vite.config.ts | 8 +-- 16 files changed, 203 insertions(+), 108 deletions(-) create mode 100644 api/src/__test__/setup.ts create mode 100644 api/src/__test__/setup/mock-fs-setup.ts create mode 100644 api/src/__test__/setup/store-reset.ts create mode 100644 api/src/store/actions/reset-store.ts create mode 100644 api/src/store/root-reducer.ts diff --git a/api/src/__test__/common/allowed-origins.test.ts b/api/src/__test__/common/allowed-origins.test.ts index 33ed328fbb..cae584ef09 100644 --- a/api/src/__test__/common/allowed-origins.test.ts +++ b/api/src/__test__/common/allowed-origins.test.ts @@ -1,72 +1,16 @@ -import { getAllowedOrigins, getExtraOrigins } from '@app/common/allowed-origins.js'; -import { getServerIps } from '@app/graphql/resolvers/subscription/network.js'; +import { getAllowedOrigins } from '@app/common/allowed-origins.js'; import { store } from '@app/store/index.js'; import { loadConfigFile } from '@app/store/modules/config.js'; import { loadStateFiles } from '@app/store/modules/emhttp.js'; import 'reflect-metadata'; -import { beforeEach, expect, test, vi } from 'vitest'; - -// Mock the dependencies that provide dynamic values -vi.mock('@app/graphql/resolvers/subscription/network.js', () => ({ - getServerIps: vi.fn(), - getUrlForField: vi.fn(({ url, port, portSsl }) => { - if (port) return `http://${url}:${port}`; - if (portSsl) return `https://${url}:${portSsl}`; - return `https://${url}`; - }), -})); - -vi.mock('@app/store/index.js', () => ({ - store: { - getState: vi.fn(() => ({ - emhttp: { - status: 'LOADED', - nginx: { - httpPort: 8080, - httpsPort: 4443, - }, - }, - })), - dispatch: vi.fn(), - }, - getters: { - config: vi.fn(() => ({ - api: { - extraOrigins: 'https://google.com,https://test.com', - }, - })), - }, -})); - -beforeEach(() => { - vi.clearAllMocks(); - - // Mock getServerIps to return a consistent set of URLs - (getServerIps as any).mockReturnValue({ - urls: [ - { ipv4: 'https://tower.local:4443' }, - { ipv4: 'https://192.168.1.150:4443' }, - { ipv4: 'https://tower:4443' }, - { ipv4: 'https://192-168-1-150.thisisfourtyrandomcharacters012345678900.myunraid.net:4443' }, - { ipv4: 'https://10-252-0-1.hash.myunraid.net:4443' }, - { ipv4: 'https://10-252-1-1.hash.myunraid.net:4443' }, - { ipv4: 'https://10-253-3-1.hash.myunraid.net:4443' }, - { ipv4: 'https://10-253-4-1.hash.myunraid.net:4443' }, - { ipv4: 'https://10-253-5-1.hash.myunraid.net:4443' }, - { ipv4: 'https://10-100-0-1.hash.myunraid.net:4443' }, - { ipv4: 'https://10-100-0-2.hash.myunraid.net:4443' }, - { ipv4: 'https://10-123-1-2.hash.myunraid.net:4443' }, - { ipv4: 'https://221-123-121-112.hash.myunraid.net:4443' }, - ], - }); -}); +import { expect, test } from 'vitest'; test('Returns allowed origins', async () => { // Load state files into store - await store.dispatch(loadStateFiles()); - await store.dispatch(loadConfigFile()); + await store.dispatch(loadStateFiles()).unwrap(); + await store.dispatch(loadConfigFile()).unwrap(); // Get allowed origins const allowedOrigins = getAllowedOrigins(); diff --git a/api/src/__test__/core/utils/misc/get-key-file.test.ts b/api/src/__test__/core/utils/misc/get-key-file.test.ts index c443002878..12698aed35 100644 --- a/api/src/__test__/core/utils/misc/get-key-file.test.ts +++ b/api/src/__test__/core/utils/misc/get-key-file.test.ts @@ -44,7 +44,7 @@ test('Returns empty key if key location is empty', async () => { // Check if store has state files loaded const { status } = store.getState().registration; - expect(status).toBe(FileLoadStatus.LOADED); + expect(status).toBe(FileLoadStatus.UNLOADED); await expect(getKeyFile()).resolves.toBe(''); }); @@ -53,10 +53,10 @@ test( async () => { const { getKeyFile } = await import('@app/core/utils/misc/get-key-file.js'); const { loadStateFiles } = await import('@app/store/modules/emhttp.js'); - + const { loadRegistrationKey } = await import('@app/store/modules/registration.js'); // Load state files into store await store.dispatch(loadStateFiles()); - + await store.dispatch(loadRegistrationKey()); // Check if store has state files loaded const { status } = store.getState().registration; expect(status).toBe(FileLoadStatus.LOADED); diff --git a/api/src/__test__/core/utils/shares/get-shares.test.ts b/api/src/__test__/core/utils/shares/get-shares.test.ts index b3e75bb46a..3131f61ebe 100644 --- a/api/src/__test__/core/utils/shares/get-shares.test.ts +++ b/api/src/__test__/core/utils/shares/get-shares.test.ts @@ -209,6 +209,7 @@ test('Returns shares by type', async () => { }); test('Returns shares by name', async () => { + await store.dispatch(loadStateFiles()); expect(getShares('user', { name: 'domains' })).toMatchInlineSnapshot(` { "allocator": "highwater", diff --git a/api/src/__test__/setup.ts b/api/src/__test__/setup.ts new file mode 100644 index 0000000000..00c9fbe05e --- /dev/null +++ b/api/src/__test__/setup.ts @@ -0,0 +1,8 @@ +import '@app/__test__/setup/env-setup.js'; +// import './setup/mock-fs-setup'; +import '@app/__test__/setup/keyserver-mock.js'; +import '@app/__test__/setup/config-setup.js'; +import '@app/__test__/setup/store-reset.js'; + +// This file is automatically loaded by Vitest before running tests +// It imports all the setup files that need to be run before tests diff --git a/api/src/__test__/setup/mock-fs-setup.ts b/api/src/__test__/setup/mock-fs-setup.ts new file mode 100644 index 0000000000..308a2cd88d --- /dev/null +++ b/api/src/__test__/setup/mock-fs-setup.ts @@ -0,0 +1,45 @@ +import { beforeEach, vi } from 'vitest'; + +// Create a global mock file system that can be used across all tests +export const mockFileSystem = new Map(); + +// Mock fs/promises +vi.mock('node:fs/promises', () => ({ + writeFile: vi.fn().mockImplementation((path, content) => { + mockFileSystem.set(path.toString(), content.toString()); + return Promise.resolve(); + }), + readFile: vi.fn().mockImplementation((path) => { + const content = mockFileSystem.get(path.toString()); + if (content === undefined) { + return Promise.reject(new Error(`File not found: ${path}`)); + } + return Promise.resolve(content); + }), + access: vi.fn().mockImplementation((path) => { + if (mockFileSystem.has(path.toString())) { + return Promise.resolve(); + } + return Promise.reject(new Error(`File not found: ${path}`)); + }), +})); + +// Mock fs-extra +vi.mock('fs-extra', () => ({ + emptyDir: vi.fn().mockImplementation(() => { + mockFileSystem.clear(); + return Promise.resolve(); + }), +})); + +// Mock file-exists utility +vi.mock('@app/core/utils/files/file-exists.js', () => ({ + fileExists: vi.fn().mockImplementation((path) => { + return Promise.resolve(mockFileSystem.has(path.toString())); + }), +})); + +// Clear the mock file system before each test +beforeEach(() => { + mockFileSystem.clear(); +}); diff --git a/api/src/__test__/setup/store-reset.ts b/api/src/__test__/setup/store-reset.ts new file mode 100644 index 0000000000..dd230b9240 --- /dev/null +++ b/api/src/__test__/setup/store-reset.ts @@ -0,0 +1,8 @@ +import { beforeEach } from 'vitest'; + +import { resetStore } from '@app/store/actions/reset-store.js'; +import { store } from '@app/store/index.js'; + +beforeEach(() => { + store.dispatch(resetStore()); +}); diff --git a/api/src/__test__/store/modules/config.test.ts b/api/src/__test__/store/modules/config.test.ts index f185022d46..0bae3d7b96 100644 --- a/api/src/__test__/store/modules/config.test.ts +++ b/api/src/__test__/store/modules/config.test.ts @@ -1,4 +1,4 @@ -import { expect, test, vi } from 'vitest'; +import { beforeEach, expect, test, vi } from 'vitest'; import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; import { MinigraphStatus, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE } from '@app/graphql/generated/api/types.js'; @@ -10,15 +10,36 @@ import { store } from '@app/store/index.js'; import { MyServersConfigMemory } from '@app/types/my-servers-config.js'; // Mock dependencies -vi.mock('@app/core/pubsub.js', () => ({ - pubsub: { - publish: vi.fn(), - }, - PUBSUB_CHANNEL: { - OWNER: 'OWNER', - SERVERS: 'SERVERS', - }, -})); +vi.mock('@app/core/pubsub.js', () => { + const mockPublish = vi.fn(); + return { + pubsub: { + publish: mockPublish, + }, + PUBSUB_CHANNEL: { + OWNER: 'OWNER', + SERVERS: 'SERVERS', + }, + __esModule: true, + default: { + pubsub: { + publish: mockPublish, + }, + PUBSUB_CHANNEL: { + OWNER: 'OWNER', + SERVERS: 'SERVERS', + }, + }, + }; +}); + +// Get the mock function for pubsub.publish +const mockPublish = vi.mocked(pubsub.publish); + +// Clear mock before each test +beforeEach(() => { + mockPublish.mockClear(); +}); vi.mock('@app/mothership/graphql-client.js', () => ({ GraphQLClient: { diff --git a/api/src/__test__/store/modules/registration.test.ts b/api/src/__test__/store/modules/registration.test.ts index cb33588362..b98658b731 100644 --- a/api/src/__test__/store/modules/registration.test.ts +++ b/api/src/__test__/store/modules/registration.test.ts @@ -1,13 +1,13 @@ import { expect, test } from 'vitest'; -import { store } from '@app/store/index.js'; +import { getters, store } from '@app/store/index.js'; import { loadRegistrationKey } from '@app/store/modules/registration.js'; import { FileLoadStatus, StateFileKey } from '@app/store/types.js'; // Preloading imports for faster tests test('Before loading key returns null', async () => { - const { status, keyFile } = store.getState().registration; + const { status, keyFile } = getters.registration(); expect(status).toBe(FileLoadStatus.UNLOADED); expect(keyFile).toBe(null); }); @@ -17,7 +17,7 @@ test('Requires emhttp to be loaded to find key file', async () => { await store.dispatch(loadRegistrationKey()); // Check if store has state files loaded - const { status, keyFile } = store.getState().registration; + const { status, keyFile } = getters.registration(); expect(status).toBe(FileLoadStatus.LOADED); expect(keyFile).toBe(null); @@ -42,7 +42,7 @@ test('Returns empty key if key location is empty', async () => { await store.dispatch(loadRegistrationKey()); // Check if store has state files loaded - const { status, keyFile } = store.getState().registration; + const { status, keyFile } = getters.registration(); expect(status).toBe(FileLoadStatus.LOADED); expect(keyFile).toBe(''); }); diff --git a/api/src/common/allowed-origins.ts b/api/src/common/allowed-origins.ts index 16049d2b08..88263aa70f 100644 --- a/api/src/common/allowed-origins.ts +++ b/api/src/common/allowed-origins.ts @@ -20,6 +20,7 @@ const getAllowedSocks = (): string[] => [ const getLocalAccessUrlsForServer = (state: RootState = store.getState()): string[] => { const { emhttp } = state; + if (emhttp.status !== FileLoadStatus.LOADED) { return []; } @@ -90,7 +91,7 @@ const getApolloSandbox = (): string[] => { export const getAllowedOrigins = (state: RootState = store.getState()): string[] => uniq([ ...getAllowedSocks(), - ...getLocalAccessUrlsForServer(), + ...getLocalAccessUrlsForServer(state), ...getRemoteAccessUrlsForAllowedOrigins(state), ...getExtraOrigins(), ...getConnectOrigins(), diff --git a/api/src/store/actions/reset-store.ts b/api/src/store/actions/reset-store.ts new file mode 100644 index 0000000000..cc9f388f95 --- /dev/null +++ b/api/src/store/actions/reset-store.ts @@ -0,0 +1,7 @@ +import { createAction } from '@reduxjs/toolkit'; + +/** + * Action to reset the store to its initial state. + * This will be handled by the root reducer to reset all slices. + */ +export const resetStore = createAction('store/reset'); diff --git a/api/src/store/index.ts b/api/src/store/index.ts index d8f08f0206..8c9abf5936 100644 --- a/api/src/store/index.ts +++ b/api/src/store/index.ts @@ -1,30 +1,10 @@ import { configureStore } from '@reduxjs/toolkit'; import { listenerMiddleware } from '@app/store/listeners/listener-middleware.js'; -import { cache } from '@app/store/modules/cache.js'; -import { configReducer } from '@app/store/modules/config.js'; -import { dynamicRemoteAccessReducer } from '@app/store/modules/dynamic-remote-access.js'; -import { dynamix } from '@app/store/modules/dynamix.js'; -import { emhttp } from '@app/store/modules/emhttp.js'; -import { mothership } from '@app/store/modules/minigraph.js'; -import { paths } from '@app/store/modules/paths.js'; -import { registration } from '@app/store/modules/registration.js'; -import { remoteGraphQLReducer } from '@app/store/modules/remote-graphql.js'; -import { upnp } from '@app/store/modules/upnp.js'; +import { rootReducer } from '@app/store/root-reducer.js'; export const store = configureStore({ - reducer: { - config: configReducer, - dynamicRemoteAccess: dynamicRemoteAccessReducer, - minigraph: mothership.reducer, - paths: paths.reducer, - emhttp: emhttp.reducer, - registration: registration.reducer, - remoteGraphQL: remoteGraphQLReducer, - cache: cache.reducer, - upnp: upnp.reducer, - dynamix: dynamix.reducer, - }, + reducer: rootReducer, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: false, diff --git a/api/src/store/modules/config.ts b/api/src/store/modules/config.ts index 50023c8aca..41a8f32867 100644 --- a/api/src/store/modules/config.ts +++ b/api/src/store/modules/config.ts @@ -67,6 +67,7 @@ export const loginUser = createAsyncThunk< { state: RootState } >('config/login-user', async (userInfo) => { logger.info('Logging in user: %s', userInfo.username); + const { pubsub, PUBSUB_CHANNEL } = await import('@app/core/pubsub.js'); const owner: Owner = { username: userInfo.username, avatar: userInfo.avatar, @@ -79,7 +80,9 @@ export const logoutUser = createAsyncThunk { logger.info('Logging out user: %s', reason ?? 'No reason provided'); - const { pubsub } = await import('@app/core/pubsub.js'); + const { pubsub, PUBSUB_CHANNEL } = await import('@app/core/pubsub.js'); + const { stopPingTimeoutJobs } = await import('@app/mothership/jobs/ping-timeout-jobs.js'); + const { GraphQLClient } = await import('@app/mothership/graphql-client.js'); // Publish to servers endpoint await pubsub.publish(PUBSUB_CHANNEL.SERVERS, { diff --git a/api/src/store/modules/registration.ts b/api/src/store/modules/registration.ts index dff1d68fbd..85f4e3612d 100644 --- a/api/src/store/modules/registration.ts +++ b/api/src/store/modules/registration.ts @@ -65,3 +65,5 @@ export const registration = createSlice({ }); }, }); + +export const registrationReducer = registration.reducer; diff --git a/api/src/store/root-reducer.ts b/api/src/store/root-reducer.ts new file mode 100644 index 0000000000..47c381845d --- /dev/null +++ b/api/src/store/root-reducer.ts @@ -0,0 +1,40 @@ +import { combineReducers } from '@reduxjs/toolkit'; + +import { resetStore } from '@app/store/actions/reset-store.js'; +import { cache } from '@app/store/modules/cache.js'; +import { configReducer } from '@app/store/modules/config.js'; +import { dynamicRemoteAccessReducer } from '@app/store/modules/dynamic-remote-access.js'; +import { dynamix } from '@app/store/modules/dynamix.js'; +import { emhttp } from '@app/store/modules/emhttp.js'; +import { mothership } from '@app/store/modules/minigraph.js'; +import { paths } from '@app/store/modules/paths.js'; +import { registrationReducer } from '@app/store/modules/registration.js'; +import { remoteGraphQLReducer } from '@app/store/modules/remote-graphql.js'; +import { upnp } from '@app/store/modules/upnp.js'; + +/** + * Root reducer that combines all slice reducers and handles the reset action. + * When the reset action is dispatched, all slices will be reset to their initial state. + */ +const appReducer = combineReducers({ + config: configReducer, + dynamicRemoteAccess: dynamicRemoteAccessReducer, + minigraph: mothership.reducer, + paths: paths.reducer, + emhttp: emhttp.reducer, + registration: registrationReducer, + remoteGraphQL: remoteGraphQLReducer, + cache: cache.reducer, + upnp: upnp.reducer, + dynamix: dynamix.reducer, +}); + +export const rootReducer = (state: any, action: any) => { + // When the reset action is dispatched, return undefined to reset all reducers + if (action.type === resetStore.type) { + return appReducer(undefined, action); + } + + // Otherwise, use the combined reducer + return appReducer(state, action); +}; diff --git a/api/src/unraid-api/auth/cookie.service.spec.ts b/api/src/unraid-api/auth/cookie.service.spec.ts index 40f00775d0..6f83df4f6d 100644 --- a/api/src/unraid-api/auth/cookie.service.spec.ts +++ b/api/src/unraid-api/auth/cookie.service.spec.ts @@ -3,10 +3,49 @@ import { Test } from '@nestjs/testing'; import { writeFile } from 'node:fs/promises'; import { emptyDir } from 'fs-extra'; -import { afterAll, beforeAll, describe, it } from 'vitest'; +import { afterAll, beforeAll, describe, it, vi } from 'vitest'; import { CookieService, SESSION_COOKIE_CONFIG } from '@app/unraid-api/auth/cookie.service.js'; +// Mock file system +const mockFileSystem = new Map(); + +// Mock fs/promises +vi.mock('node:fs/promises', () => ({ + writeFile: vi.fn().mockImplementation((path, content) => { + mockFileSystem.set(path.toString(), content.toString()); + return Promise.resolve(); + }), + readFile: vi.fn().mockImplementation((path) => { + const content = mockFileSystem.get(path.toString()); + if (content === undefined) { + return Promise.reject(new Error(`File not found: ${path}`)); + } + return Promise.resolve(content); + }), + access: vi.fn().mockImplementation((path) => { + if (mockFileSystem.has(path.toString())) { + return Promise.resolve(); + } + return Promise.reject(new Error(`File not found: ${path}`)); + }), +})); + +// Mock fs-extra +vi.mock('fs-extra', () => ({ + emptyDir: vi.fn().mockImplementation(() => { + mockFileSystem.clear(); + return Promise.resolve(); + }), +})); + +// Mock file-exists utility +vi.mock('@app/core/utils/files/file-exists.js', () => ({ + fileExists: vi.fn().mockImplementation((path) => { + return Promise.resolve(mockFileSystem.has(path.toString())); + }), +})); + describe.concurrent('CookieService', () => { let service: CookieService; const sessionDir = '/tmp/php/sessions'; diff --git a/api/vite.config.ts b/api/vite.config.ts index 9b7beb3791..756652cc58 100644 --- a/api/vite.config.ts +++ b/api/vite.config.ts @@ -143,7 +143,7 @@ export default defineConfig(({ mode }): ViteUserConfig => { }, }, test: { - isolate: false, + isolate: true, poolOptions: { threads: { useAtomics: true, @@ -156,20 +156,16 @@ export default defineConfig(({ mode }): ViteUserConfig => { }, }, maxConcurrency: 10, - globals: true, environment: 'node', coverage: { all: true, include: ['src/**/*'], reporter: ['text', 'json', 'html'], }, - clearMocks: true, setupFiles: [ 'dotenv/config', 'reflect-metadata', - 'src/__test__/setup/env-setup.ts', - 'src/__test__/setup/keyserver-mock.ts', - 'src/__test__/setup/config-setup.ts', + 'src/__test__/setup.ts', ], exclude: ['**/deploy/**', '**/node_modules/**'], }, From f6cd461384a83872f97c1e58e2e0459cdd075b57 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 7 Apr 2025 13:59:05 -0400 Subject: [PATCH 4/9] chore: remove unused imports --- api/src/store/modules/config.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/api/src/store/modules/config.ts b/api/src/store/modules/config.ts index 41a8f32867..741918eabd 100644 --- a/api/src/store/modules/config.ts +++ b/api/src/store/modules/config.ts @@ -8,14 +8,11 @@ import { isEqual, merge } from 'lodash-es'; import type { Owner } from '@app/graphql/generated/api/types.js'; import { logger } from '@app/core/log.js'; -import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; import { getWriteableConfig } from '@app/core/utils/files/config-file-normalizer.js'; import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer.js'; import { parseConfig } from '@app/core/utils/misc/parse-config.js'; import { NODE_ENV } from '@app/environment.js'; import { DynamicRemoteAccessType, MinigraphStatus } from '@app/graphql/generated/api/types.js'; -import { GraphQLClient } from '@app/mothership/graphql-client.js'; -import { stopPingTimeoutJobs } from '@app/mothership/jobs/ping-timeout-jobs.js'; import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status.js'; import { setupRemoteAccessThunk } from '@app/store/actions/setup-remote-access.js'; import { type RootState } from '@app/store/index.js'; From 851908fbda5ce94357626809799513d1b902e4cf Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 7 Apr 2025 14:28:48 -0400 Subject: [PATCH 5/9] chore: fix type issue --- api/src/store/modules/minigraph.ts | 2 ++ api/src/store/root-reducer.ts | 11 +++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/api/src/store/modules/minigraph.ts b/api/src/store/modules/minigraph.ts index 222f9e25de..30209023e8 100644 --- a/api/src/store/modules/minigraph.ts +++ b/api/src/store/modules/minigraph.ts @@ -87,3 +87,5 @@ export const mothership = createSlice({ export const { setMothershipTimeout, receivedMothershipPing, setSelfDisconnected, setSelfReconnected } = mothership.actions; + +export const mothershipReducer = mothership.reducer; diff --git a/api/src/store/root-reducer.ts b/api/src/store/root-reducer.ts index 47c381845d..bc8a523e32 100644 --- a/api/src/store/root-reducer.ts +++ b/api/src/store/root-reducer.ts @@ -1,4 +1,4 @@ -import { combineReducers } from '@reduxjs/toolkit'; +import { combineReducers, UnknownAction } from '@reduxjs/toolkit'; import { resetStore } from '@app/store/actions/reset-store.js'; import { cache } from '@app/store/modules/cache.js'; @@ -6,7 +6,7 @@ import { configReducer } from '@app/store/modules/config.js'; import { dynamicRemoteAccessReducer } from '@app/store/modules/dynamic-remote-access.js'; import { dynamix } from '@app/store/modules/dynamix.js'; import { emhttp } from '@app/store/modules/emhttp.js'; -import { mothership } from '@app/store/modules/minigraph.js'; +import { mothershipReducer } from '@app/store/modules/minigraph.js'; import { paths } from '@app/store/modules/paths.js'; import { registrationReducer } from '@app/store/modules/registration.js'; import { remoteGraphQLReducer } from '@app/store/modules/remote-graphql.js'; @@ -19,7 +19,7 @@ import { upnp } from '@app/store/modules/upnp.js'; const appReducer = combineReducers({ config: configReducer, dynamicRemoteAccess: dynamicRemoteAccessReducer, - minigraph: mothership.reducer, + minigraph: mothershipReducer, paths: paths.reducer, emhttp: emhttp.reducer, registration: registrationReducer, @@ -29,7 +29,10 @@ const appReducer = combineReducers({ dynamix: dynamix.reducer, }); -export const rootReducer = (state: any, action: any) => { +// Define the return type of the combined reducer +type AppState = ReturnType; + +export const rootReducer = (state: AppState | undefined, action: UnknownAction): AppState => { // When the reset action is dispatched, return undefined to reset all reducers if (action.type === resetStore.type) { return appReducer(undefined, action); From 400c38addca92d708bc18eecf9339889380fa643 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 7 Apr 2025 16:24:31 -0400 Subject: [PATCH 6/9] chore: fix unit tests --- api/dev/states/myservers.cfg | 4 +- api/generated-schema.graphql | 11 +- api/src/graphql/generated/api/operations.ts | 12 +- api/src/graphql/generated/api/types.ts | 18 +- .../graphql/schema/types/disks/disk.graphql | 2 +- .../resolvers/disks/disks.resolver.spec.ts | 39 +++- .../graph/resolvers/disks/disks.resolver.ts | 16 +- .../resolvers/disks/disks.service.spec.ts | 182 +++++------------- .../graph/resolvers/disks/disks.service.ts | 49 ++--- .../downloaded/.login.php.last-download-time | 2 +- .../downloaded/DefaultPageLayout.php | 77 ++++---- .../DefaultPageLayout.php.last-download-time | 2 +- .../Notifications.page.last-download-time | 2 +- .../auth-request.php.last-download-time | 2 +- ...efaultPageLayout.php.modified.snapshot.php | 77 ++++---- .../patches/default-page-layout.patch | 8 +- 16 files changed, 215 insertions(+), 288 deletions(-) diff --git a/api/dev/states/myservers.cfg b/api/dev/states/myservers.cfg index 7da25ee640..42ccb6be12 100644 --- a/api/dev/states/myservers.cfg +++ b/api/dev/states/myservers.cfg @@ -18,7 +18,7 @@ idtoken="" refreshtoken="" dynamicRemoteAccessType="DISABLED" ssoSubIds="" -allowedOrigins="/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, http://localhost:8080, https://localhost:4443, https://tower.local:4443, https://192.168.1.150:4443, https://tower:4443, https://192-168-1-150.thisisfourtyrandomcharacters012345678900.myunraid.net:4443, https://10-252-0-1.hash.myunraid.net:4443, https://10-252-1-1.hash.myunraid.net:4443, https://10-253-3-1.hash.myunraid.net:4443, https://10-253-4-1.hash.myunraid.net:4443, https://10-253-5-1.hash.myunraid.net:4443, https://10-100-0-1.hash.myunraid.net:4443, https://10-100-0-2.hash.myunraid.net:4443, https://10-123-1-2.hash.myunraid.net:4443, https://221-123-121-112.hash.myunraid.net:4443, https://google.com, https://test.com, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000, https://studio.apollographql.com" +allowedOrigins="/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, http://localhost:8080, https://localhost:4443, https://tower.local:4443, https://192.168.1.150:4443, https://tower:4443, https://192-168-1-150.thisisfourtyrandomcharacters012345678900.myunraid.net:4443, https://85-121-123-122.thisisfourtyrandomcharacters012345678900.myunraid.net:8443, https://10-252-0-1.hash.myunraid.net:4443, https://10-252-1-1.hash.myunraid.net:4443, https://10-253-3-1.hash.myunraid.net:4443, https://10-253-4-1.hash.myunraid.net:4443, https://10-253-5-1.hash.myunraid.net:4443, https://10-100-0-1.hash.myunraid.net:4443, https://10-100-0-2.hash.myunraid.net:4443, https://10-123-1-2.hash.myunraid.net:4443, https://221-123-121-112.hash.myunraid.net:4443, https://google.com, https://test.com, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000, https://studio.apollographql.com" [connectionStatus] minigraph="ERROR_RETRYING" -upnpStatus="Success: UPNP Lease Renewed [4/2/2025 12:00:00 PM] Public Port [41820] Local Port [443]" +upnpStatus="" diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 51187eaac5..be62618b95 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -530,7 +530,7 @@ type Disk { serialNum: String! size: Long! smartStatus: DiskSmartStatus! - temperature: Long! + temperature: Long totalCylinders: Long! totalHeads: Long! totalSectors: Long! @@ -595,7 +595,6 @@ type Display { type Docker implements Node { containers: [DockerContainer!] id: ID! - mutations: DockerMutations! networks: [DockerNetwork!] } @@ -620,8 +619,11 @@ type DockerContainer { } type DockerMutations { - startContainer(id: ID!): DockerContainer! - stopContainer(id: ID!): DockerContainer! + """ Start a container """ + start(id: ID!): DockerContainer! + + """ Stop a container """ + stop(id: ID!): DockerContainer! } type DockerNetwork { @@ -861,6 +863,7 @@ type Mutation { """Delete a user""" deleteUser(input: deleteUserInput!): User + docker: DockerMutations enableDynamicRemoteAccess(input: EnableDynamicRemoteAccessInput!): Boolean! login(password: String!, username: String!): String diff --git a/api/src/graphql/generated/api/operations.ts b/api/src/graphql/generated/api/operations.ts index fe5c4cb6dd..b06ddec41d 100755 --- a/api/src/graphql/generated/api/operations.ts +++ b/api/src/graphql/generated/api/operations.ts @@ -2,7 +2,7 @@ import * as Types from '@app/graphql/generated/api/types.js'; import { z } from 'zod' -import { AccessUrl, AccessUrlInput, AddPermissionInput, AddRoleForApiKeyInput, AddRoleForUserInput, AllowedOriginInput, ApiKey, ApiKeyResponse, ApiKeyWithSecret, ApiSettingsInput, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskInput, ArrayDiskStatus, ArrayDiskType, ArrayMutations, ArrayMutationsaddDiskToArrayArgs, ArrayMutationsclearArrayDiskStatisticsArgs, ArrayMutationsmountArrayDiskArgs, ArrayMutationsremoveDiskFromArrayArgs, ArrayMutationssetStateArgs, ArrayMutationsunmountArrayDiskArgs, ArrayPendingState, ArrayState, ArrayStateInput, ArrayStateInputState, AuthActionVerb, AuthPossession, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, ConnectSettings, ConnectSettingsValues, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, CreateApiKeyInput, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, Docker, DockerContainer, DockerMutations, DockerMutationsstartContainerArgs, DockerMutationsstopContainerArgs, DockerNetwork, DynamicRemoteAccessStatus, DynamicRemoteAccessType, EnableDynamicRemoteAccessInput, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, LogFile, LogFileContent, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Node, Notification, NotificationCounts, NotificationData, NotificationFilter, NotificationOverview, NotificationType, Notifications, NotificationslistArgs, Os, Owner, ParityCheck, Partition, Pci, Permission, ProfileModel, Registration, RegistrationState, RelayResponse, RemoteAccess, RemoveRoleFromApiKeyInput, Resource, Role, Server, ServerStatus, Service, SetupRemoteAccessInput, Share, System, Temperature, Theme, URL_TYPE, UnassignedDevice, Uptime, Usb, User, UserAccount, Vars, Versions, VmDomain, VmMutations, VmMutationsforceStopVmArgs, VmMutationspauseVmArgs, VmMutationsrebootVmArgs, VmMutationsresetVmArgs, VmMutationsresumeVmArgs, VmMutationsstartVmArgs, VmMutationsstopVmArgs, VmState, Vms, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, Welcome, addUserInput, deleteUserInput, mdState, registrationType, usersInput } from '@app/graphql/generated/api/types.js' +import { AccessUrl, AccessUrlInput, AddPermissionInput, AddRoleForApiKeyInput, AddRoleForUserInput, AllowedOriginInput, ApiKey, ApiKeyResponse, ApiKeyWithSecret, ApiSettingsInput, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskInput, ArrayDiskStatus, ArrayDiskType, ArrayMutations, ArrayMutationsaddDiskToArrayArgs, ArrayMutationsclearArrayDiskStatisticsArgs, ArrayMutationsmountArrayDiskArgs, ArrayMutationsremoveDiskFromArrayArgs, ArrayMutationssetStateArgs, ArrayMutationsunmountArrayDiskArgs, ArrayPendingState, ArrayState, ArrayStateInput, ArrayStateInputState, AuthActionVerb, AuthPossession, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, ConnectSettings, ConnectSettingsValues, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, CreateApiKeyInput, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, Docker, DockerContainer, DockerMutations, DockerMutationsstartArgs, DockerMutationsstopArgs, DockerNetwork, DynamicRemoteAccessStatus, DynamicRemoteAccessType, EnableDynamicRemoteAccessInput, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, LogFile, LogFileContent, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Node, Notification, NotificationCounts, NotificationData, NotificationFilter, NotificationOverview, NotificationType, Notifications, NotificationslistArgs, Os, Owner, ParityCheck, Partition, Pci, Permission, ProfileModel, Registration, RegistrationState, RelayResponse, RemoteAccess, RemoveRoleFromApiKeyInput, Resource, Role, Server, ServerStatus, Service, SetupRemoteAccessInput, Share, System, Temperature, Theme, URL_TYPE, UnassignedDevice, Uptime, Usb, User, UserAccount, Vars, Versions, VmDomain, VmMutations, VmMutationsforceStopVmArgs, VmMutationspauseVmArgs, VmMutationsrebootVmArgs, VmMutationsresetVmArgs, VmMutationsresumeVmArgs, VmMutationsstartVmArgs, VmMutationsstopVmArgs, VmState, Vms, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, Welcome, addUserInput, deleteUserInput, mdState, registrationType, usersInput } from '@app/graphql/generated/api/types.js' import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; type Properties = Required<{ @@ -456,7 +456,7 @@ export function DiskSchema(): z.ZodObject> { serialNum: z.string(), size: z.number(), smartStatus: DiskSmartStatusSchema, - temperature: z.number(), + temperature: z.number().nullish(), totalCylinders: z.number(), totalHeads: z.number(), totalSectors: z.number(), @@ -536,18 +536,18 @@ export function DockerContainerSchema(): z.ZodObject export function DockerMutationsSchema(): z.ZodObject> { return z.object({ __typename: z.literal('DockerMutations').optional(), - startContainer: DockerContainerSchema(), - stopContainer: DockerContainerSchema() + start: DockerContainerSchema(), + stop: DockerContainerSchema() }) } -export function DockerMutationsstartContainerArgsSchema(): z.ZodObject> { +export function DockerMutationsstartArgsSchema(): z.ZodObject> { return z.object({ id: z.string() }) } -export function DockerMutationsstopContainerArgsSchema(): z.ZodObject> { +export function DockerMutationsstopArgsSchema(): z.ZodObject> { return z.object({ id: z.string() }) diff --git a/api/src/graphql/generated/api/types.ts b/api/src/graphql/generated/api/types.ts index 9ebabaaafd..46f5068003 100644 --- a/api/src/graphql/generated/api/types.ts +++ b/api/src/graphql/generated/api/types.ts @@ -519,7 +519,7 @@ export type Disk = { serialNum: Scalars['String']['output']; size: Scalars['Long']['output']; smartStatus: DiskSmartStatus; - temperature: Scalars['Long']['output']; + temperature?: Maybe; totalCylinders: Scalars['Long']['output']; totalHeads: Scalars['Long']['output']; totalSectors: Scalars['Long']['output']; @@ -612,17 +612,19 @@ export type DockerContainer = { export type DockerMutations = { __typename?: 'DockerMutations'; - startContainer: DockerContainer; - stopContainer: DockerContainer; + /** Start a container */ + start: DockerContainer; + /** Stop a container */ + stop: DockerContainer; }; -export type DockerMutationsstartContainerArgs = { +export type DockerMutationsstartArgs = { id: Scalars['ID']['input']; }; -export type DockerMutationsstopContainerArgs = { +export type DockerMutationsstopArgs = { id: Scalars['ID']['input']; }; @@ -2512,7 +2514,7 @@ export type DiskResolvers; size?: Resolver; smartStatus?: Resolver; - temperature?: Resolver; + temperature?: Resolver, ParentType, ContextType>; totalCylinders?: Resolver; totalHeads?: Resolver; totalSectors?: Resolver; @@ -2582,8 +2584,8 @@ export type DockerContainerResolvers; export type DockerMutationsResolvers = ResolversObject<{ - startContainer?: Resolver>; - stopContainer?: Resolver>; + start?: Resolver>; + stop?: Resolver>; __isTypeOf?: IsTypeOfResolverFn; }>; diff --git a/api/src/graphql/schema/types/disks/disk.graphql b/api/src/graphql/schema/types/disks/disk.graphql index acb078873d..a721755145 100644 --- a/api/src/graphql/schema/types/disks/disk.graphql +++ b/api/src/graphql/schema/types/disks/disk.graphql @@ -36,7 +36,7 @@ type Disk { serialNum: String! interfaceType: DiskInterfaceType! smartStatus: DiskSmartStatus! - temperature: Long! + temperature: Long partitions: [DiskPartition!] } diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/disks/disks.resolver.spec.ts index 70ba979f62..dc8fb01f7b 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.resolver.spec.ts @@ -11,6 +11,7 @@ import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.servic // Mock the DisksService const mockDisksService = { getDisks: vi.fn(), + getTemperature: vi.fn(), }; describe('DisksResolver', () => { @@ -70,7 +71,7 @@ describe('DisksResolver', () => { expect(result).toEqual(mockResult); expect(service.getDisks).toHaveBeenCalledTimes(1); - expect(service.getDisks).toHaveBeenCalledWith({ temperature: true }); + expect(service.getDisks).toHaveBeenCalledWith(); }); it('should call the service', async () => { @@ -79,7 +80,41 @@ describe('DisksResolver', () => { await resolver.disks(); expect(service.getDisks).toHaveBeenCalledTimes(1); - expect(service.getDisks).toHaveBeenCalledWith({ temperature: true }); + expect(service.getDisks).toHaveBeenCalledWith(); + }); + }); + + describe('temperature', () => { + it('should call getTemperature with the disk device', async () => { + const mockDisk: Disk = { + id: 'SERIAL123', + device: '/dev/sda', + type: 'SSD', + name: 'Samsung SSD 860 EVO 1TB', + vendor: 'Samsung', + size: 1000204886016, + bytesPerSector: 512, + totalCylinders: 121601, + totalHeads: 255, + totalSectors: 1953525168, + totalTracks: 31008255, + tracksPerCylinder: 255, + sectorsPerTrack: 63, + firmwareRevision: 'RVT04B6Q', + serialNum: 'SERIAL123', + interfaceType: DiskInterfaceType.SATA, + smartStatus: DiskSmartStatus.OK, + temperature: -1, + partitions: [], + }; + + mockDisksService.getTemperature.mockResolvedValue(42); + + const result = await resolver.temperature(mockDisk); + + expect(result).toBe(42); + expect(service.getTemperature).toHaveBeenCalledTimes(1); + expect(service.getTemperature).toHaveBeenCalledWith('/dev/sda'); }); }); }); diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.resolver.ts b/api/src/unraid-api/graph/resolvers/disks/disks.resolver.ts index f387ff6d59..4ce069fb48 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.resolver.ts @@ -1,22 +1,26 @@ -import { Query, Resolver } from '@nestjs/graphql'; +import { Parent, Query, ResolveField, Resolver } from '@nestjs/graphql'; import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz'; -import { Resource } from '@app/graphql/generated/api/types.js'; +import { Disk, Resource } from '@app/graphql/generated/api/types.js'; import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.service.js'; -@Resolver('Disks') +@Resolver('Disk') export class DisksResolver { constructor(private readonly disksService: DisksService) {} - @Query() + @Query('disks') @UsePermissions({ action: AuthActionVerb.READ, resource: Resource.DISK, possession: AuthPossession.ANY, }) public async disks() { - const disks = await this.disksService.getDisks({ temperature: true }); - return disks; + return this.disksService.getDisks(); + } + + @ResolveField('temperature') + public async temperature(@Parent() disk: Disk) { + return this.disksService.getTemperature(disk.device); } } 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 1871961c2e..7efbe605eb 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 @@ -203,13 +203,13 @@ describe('DisksService', () => { // --- Test getDisks --- describe('getDisks', () => { - it('should return disks without temperature when options.temperature is false', async () => { - const disks = await service.getDisks({ temperature: false }); + it('should return disks without temperature', 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).not.toHaveBeenCalled(); // Should not use batchProcess if temp is false + expect(mockBatchProcess).toHaveBeenCalledTimes(1); // Still uses batchProcess for parsing expect(disks).toHaveLength(mockDiskLayoutData.length); expect(disks[0]).toMatchObject({ @@ -221,7 +221,7 @@ describe('DisksService', () => { size: 512110190592, interfaceType: DiskInterfaceType.PCIE, smartStatus: DiskSmartStatus.OK, - temperature: -1, // Expect default -1 when not fetched + temperature: null, // Temperature is now null by default partitions: [ { name: 'sda1', fsType: DiskFsType.VFAT, size: 536870912 }, { name: 'sda2', fsType: DiskFsType.EXT4, size: 511560000000 }, @@ -232,7 +232,7 @@ describe('DisksService', () => { device: '/dev/sdb', interfaceType: DiskInterfaceType.SATA, smartStatus: DiskSmartStatus.OK, - temperature: -1, + temperature: null, partitions: [{ name: 'sdb1', fsType: DiskFsType.XFS, size: 4000787030016 }], }); expect(disks[2]).toMatchObject({ @@ -240,99 +240,46 @@ describe('DisksService', () => { device: '/dev/sdc', interfaceType: DiskInterfaceType.UNKNOWN, smartStatus: DiskSmartStatus.UNKNOWN, - temperature: -1, + temperature: null, partitions: [{ name: 'sdc1', fsType: DiskFsType.NTFS, size: 1000204886016 }], }); }); - it('should return disks with temperature when options.temperature is true or omitted', async () => { - // Mock smartctl output for each disk - mockExeca - .mockResolvedValueOnce({ - // sda - NVMe often doesn't report via smartctl easily, simulate failure - stdout: '', - stderr: 'smartctl open device: /dev/sda failed: Unknown NVMe device', - exitCode: 1, - failed: true, - command: '', - cwd: '', - isCanceled: false, - }) - .mockResolvedValueOnce({ - // sdb - Standard Temp - stdout: `smartctl 7.2 2020-12-30 r5155 [x86_64-linux-5.10.0-8-amd64] (local build) -Copyright (C) 2002-20, Bruce Allen, Christian Franke, www.smartmontools.org - -=== START OF READ SMART DATA SECTION === -SMART Attributes Data Structure revision number: 16 -Vendor Specific SMART Attributes with Thresholds: -ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE - 1 Raw_Read_Error_Rate 0x000f 119 099 006 Pre-fail Always - 197032872 - ... -194 Temperature_Celsius 0x0022 114 091 000 Old_age Always - 36 (Min/Max 19/58) -199 UDMA_CRC_Error_Count 0x003e 200 200 000 Old_age Always - 0 -`, - stderr: '', - exitCode: 0, - failed: false, - command: '', - cwd: '', - isCanceled: false, - }) - .mockResolvedValueOnce({ - // sdc - Airflow Temp + Min/Max format - stdout: `ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE -190 Airflow_Temperature_Cel 0x0022 065 058 045 Old_age Always - 35 (Min/Max 30/42 #123) -`, - stderr: '', - exitCode: 0, - failed: false, - command: '', - cwd: '', - isCanceled: false, - }); - - const disks = await service.getDisks(); // Omit options, should default to temperature: true - - expect(mockDiskLayout).toHaveBeenCalledTimes(1); - expect(mockBlockDevices).toHaveBeenCalledTimes(1); - // Ensure batchProcess was called correctly - expect(mockBatchProcess).toHaveBeenCalledTimes(1); - expect(mockBatchProcess).toHaveBeenCalledWith(mockDiskLayoutData, expect.any(Function)); - - // Check that execa was called for each disk inside the batch processor - expect(mockExeca).toHaveBeenCalledTimes(mockDiskLayoutData.length); - expect(mockExeca).toHaveBeenCalledWith('smartctl', ['-A', '/dev/sda']); - expect(mockExeca).toHaveBeenCalledWith('smartctl', ['-A', '/dev/sdb']); - expect(mockExeca).toHaveBeenCalledWith('smartctl', ['-A', '/dev/sdc']); + it('should handle empty disk layout or block devices', async () => { + mockDiskLayout.mockResolvedValue([]); + mockBlockDevices.mockResolvedValue([]); - expect(disks).toHaveLength(mockDiskLayoutData.length); - expect(disks[0].temperature).toBe(-1); // Failed smartctl call - expect(disks[1].temperature).toBe(36); // Standard temp line - expect(disks[2].temperature).toBe(35); // Airflow temp line with Min/Max + const disks = await service.getDisks(); + expect(disks).toEqual([]); + expect(mockBatchProcess).toHaveBeenCalledWith([], expect.any(Function)); - // Check other fields remain correct - expect(disks[1]).toMatchObject({ - id: 'WD-WCC7K7YL9876', - device: '/dev/sdb', - interfaceType: DiskInterfaceType.SATA, - smartStatus: DiskSmartStatus.OK, - partitions: [{ name: 'sdb1', fsType: DiskFsType.XFS, size: 4000787030016 }], - }); + mockDiskLayout.mockResolvedValue(mockDiskLayoutData); // Restore for next check + mockBlockDevices.mockResolvedValue([]); + const disks2 = await service.getDisks(); + expect(disks2).toHaveLength(mockDiskLayoutData.length); + expect(disks2[0].partitions).toEqual([]); + expect(disks2[1].partitions).toEqual([]); + expect(disks2[2].partitions).toEqual([]); }); + }); - it('should handle errors during temperature fetching gracefully', async () => { - mockExeca.mockRejectedValue(new Error('smartctl command failed')); - - const disks = await service.getDisks({ temperature: true }); // Explicitly true + // --- Test getTemperature --- + describe('getTemperature', () => { + it('should return temperature for a disk', async () => { + mockExeca.mockResolvedValue({ + stdout: `ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE +194 Temperature_Celsius 0x0022 114 091 000 Old_age Always - 42`, + stderr: '', + exitCode: 0, + failed: false, + command: '', + cwd: '', + isCanceled: false, + }); - expect(mockBatchProcess).toHaveBeenCalledTimes(1); - expect(mockExeca).toHaveBeenCalledTimes(mockDiskLayoutData.length); // Still attempts for all - expect(disks).toHaveLength(mockDiskLayoutData.length); - // All temperatures should be -1 due to errors - expect(disks[0].temperature).toBe(-1); - expect(disks[1].temperature).toBe(-1); - expect(disks[2].temperature).toBe(-1); + const temperature = await service.getTemperature('/dev/sda'); + expect(temperature).toBe(42); + expect(mockExeca).toHaveBeenCalledWith('smartctl', ['-A', '/dev/sda']); }); it('should handle case where smartctl output has no temperature field', async () => { @@ -346,12 +293,8 @@ ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_ isCanceled: false, }); // No temp line - const disks = await service.getDisks({ temperature: true }); - - expect(disks).toHaveLength(mockDiskLayoutData.length); - expect(disks[0].temperature).toBe(-1); - expect(disks[1].temperature).toBe(-1); - expect(disks[2].temperature).toBe(-1); + const temperature = await service.getTemperature('/dev/sda'); + expect(temperature).toBeNull(); }); it('should handle case where smartctl output has Temperature_Celsius with Min/Max format', async () => { @@ -366,43 +309,14 @@ ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_ isCanceled: false, }); - const disks = await service.getDisks({ temperature: true }); - - expect(disks[0].temperature).toBe(30); - expect(disks[1].temperature).toBe(30); - expect(disks[2].temperature).toBe(30); - }); - - it('should handle empty disk layout or block devices', async () => { - mockDiskLayout.mockResolvedValue([]); - mockBlockDevices.mockResolvedValue([]); - - const disks = await service.getDisks({ temperature: true }); - expect(disks).toEqual([]); - expect(mockBatchProcess).toHaveBeenCalledWith([], expect.any(Function)); - - mockDiskLayout.mockResolvedValue(mockDiskLayoutData); // Restore for next check - mockBlockDevices.mockResolvedValue([]); - const disks2 = await service.getDisks({ temperature: false }); // Temp false path - expect(disks2).toHaveLength(mockDiskLayoutData.length); - expect(disks2[0].partitions).toEqual([]); - expect(disks2[1].partitions).toEqual([]); - expect(disks2[2].partitions).toEqual([]); + const temperature = await service.getTemperature('/dev/sda'); + expect(temperature).toBe(30); }); - }); - // --- Test getTemperature (Indirectly via getDisks, but can add specific mocks here if needed) --- - // Most cases are covered by getDisks tests above. - // Add specific tests for edge cases in parsing if necessary. - describe('getTemperature parsing (indirectly tested)', () => { - // Example: Test specific parsing logic if needed, though covered above. - it('should correctly parse standard temperature line', async () => { - // Mock execa for a single disk scenario if testing private method logic - const singleDisk = mockDiskLayoutData[1]; // WDC disk - mockDiskLayout.mockResolvedValue([singleDisk]); + it('should handle case where smartctl output has Airflow_Temperature_Cel', async () => { mockExeca.mockResolvedValue({ stdout: `ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE -194 Temperature_Celsius 0x0022 114 091 000 Old_age Always - 42`, // Different temp +190 Airflow_Temperature_Cel 0x0022 065 058 045 Old_age Always - 35 (Min/Max 30/42 #123)`, stderr: '', exitCode: 0, failed: false, @@ -411,9 +325,15 @@ ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_ isCanceled: false, }); - const disks = await service.getDisks({ temperature: true }); - expect(disks).toHaveLength(1); - expect(disks[0].temperature).toBe(42); + const temperature = await service.getTemperature('/dev/sda'); + expect(temperature).toBe(35); + }); + + it('should handle errors during temperature fetching gracefully', async () => { + mockExeca.mockRejectedValue(new Error('smartctl command failed')); + + const temperature = await service.getTemperature('/dev/sda'); + expect(temperature).toBeNull(); }); }); }); 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 033844468d..4908840d4f 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.service.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.service.ts @@ -5,19 +5,14 @@ import { execa } from 'execa'; import { blockDevices, diskLayout } from 'systeminformation'; import type { Disk } from '@app/graphql/generated/api/types.js'; -import { graphqlLogger } from '@app/core/log.js'; import { DiskFsType, DiskInterfaceType, DiskSmartStatus } from '@app/graphql/generated/api/types.js'; -import { getters } from '@app/store/index.js'; import { batchProcess } from '@app/utils.js'; @Injectable() export class DisksService { - // Renamed from DiskService - private async getTemperature(disk: Systeminformation.DiskLayoutData): Promise { + public async getTemperature(device: string): Promise { try { - const stdout = await execa('smartctl', ['-A', disk.device]) - .then(({ stdout }) => stdout) - .catch(() => ''); + const { stdout } = await execa('smartctl', ['-A', device]); const lines = stdout.split('\n'); const header = lines.find((line) => line.startsWith('ID#')) ?? ''; const fields = lines.splice(lines.indexOf(header) + 1, lines.length); @@ -27,7 +22,7 @@ export class DisksService { ); if (!field) { - return -1; + return null; } if (field.includes('Min/Max')) { @@ -36,16 +31,14 @@ export class DisksService { const line = field.split(' '); return Number.parseInt(line[line.length - 1], 10); - } catch (error) { - graphqlLogger.warn('Caught error fetching disk temperature: %o', error); - return -1; + } catch (error: unknown) { + return null; } } private async parseDisk( disk: Systeminformation.DiskLayoutData, - partitionsToParse: Systeminformation.BlockDevicesData[], - temperature = false + partitionsToParse: Systeminformation.BlockDevicesData[] ): Promise { const partitions = partitionsToParse // Only get partitions from this disk @@ -113,43 +106,23 @@ export class DisksService { ...disk, id: disk.serialNum, // Ensure id is set smartStatus: - typeof disk.smartStatus === 'string' - ? (DiskSmartStatus[disk.smartStatus.toUpperCase() as keyof typeof DiskSmartStatus] ?? - DiskSmartStatus.UNKNOWN) - : DiskSmartStatus.UNKNOWN, // Default to UNKNOWN if undefined + DiskSmartStatus[disk.smartStatus?.toUpperCase() as keyof typeof DiskSmartStatus] ?? + DiskSmartStatus.UNKNOWN, interfaceType: mappedInterfaceType, - temperature: temperature ? await this.getTemperature(disk) : -1, - partitions, // Now correctly typed after filter + partitions, }; } /** * Get all disks. */ - async getDisks(options?: { temperature: boolean }): Promise { - const vars = getters.emhttp().var; - - // Return all fields but temperature - if (options?.temperature === false) { - const partitions = await blockDevices().then((devices) => - devices.filter((device) => device.type === 'part') - ); - const diskLayoutData = await diskLayout(); - // Pass unraidVar to parseDisk - const disks = await Promise.all( - diskLayoutData.map((disk) => this.parseDisk(disk, partitions)) - ); - - return disks; - } - + async getDisks(): Promise { const partitions = await blockDevices().then((devices) => devices.filter((device) => device.type === 'part') ); const { data } = await batchProcess(await diskLayout(), async (disk) => - // Pass unraidVar and temperature flag to parseDisk - this.parseDisk(disk, partitions, true) + this.parseDisk(disk, partitions) ); return data; } diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/.login.php.last-download-time b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/.login.php.last-download-time index f82e41be30..6f5a43c57f 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/.login.php.last-download-time +++ b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/.login.php.last-download-time @@ -1 +1 @@ -1743448824441 +1744055516073 \ No newline at end of file diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/DefaultPageLayout.php b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/DefaultPageLayout.php index 0348215f9f..e71f307c16 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/DefaultPageLayout.php +++ b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded/DefaultPageLayout.php @@ -17,6 +17,10 @@ $backgnd = $display['background']; $themes1 = in_array($theme,['black','white']); $themes2 = in_array($theme,['gray','azure']); +$themeHtmlClass = "Theme--$theme"; +if ($themes2) { + $themeHtmlClass .= " Theme--sidebar"; +} $config = "/boot/config"; $entity = $notify['entity'] & 1 == 1; $alerts = '/tmp/plugins/my_alerts.txt'; @@ -29,7 +33,7 @@ function annotate($text) {echo "\n\n";} ?> -lang=""> +lang="" class=""> <?=_var($var,'NAME')?>/<?=_var($myPage,'name')?> @@ -45,9 +49,13 @@ function annotate($text) {echo "\n\n";} ?> -lang=""> +lang="" class=""> <?=_var($var,'NAME')?>/<?=_var($myPage,'name')?> @@ -45,9 +49,13 @@ function annotate($text) {echo "\n