From 1b279bbab3a51e7d032e7e3c9898feac8bfdbafa Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 20 Jun 2025 17:22:54 -0400 Subject: [PATCH] feat: info resolver cleanup (#1425) ## Summary by CodeRabbit - **New Features** - Introduced device information endpoints, allowing users to query GPU, PCI, and USB device details. - Added new system information endpoints for apps, OS, CPU, display, versions, and memory, providing detailed environment insights. - Added a new configuration file for network and authentication settings. - **Bug Fixes** - Improved error handling for system and device information retrieval, ensuring graceful fallbacks when data is unavailable. - **Tests** - Added comprehensive tests for device and system information services and resolvers to ensure accurate data and robust error handling. - **Refactor** - Refactored system information and display logic into dedicated service classes for better maintainability and modularity. - Updated resolvers to delegate data fetching to injected services instead of standalone functions or inline logic. --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- api/dev/configs/connect.json | 16 + api/src/graphql/resolvers/query/info.ts | 393 ------------------ .../display/display.resolver.spec.ts | 73 +++- .../resolvers/display/display.resolver.ts | 97 +---- .../resolvers/display/display.service.spec.ts | 156 +++++++ .../resolvers/display/display.service.ts | 143 +++++++ .../resolvers/info/devices.resolver.spec.ts | 101 +++++ .../graph/resolvers/info/devices.resolver.ts | 24 ++ .../resolvers/info/devices.service.spec.ts | 186 +++++++++ .../graph/resolvers/info/devices.service.ts | 225 ++++++++++ .../graph/resolvers/info/info.model.ts | 14 + .../resolvers/info/info.resolver.spec.ts | 368 +++++++++++++++- .../graph/resolvers/info/info.resolver.ts | 30 +- .../graph/resolvers/info/info.service.spec.ts | 346 +++++++++++++++ .../graph/resolvers/info/info.service.ts | 94 +++++ .../graph/resolvers/resolvers.module.ts | 8 + 16 files changed, 1766 insertions(+), 508 deletions(-) delete mode 100644 api/src/graphql/resolvers/query/info.ts create mode 100644 api/src/unraid-api/graph/resolvers/display/display.service.spec.ts create mode 100644 api/src/unraid-api/graph/resolvers/display/display.service.ts create mode 100644 api/src/unraid-api/graph/resolvers/info/devices.resolver.spec.ts create mode 100644 api/src/unraid-api/graph/resolvers/info/devices.resolver.ts create mode 100644 api/src/unraid-api/graph/resolvers/info/devices.service.spec.ts create mode 100644 api/src/unraid-api/graph/resolvers/info/devices.service.ts create mode 100644 api/src/unraid-api/graph/resolvers/info/info.service.spec.ts create mode 100644 api/src/unraid-api/graph/resolvers/info/info.service.ts diff --git a/api/dev/configs/connect.json b/api/dev/configs/connect.json index e69de29bb..157a8984b 100644 --- a/api/dev/configs/connect.json +++ b/api/dev/configs/connect.json @@ -0,0 +1,16 @@ +{ + "wanaccess": false, + "wanport": 0, + "upnpEnabled": false, + "apikey": "", + "localApiKey": "", + "email": "", + "username": "", + "avatar": "", + "regWizTime": "", + "accesstoken": "", + "idtoken": "", + "refreshtoken": "", + "dynamicRemoteAccessType": "DISABLED", + "ssoSubIds": [] +} \ No newline at end of file diff --git a/api/src/graphql/resolvers/query/info.ts b/api/src/graphql/resolvers/query/info.ts deleted file mode 100644 index d7654c9ba..000000000 --- a/api/src/graphql/resolvers/query/info.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { access } from 'fs/promises'; - -import toBytes from 'bytes'; -import { execa, execaCommandSync } from 'execa'; -import { isSymlink } from 'path-type'; -import { cpu, cpuFlags, mem, memLayout, osInfo, versions } from 'systeminformation'; - -import type { PciDevice } from '@app/core/types/index.js'; -import { bootTimestamp } from '@app/common/dashboard/boot-timestamp.js'; -import { getUnraidVersion } from '@app/common/dashboard/get-unraid-version.js'; -import { AppError } from '@app/core/errors/app-error.js'; -import { type DynamixConfig } from '@app/core/types/ini.js'; -import { toBoolean } from '@app/core/utils/casting.js'; -import { docker } from '@app/core/utils/clients/docker.js'; -import { cleanStdout } from '@app/core/utils/misc/clean-stdout.js'; -import { loadState } from '@app/core/utils/misc/load-state.js'; -import { sanitizeProduct } from '@app/core/utils/vms/domain/sanitize-product.js'; -import { sanitizeVendor } from '@app/core/utils/vms/domain/sanitize-vendor.js'; -import { vmRegExps } from '@app/core/utils/vms/domain/vm-regexps.js'; -import { filterDevices } from '@app/core/utils/vms/filter-devices.js'; -import { getPciDevices } from '@app/core/utils/vms/get-pci-devices.js'; -import { getters } from '@app/store/index.js'; -import { ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js'; -import { - Devices, - Display, - Gpu, - InfoApps, - InfoCpu, - InfoMemory, - Os as InfoOs, - MemoryLayout, - Temperature, - Versions, -} from '@app/unraid-api/graph/resolvers/info/info.model.js'; - -export const generateApps = async (): Promise => { - const installed = await docker - .listContainers({ all: true }) - .catch(() => []) - .then((containers) => containers.length); - const started = await docker - .listContainers() - .catch(() => []) - .then((containers) => containers.length); - return { id: 'info/apps', installed, started }; -}; - -export const generateOs = async (): Promise => { - const os = await osInfo(); - - return { - id: 'info/os', - ...os, - hostname: getters.emhttp().var.name, - uptime: bootTimestamp.toISOString(), - }; -}; - -export const generateCpu = async (): Promise => { - const { cores, physicalCores, speedMin, speedMax, stepping, ...rest } = await cpu(); - const flags = await cpuFlags() - .then((flags) => flags.split(' ')) - .catch(() => []); - - return { - id: 'info/cpu', - ...rest, - cores: physicalCores, - threads: cores, - flags, - stepping: Number(stepping), - // @TODO Find out what these should be if they're not defined - speedmin: speedMin || -1, - speedmax: speedMax || -1, - }; -}; - -export const generateDisplay = async (): Promise => { - const filePaths = getters.paths()['dynamix-config']; - - const state = filePaths.reduce>( - (acc, filePath) => { - const state = loadState(filePath); - return state ? { ...acc, ...state } : acc; - }, - { - id: 'dynamix-config/display', - } - ); - - if (!state.display) { - return { - id: 'dynamix-config/display', - }; - } - const { theme, unit, ...display } = state.display; - return { - id: 'dynamix-config/display', - ...display, - theme: theme as ThemeName, - unit: unit as Temperature, - scale: toBoolean(display.scale), - tabs: toBoolean(display.tabs), - resize: toBoolean(display.resize), - wwn: toBoolean(display.wwn), - total: toBoolean(display.total), - usage: toBoolean(display.usage), - text: toBoolean(display.text), - warning: Number.parseInt(display.warning, 10), - critical: Number.parseInt(display.critical, 10), - hot: Number.parseInt(display.hot, 10), - max: Number.parseInt(display.max, 10), - locale: display.locale || 'en_US', - }; -}; - -export const generateVersions = async (): Promise => { - const unraid = await getUnraidVersion(); - const softwareVersions = await versions(); - - return { - id: 'info/versions', - unraid, - ...softwareVersions, - }; -}; - -export const generateMemory = async (): Promise => { - const layout = await memLayout() - .then((dims) => dims.map((dim) => dim as MemoryLayout)) - .catch(() => []); - const info = await mem(); - let max = info.total; - - // Max memory - try { - const memoryInfo = await execa('dmidecode', ['-t', 'memory']) - .then(cleanStdout) - .catch((error: NodeJS.ErrnoException) => { - if (error.code === 'ENOENT') { - throw new AppError('The dmidecode cli utility is missing.'); - } - - throw error; - }); - const lines = memoryInfo.split('\n'); - const header = lines.find((line) => line.startsWith('Physical Memory Array')); - if (header) { - const start = lines.indexOf(header); - const nextHeaders = lines.slice(start, -1).find((line) => line.startsWith('Handle ')); - - if (nextHeaders) { - const end = lines.indexOf(nextHeaders); - const fields = lines.slice(start, end); - - max = - toBytes( - fields - ?.find((line) => line.trim().startsWith('Maximum Capacity')) - ?.trim() - ?.split(': ')[1] ?? '0' - ) ?? 0; - } - } - } catch { - // Ignore errors here - } - - return { - id: 'info/memory', - layout, - max, - ...info, - }; -}; - -export const generateDevices = async (): Promise => { - /** - * Set device class to device. - * @param device The device to modify. - * @returns The same device passed in but with the class modified. - */ - const addDeviceClass = (device: Readonly): PciDevice => { - const modifiedDevice: PciDevice = { - ...device, - class: 'other', - }; - - // GPU - if (vmRegExps.allowedGpuClassId.test(device.typeid)) { - modifiedDevice.class = 'vga'; - // Specialized product name cleanup for GPU - // GF116 [GeForce GTX 550 Ti] --> GeForce GTX 550 Ti - const regex = new RegExp(/.+\[(?.+)]/); - const productName = regex.exec(device.productname)?.groups?.gpuName; - - if (productName) { - modifiedDevice.productname = productName; - } - - return modifiedDevice; - // Audio - } - - if (vmRegExps.allowedAudioClassId.test(device.typeid)) { - modifiedDevice.class = 'audio'; - - return modifiedDevice; - } - - return modifiedDevice; - }; - - /** - * System PCI devices. - */ - const systemPciDevices = async (): Promise => { - const devices = await getPciDevices(); - const basePath = '/sys/bus/pci/devices/0000:'; - - // Remove devices with no IOMMU support - const filteredDevices = await Promise.all( - devices.map(async (device: Readonly) => { - const exists = await access(`${basePath}${device.id}/iommu_group/`) - .then(() => true) - .catch(() => false); - return exists ? device : null; - }) - ).then((devices) => devices.filter((device) => device !== null)); - - /** - * Run device cleanup - * - * Tasks: - * - Mark disallowed devices - * - Add class - * - Add whether kernel-bound driver exists - * - Cleanup device vendor/product names - */ - const processedDevices = await filterDevices(filteredDevices).then(async (devices) => - Promise.all( - devices - .map((device) => addDeviceClass(device as PciDevice)) - .map(async (device) => { - // Attempt to get the current kernel-bound driver for this pci device - await isSymlink(`${basePath}${device.id}/driver`).then((symlink) => { - if (symlink) { - // $strLink = @readlink('/sys/bus/pci/devices/0000:'.$arrMatch['id']. '/driver'); - // if (!empty($strLink)) { - // $strDriver = basename($strLink); - // } - } - }); - - // Clean up the vendor and product name - device.vendorname = sanitizeVendor(device.vendorname); - device.productname = sanitizeProduct(device.productname); - - return device; - }) - ) - ); - - return processedDevices; - }; - - /** - * System GPU Devices - * - * @name systemGPUDevices - * @ignore - * @private - */ - const systemGPUDevices: Promise = systemPciDevices() - .then((devices) => { - return devices - .filter((device) => device.class === 'vga' && !device.allowed) - .map((entry) => { - const gpu: Gpu = { - blacklisted: entry.allowed, - class: entry.class, - id: entry.id, - productid: entry.product, - typeid: entry.typeid, - type: entry.manufacturer, - vendorname: entry.vendorname, - }; - return gpu; - }); - }) - .catch(() => []); - - /** - * System usb devices. - * @returns Array of USB devices. - */ - const getSystemUSBDevices = async () => { - try { - // Get a list of all usb hubs so we can filter the allowed/disallowed - const usbHubs = await execa('cat /sys/bus/usb/drivers/hub/*/modalias', { shell: true }) - .then(({ stdout }) => - stdout.split('\n').map((line) => { - const [, id] = line.match(/usb:v(\w{9})/) ?? []; - return id.replace('p', ':'); - }) - ) - .catch(() => [] as string[]); - - const emhttp = getters.emhttp(); - - // Remove boot drive - const filterBootDrive = (device: Readonly): boolean => - emhttp.var.flashGuid !== device.guid; - - // Remove usb hubs - const filterUsbHubs = (device: Readonly): boolean => !usbHubs.includes(device.id); - - // Clean up the name - const sanitizeVendorName = (device: Readonly) => { - const vendorname = sanitizeVendor(device.vendorname || ''); - return { - ...device, - vendorname, - }; - }; - - // Simplified basic device parsing without verbose details - const parseBasicDevice = async (device: PciDevice): Promise => { - const modifiedDevice: PciDevice = { - ...device, - }; - - // Use a simplified GUID generation instead of calling lsusb -v - const idParts = device.id.split(':'); - if (idParts.length === 2) { - const [vendorId, productId] = idParts; - modifiedDevice.guid = `${vendorId}-${productId}-basic`; - } else { - modifiedDevice.guid = `unknown-${Math.random().toString(36).substring(7)}`; - } - - // Use the name from basic lsusb output - const deviceName = device.name?.trim() || ''; - modifiedDevice.name = deviceName || '[unnamed device]'; - - return modifiedDevice; - }; - - const parseUsbDevices = (stdout: string): PciDevice[] => - stdout - .split('\n') - .map((line) => { - const regex = new RegExp(/^.+: ID (?\S+)(?.*)$/); - const result = regex.exec(line); - if (!result?.groups) return null; - - // Extract name from the line if available - const name = result.groups.n?.trim() || ''; - return { - ...result.groups, - name, - } as unknown as PciDevice; - }) - .filter((device): device is PciDevice => device !== null) ?? []; - - // Get all usb devices with basic listing only - const usbDevices = await execa('lsusb') - .then(async ({ stdout }) => { - const devices = parseUsbDevices(stdout); - // Process devices in parallel - const processedDevices = await Promise.all(devices.map(parseBasicDevice)); - return processedDevices - .filter(filterBootDrive) - .filter(filterUsbHubs) - .map(sanitizeVendorName); - }) - .catch(() => []); - - return usbDevices; - } catch (error: unknown) { - return []; - } - }; - - return { - id: 'info/devices', - // Scsi: await scsiDevices, - gpu: await systemGPUDevices, - pci: await systemPciDevices(), - usb: await getSystemUSBDevices(), - }; -}; diff --git a/api/src/unraid-api/graph/resolvers/display/display.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/display/display.resolver.spec.ts index e40ab6476..af9b51516 100644 --- a/api/src/unraid-api/graph/resolvers/display/display.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/display/display.resolver.spec.ts @@ -1,22 +1,91 @@ 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 { DisplayResolver } from '@app/unraid-api/graph/resolvers/display/display.resolver.js'; +import { DisplayService } from '@app/unraid-api/graph/resolvers/display/display.service.js'; + +// Mock the pubsub module +vi.mock('@app/core/pubsub.js', () => ({ + createSubscription: vi.fn().mockReturnValue('mock-subscription'), + PUBSUB_CHANNEL: { + DISPLAY: 'display', + }, +})); describe('DisplayResolver', () => { let resolver: DisplayResolver; + let displayService: DisplayService; + + const mockDisplayData = { + id: 'display', + case: { + url: '', + icon: 'default', + error: '', + base64: '', + }, + theme: 'black', + unit: 'C', + scale: true, + tabs: false, + resize: true, + wwn: false, + total: true, + usage: false, + text: true, + warning: 40, + critical: 50, + hot: 60, + max: 80, + locale: 'en_US', + }; + + const mockDisplayService = { + generateDisplay: vi.fn().mockResolvedValue(mockDisplayData), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [DisplayResolver], + providers: [ + DisplayResolver, + { + provide: DisplayService, + useValue: mockDisplayService, + }, + ], }).compile(); resolver = module.get(DisplayResolver); + displayService = module.get(DisplayService); + + // Reset mocks before each test + vi.clearAllMocks(); }); it('should be defined', () => { expect(resolver).toBeDefined(); + expect(displayService).toBeDefined(); + }); + + describe('display', () => { + it('should return display info from service', async () => { + const result = await resolver.display(); + + expect(mockDisplayService.generateDisplay).toHaveBeenCalledOnce(); + expect(result).toEqual(mockDisplayData); + }); + }); + + describe('displaySubscription', () => { + it('should create and return subscription', async () => { + const { createSubscription, PUBSUB_CHANNEL } = await import('@app/core/pubsub.js'); + + const result = await resolver.displaySubscription(); + + expect(createSubscription).toHaveBeenCalledWith(PUBSUB_CHANNEL.DISPLAY); + expect(result).toBe('mock-subscription'); + }); }); }); diff --git a/api/src/unraid-api/graph/resolvers/display/display.resolver.ts b/api/src/unraid-api/graph/resolvers/display/display.resolver.ts index 24982d6ee..5fce8d0ea 100644 --- a/api/src/unraid-api/graph/resolvers/display/display.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/display/display.resolver.ts @@ -1,7 +1,4 @@ import { Query, Resolver, Subscription } from '@nestjs/graphql'; -import { existsSync } from 'node:fs'; -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; import { Resource } from '@unraid/shared/graphql.model.js'; import { @@ -11,59 +8,13 @@ import { } from '@unraid/shared/use-permissions.directive.js'; import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; -import { getters } from '@app/store/index.js'; +import { DisplayService } from '@app/unraid-api/graph/resolvers/display/display.service.js'; import { Display } from '@app/unraid-api/graph/resolvers/info/info.model.js'; -const states = { - // Success - custom: { - url: '', - icon: 'custom', - error: '', - base64: '', - }, - default: { - url: '', - icon: 'default', - error: '', - base64: '', - }, - - // Errors - couldNotReadConfigFile: { - url: '', - icon: 'custom', - error: 'could-not-read-config-file', - base64: '', - }, - couldNotReadImage: { - url: '', - icon: 'custom', - error: 'could-not-read-image', - base64: '', - }, - imageMissing: { - url: '', - icon: 'custom', - error: 'image-missing', - base64: '', - }, - imageTooBig: { - url: '', - icon: 'custom', - error: 'image-too-big', - base64: '', - }, - imageCorrupt: { - url: '', - icon: 'custom', - error: 'image-corrupt', - base64: '', - }, -}; - @Resolver(() => Display) export class DisplayResolver { + constructor(private readonly displayService: DisplayService) {} + @UsePermissions({ action: AuthActionVerb.READ, resource: Resource.DISPLAY, @@ -71,47 +22,7 @@ export class DisplayResolver { }) @Query(() => Display) public async display(): Promise { - /** - * This is deprecated, remove it eventually - */ - const dynamixBasePath = getters.paths()['dynamix-base']; - const configFilePath = join(dynamixBasePath, 'case-model.cfg'); - const result = { - id: 'display', - }; - - // If the config file doesn't exist then it's a new OS install - // Default to "default" - if (!existsSync(configFilePath)) { - return { case: states.default, ...result }; - } - - // Attempt to get case from file - const serverCase = await readFile(configFilePath) - .then((buffer) => buffer.toString().split('\n')[0]) - .catch(() => 'error_reading_config_file'); - - // Config file can't be read, maybe a permissions issue? - if (serverCase === 'error_reading_config_file') { - return { case: states.couldNotReadConfigFile, ...result }; - } - - // Blank cfg file? - if (serverCase.trim().length === 0) { - return { - case: states.default, - ...result, - }; - } - - // Non-custom icon - return { - case: { - ...states.default, - icon: serverCase, - }, - ...result, - }; + return this.displayService.generateDisplay(); } @Subscription(() => Display) diff --git a/api/src/unraid-api/graph/resolvers/display/display.service.spec.ts b/api/src/unraid-api/graph/resolvers/display/display.service.spec.ts new file mode 100644 index 000000000..cf4cda338 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/display/display.service.spec.ts @@ -0,0 +1,156 @@ +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DisplayService } from '@app/unraid-api/graph/resolvers/display/display.service.js'; + +// Mock fs/promises at the module level only for specific test cases +vi.mock('node:fs/promises', async () => { + const actualFs = (await vi.importActual('node:fs/promises')) as typeof import('node:fs/promises'); + return { + ...actualFs, + readFile: vi.fn().mockImplementation(actualFs.readFile), + }; +}); + +describe('DisplayService', () => { + let service: DisplayService; + + beforeEach(async () => { + // Reset all mocks + vi.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [DisplayService], + }).compile(); + + service = module.get(DisplayService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('generateDisplay', () => { + it('should return display with case info and configuration from dev files', async () => { + const result = await service.generateDisplay(); + + // Verify basic structure + expect(result).toHaveProperty('id', 'display'); + expect(result).toHaveProperty('case'); + expect(result.case).toHaveProperty('url'); + expect(result.case).toHaveProperty('icon'); + expect(result.case).toHaveProperty('error'); + expect(result.case).toHaveProperty('base64'); + + // Verify case info is properly loaded (should have an icon from case-model.cfg) + expect(result.case!.icon).toBeTruthy(); + expect(result.case!.error).toBe(''); + }); + + it('should handle missing case model config file gracefully', async () => { + // Mock fs.readFile to simulate missing file by throwing an error + const fs = await import('node:fs/promises'); + + vi.mocked(fs.readFile).mockImplementation(async (path, options) => { + if (path.toString().includes('case-model.cfg')) { + const error = new Error('ENOENT: no such file or directory'); + (error as any).code = 'ENOENT'; + throw error; + } + // Use the original implementation for other files + const actualFs = (await vi.importActual( + 'node:fs/promises' + )) as typeof import('node:fs/promises'); + return actualFs.readFile(path, options); + }); + + const result = await service.generateDisplay(); + + expect(result.case).toEqual({ + url: '', + icon: 'custom', + error: 'could-not-read-config-file', + base64: '', + }); + + // Reset the mock to default behavior + const actualFs = (await vi.importActual( + 'node:fs/promises' + )) as typeof import('node:fs/promises'); + vi.mocked(fs.readFile).mockImplementation(actualFs.readFile); + }); + + it('should handle missing dynamix config files gracefully', async () => { + // This test validates that the service handles missing config files + // The loadState function will return null for non-existent files + // We can test this by temporarily creating a service instance + // that would encounter missing files in production + const result = await service.generateDisplay(); + + // Should still return basic structure even if some config is missing + expect(result).toHaveProperty('id', 'display'); + expect(result).toHaveProperty('case'); + // The actual config depends on what's in the dev files + }); + + it('should merge configuration from multiple config files', async () => { + // This test uses the actual dev files which have both default.cfg and dynamix.cfg + const result = await service.generateDisplay(); + + // The result should contain merged configuration from both files + // dynamix.cfg values should override default.cfg values + expect(result.theme).toBe('black'); + expect(result.unit).toBe('C'); + expect(result.scale).toBe(false); // -1 converted to false + expect(result.tabs).toBe(true); // 1 converted to true + expect(result.resize).toBe(false); // 0 converted to false + expect(result.wwn).toBe(false); // 0 converted to false + expect(result.total).toBe(true); // 1 converted to true + expect(result.usage).toBe(false); // 0 converted to false + expect(result.text).toBe(true); // 1 converted to true + expect(result.warning).toBe(70); + expect(result.critical).toBe(90); + expect(result.hot).toBe(45); + expect(result.max).toBe(55); + expect(result.date).toBe('%c'); + expect(result.number).toBe('.,'); + expect(result.users).toBe('Tasks:3'); + expect(result.banner).toBe('image'); + expect(result.dashapps).toBe('icons'); + expect(result.locale).toBe('en_US'); // default fallback when not specified + }); + + it('should handle empty case model config file', async () => { + // Create a test that simulates an empty case-model.cfg + const fs = await import('node:fs/promises'); + + vi.mocked(fs.readFile).mockImplementation(async (path, options) => { + if (path.toString().includes('case-model.cfg')) { + return Buffer.from(' \n'); // Empty/whitespace only + } + // Use the original implementation for other files + const actualFs = (await vi.importActual( + 'node:fs/promises' + )) as typeof import('node:fs/promises'); + return actualFs.readFile(path, options); + }); + + const result = await service.generateDisplay(); + + expect(result.case).toEqual({ + url: '', + icon: 'default', + error: '', + base64: '', + }); + + // Reset the mock to default behavior + const actualFs = (await vi.importActual( + 'node:fs/promises' + )) as typeof import('node:fs/promises'); + vi.mocked(fs.readFile).mockImplementation(actualFs.readFile); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/display/display.service.ts b/api/src/unraid-api/graph/resolvers/display/display.service.ts new file mode 100644 index 000000000..105e3afd4 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/display/display.service.ts @@ -0,0 +1,143 @@ +import { Injectable } from '@nestjs/common'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { type DynamixConfig } from '@app/core/types/ini.js'; +import { toBoolean } from '@app/core/utils/casting.js'; +import { loadState } from '@app/core/utils/misc/load-state.js'; +import { getters } from '@app/store/index.js'; +import { ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js'; +import { Display, Temperature } from '@app/unraid-api/graph/resolvers/info/info.model.js'; + +const states = { + // Success + custom: { + url: '', + icon: 'custom', + error: '', + base64: '', + }, + default: { + url: '', + icon: 'default', + error: '', + base64: '', + }, + + // Errors + couldNotReadConfigFile: { + url: '', + icon: 'custom', + error: 'could-not-read-config-file', + base64: '', + }, + couldNotReadImage: { + url: '', + icon: 'custom', + error: 'could-not-read-image', + base64: '', + }, + imageMissing: { + url: '', + icon: 'custom', + error: 'image-missing', + base64: '', + }, + imageTooBig: { + url: '', + icon: 'custom', + error: 'image-too-big', + base64: '', + }, + imageCorrupt: { + url: '', + icon: 'custom', + error: 'image-corrupt', + base64: '', + }, +}; + +@Injectable() +export class DisplayService { + async generateDisplay(): Promise { + // Get case information + const caseInfo = await this.getCaseInfo(); + + // Get display configuration + const config = await this.getDisplayConfig(); + + return { + id: 'display', + case: caseInfo, + ...config, + }; + } + + private async getCaseInfo() { + const dynamixBasePath = getters.paths()['dynamix-base']; + const configFilePath = join(dynamixBasePath, 'case-model.cfg'); + + // If the config file doesn't exist then it's a new OS install + // Default to "default" + if (!existsSync(configFilePath)) { + return states.default; + } + + // Attempt to get case from file + const serverCase = await readFile(configFilePath) + .then((buffer) => buffer.toString().split('\n')[0]) + .catch(() => 'error_reading_config_file'); + + // Config file can't be read, maybe a permissions issue? + if (serverCase === 'error_reading_config_file') { + return states.couldNotReadConfigFile; + } + + // Blank cfg file? + if (serverCase.trim().length === 0) { + return states.default; + } + + // Non-custom icon + return { + ...states.default, + icon: serverCase, + }; + } + + private async getDisplayConfig() { + const filePaths = getters.paths()['dynamix-config']; + + const state = filePaths.reduce>((acc, filePath) => { + const state = loadState(filePath); + if (state) { + Object.assign(acc, state); + } + return acc; + }, {}); + + if (!state.display) { + return {}; + } + + const { theme, unit, ...display } = state.display; + return { + ...display, + theme: theme as ThemeName, + unit: unit as Temperature, + scale: toBoolean(display.scale), + tabs: toBoolean(display.tabs), + resize: toBoolean(display.resize), + wwn: toBoolean(display.wwn), + total: toBoolean(display.total), + usage: toBoolean(display.usage), + text: toBoolean(display.text), + warning: Number.parseInt(display.warning, 10), + critical: Number.parseInt(display.critical, 10), + hot: Number.parseInt(display.hot, 10), + max: Number.parseInt(display.max, 10), + locale: display.locale || 'en_US', + }; + } +} diff --git a/api/src/unraid-api/graph/resolvers/info/devices.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/info/devices.resolver.spec.ts new file mode 100644 index 000000000..9d5becb69 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/devices.resolver.spec.ts @@ -0,0 +1,101 @@ +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DevicesResolver } from '@app/unraid-api/graph/resolvers/info/devices.resolver.js'; +import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices.service.js'; + +describe('DevicesResolver', () => { + let resolver: DevicesResolver; + let devicesService: DevicesService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DevicesResolver, + { + provide: DevicesService, + useValue: { + generateGpu: vi.fn(), + generatePci: vi.fn(), + generateUsb: vi.fn(), + }, + }, + ], + }).compile(); + + resolver = module.get(DevicesResolver); + devicesService = module.get(DevicesService); + }); + + it('should be defined', () => { + expect(resolver).toBeDefined(); + }); + + describe('gpu', () => { + it('should call devicesService.generateGpu', async () => { + const mockGpus = [ + { + id: 'gpu/01:00.0', + blacklisted: false, + class: 'vga', + productid: '2206', + typeid: '0300', + type: 'NVIDIA', + vendorname: 'NVIDIA', + }, + ]; + + vi.mocked(devicesService.generateGpu).mockResolvedValue(mockGpus); + + const result = await resolver.gpu(); + + expect(devicesService.generateGpu).toHaveBeenCalledOnce(); + expect(result).toEqual(mockGpus); + }); + }); + + describe('pci', () => { + it('should call devicesService.generatePci', async () => { + const mockPciDevices = [ + { + id: 'pci/01:00.0', + type: 'NVIDIA', + typeid: '0300', + vendorname: 'NVIDIA', + vendorid: '0300', + productname: 'GeForce RTX 3080', + productid: '2206', + blacklisted: 'false', + class: 'vga', + }, + ]; + + vi.mocked(devicesService.generatePci).mockResolvedValue(mockPciDevices); + + const result = await resolver.pci(); + + expect(devicesService.generatePci).toHaveBeenCalledOnce(); + expect(result).toEqual(mockPciDevices); + }); + }); + + describe('usb', () => { + it('should call devicesService.generateUsb', async () => { + const mockUsbDevices = [ + { + id: 'usb/1234:5678', + name: 'Test USB Device', + }, + ]; + + vi.mocked(devicesService.generateUsb).mockResolvedValue(mockUsbDevices); + + const result = await resolver.usb(); + + expect(devicesService.generateUsb).toHaveBeenCalledOnce(); + expect(result).toEqual(mockUsbDevices); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/info/devices.resolver.ts b/api/src/unraid-api/graph/resolvers/info/devices.resolver.ts new file mode 100644 index 000000000..4f7bcf647 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/devices.resolver.ts @@ -0,0 +1,24 @@ +import { ResolveField, Resolver } from '@nestjs/graphql'; + +import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices.service.js'; +import { Devices, Gpu, Pci, Usb } from '@app/unraid-api/graph/resolvers/info/info.model.js'; + +@Resolver(() => Devices) +export class DevicesResolver { + constructor(private readonly devicesService: DevicesService) {} + + @ResolveField(() => [Gpu]) + public async gpu(): Promise { + return this.devicesService.generateGpu(); + } + + @ResolveField(() => [Pci]) + public async pci(): Promise { + return this.devicesService.generatePci(); + } + + @ResolveField(() => [Usb]) + public async usb(): Promise { + return this.devicesService.generateUsb(); + } +} diff --git a/api/src/unraid-api/graph/resolvers/info/devices.service.spec.ts b/api/src/unraid-api/graph/resolvers/info/devices.service.spec.ts new file mode 100644 index 000000000..85cd8c7df --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/devices.service.spec.ts @@ -0,0 +1,186 @@ +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices.service.js'; + +// Mock external dependencies +vi.mock('fs/promises', () => ({ + access: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('execa', () => ({ + execa: vi.fn(), +})); + +vi.mock('path-type', () => ({ + isSymlink: vi.fn().mockResolvedValue(false), +})); + +vi.mock('@app/core/utils/vms/get-pci-devices.js', () => ({ + getPciDevices: vi.fn(), +})); + +vi.mock('@app/core/utils/vms/filter-devices.js', () => ({ + filterDevices: vi.fn(), +})); + +vi.mock('@app/store/index.js', () => ({ + getters: { + emhttp: () => ({ + var: { + flashGuid: 'test-flash-guid', + }, + }), + }, +})); + +describe('DevicesService', () => { + let service: DevicesService; + let mockExeca: any; + let mockGetPciDevices: any; + let mockFilterDevices: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [DevicesService], + }).compile(); + + service = module.get(DevicesService); + + mockExeca = await import('execa'); + mockGetPciDevices = await import('@app/core/utils/vms/get-pci-devices.js'); + mockFilterDevices = await import('@app/core/utils/vms/filter-devices.js'); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('generateGpu', () => { + it('should return GPU devices from PCI devices', async () => { + const mockPciDevices = [ + { + id: '01:00.0', + typeid: '0300', + vendorname: 'NVIDIA', + productname: 'GeForce RTX 3080', + product: '2206', + manufacturer: 'NVIDIA', + allowed: false, + class: 'vga', + }, + { + id: '02:00.0', + typeid: '0403', + vendorname: 'Intel', + productname: 'Audio Controller', + product: '1234', + manufacturer: 'Intel', + allowed: false, + class: 'audio', + }, + ]; + + mockGetPciDevices.getPciDevices.mockResolvedValue(mockPciDevices); + mockFilterDevices.filterDevices.mockResolvedValue(mockPciDevices); + + const result = await service.generateGpu(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: 'gpu/01:00.0', + blacklisted: false, + class: 'vga', + productid: '2206', + typeid: '0300', + type: 'NVIDIA', + vendorname: 'NVIDIA', + }); + }); + + it('should handle errors gracefully', async () => { + mockGetPciDevices.getPciDevices.mockRejectedValue(new Error('PCI error')); + + const result = await service.generateGpu(); + + expect(result).toEqual([]); + }); + }); + + describe('generatePci', () => { + it('should return all PCI devices', async () => { + const mockPciDevices = [ + { + id: '01:00.0', + typeid: '0300', + vendorname: 'NVIDIA', + productname: 'GeForce RTX 3080', + product: '2206', + manufacturer: 'NVIDIA', + allowed: false, + class: 'vga', + }, + ]; + + mockGetPciDevices.getPciDevices.mockResolvedValue(mockPciDevices); + mockFilterDevices.filterDevices.mockResolvedValue(mockPciDevices); + + const result = await service.generatePci(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: 'pci/01:00.0', + type: 'NVIDIA', + typeid: '0300', + vendorname: 'NVIDIA', + vendorid: '0300', + productname: 'GeForce RTX 3080', + productid: '2206', + blacklisted: 'false', + class: 'vga', + }); + }); + + it('should handle errors gracefully', async () => { + mockGetPciDevices.getPciDevices.mockRejectedValue(new Error('PCI error')); + + const result = await service.generatePci(); + + expect(result).toEqual([]); + }); + }); + + describe('generateUsb', () => { + it('should return USB devices', async () => { + mockExeca.execa + .mockResolvedValueOnce({ stdout: '' }) // Empty USB hubs to avoid filtering + .mockResolvedValueOnce({ + stdout: 'Bus 001 Device 002: ID 1234:5678 Test USB Device\nBus 001 Device 003: ID abcd:ef01 Another Device', + }); // USB devices + + const result = await service.generateUsb(); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + id: 'usb/1234:5678', + name: 'Test USB Device', + }); + expect(result[1]).toEqual({ + id: 'usb/abcd:ef01', + name: 'Another Device', + }); + }); + + it('should handle errors gracefully', async () => { + mockExeca.execa.mockRejectedValue(new Error('USB error')); + + const result = await service.generateUsb(); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/info/devices.service.ts b/api/src/unraid-api/graph/resolvers/info/devices.service.ts new file mode 100644 index 000000000..7d90ccf5f --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/devices.service.ts @@ -0,0 +1,225 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { access } from 'fs/promises'; + +import { execa } from 'execa'; +import { isSymlink } from 'path-type'; + +import type { PciDevice } from '@app/core/types/index.js'; +import { sanitizeProduct } from '@app/core/utils/vms/domain/sanitize-product.js'; +import { sanitizeVendor } from '@app/core/utils/vms/domain/sanitize-vendor.js'; +import { vmRegExps } from '@app/core/utils/vms/domain/vm-regexps.js'; +import { filterDevices } from '@app/core/utils/vms/filter-devices.js'; +import { getPciDevices } from '@app/core/utils/vms/get-pci-devices.js'; +import { getters } from '@app/store/index.js'; +import { + Gpu, + Pci, + RawUsbDeviceData, + Usb, + UsbDevice, +} from '@app/unraid-api/graph/resolvers/info/info.model.js'; + +@Injectable() +export class DevicesService { + private readonly logger = new Logger(DevicesService.name); + + async generateGpu(): Promise { + try { + const systemPciDevices = await this.getSystemPciDevices(); + return systemPciDevices + .filter((device) => device.class === 'vga' && !device.allowed) + .map((entry) => { + const gpu: Gpu = { + id: `gpu/${entry.id}`, + blacklisted: entry.allowed, + class: entry.class, + productid: entry.product, + typeid: entry.typeid, + type: entry.manufacturer, + vendorname: entry.vendorname, + }; + return gpu; + }); + } catch (error: unknown) { + this.logger.error( + `Failed to generate GPU devices: ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined + ); + return []; + } + } + + async generatePci(): Promise { + try { + const devices = await this.getSystemPciDevices(); + return devices.map((device) => ({ + id: `pci/${device.id}`, + type: device.manufacturer, + typeid: device.typeid, + vendorname: device.vendorname, + vendorid: device.typeid.substring(0, 4), // Extract vendor ID from type ID + productname: device.productname, + productid: device.product, + blacklisted: device.allowed ? 'true' : 'false', + class: device.class, + })); + } catch (error: unknown) { + this.logger.error( + `Failed to generate PCI devices: ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined + ); + return []; + } + } + + async generateUsb(): Promise { + try { + const usbDevices = await this.getSystemUSBDevices(); + return usbDevices.map((device) => ({ + id: `usb/${device.id}`, + name: device.name, + })); + } catch (error: unknown) { + this.logger.error( + `Failed to generate USB devices: ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined + ); + return []; + } + } + + private addDeviceClass(device: Readonly): PciDevice { + const modifiedDevice: PciDevice = { + ...device, + class: 'other', + }; + + if (vmRegExps.allowedGpuClassId.test(device.typeid)) { + modifiedDevice.class = 'vga'; + const regex = new RegExp(/.+\[(?.+)]/); + const productName = regex.exec(device.productname)?.groups?.gpuName; + + if (productName) { + modifiedDevice.productname = productName; + } + + return modifiedDevice; + } + + if (vmRegExps.allowedAudioClassId.test(device.typeid)) { + modifiedDevice.class = 'audio'; + return modifiedDevice; + } + + return modifiedDevice; + } + + private async getSystemPciDevices(): Promise { + const devices = await getPciDevices(); + const basePath = '/sys/bus/pci/devices/0000:'; + + const filteredDevices = await Promise.all( + devices.map(async (device: Readonly) => { + const exists = await access(`${basePath}${device.id}/iommu_group/`) + .then(() => true) + .catch(() => false); + return exists ? device : null; + }) + ).then((devices) => devices.filter((device) => device !== null)); + + const processedDevices = await filterDevices(filteredDevices).then(async (devices) => + Promise.all( + devices + .map((device) => this.addDeviceClass(device as PciDevice)) + .map(async (device) => { + await isSymlink(`${basePath}${device.id}/driver`).then((symlink) => { + if (symlink) { + // Future: Add driver detection logic here + } + }); + + device.vendorname = sanitizeVendor(device.vendorname); + device.productname = sanitizeProduct(device.productname); + + return device; + }) + ) + ); + + return processedDevices; + } + + private async getSystemUSBDevices(): Promise { + const usbHubs = await execa('cat /sys/bus/usb/drivers/hub/*/modalias', { shell: true }) + .then(({ stdout }) => + stdout.split('\n').map((line) => { + const [, id] = line.match(/usb:v(\w{9})/) ?? []; + return id.replace('p', ':'); + }) + ) + .catch(() => [] as string[]); + + const emhttp = getters.emhttp(); + + const filterBootDrive = (device: UsbDevice): boolean => emhttp.var.flashGuid !== device.guid; + + const filterUsbHubs = (device: UsbDevice): boolean => !usbHubs.includes(device.id); + + const sanitizeVendorName = (device: UsbDevice): UsbDevice => { + const vendorname = sanitizeVendor(device.vendorname || ''); + return { + ...device, + vendorname, + }; + }; + + const parseBasicDevice = (device: RawUsbDeviceData): UsbDevice => { + const idParts = device.id.split(':'); + let guid: string; + + if (idParts.length === 2) { + const [vendorId, productId] = idParts; + guid = `${vendorId}-${productId}-basic`; + } else { + guid = `unknown-${randomUUID()}`; + } + + const deviceName = device.n?.trim() || ''; + + return { + id: device.id, + name: deviceName || '[unnamed device]', + guid, + vendorname: '', // Will be sanitized later + }; + }; + + const parseUsbDevices = (stdout: string): UsbDevice[] => { + return stdout + .split('\n') + .map((line) => { + const regex = /^.+: ID (?\S+)(?.*)$/; + const result = regex.exec(line); + if (!result?.groups) return null; + + const rawData: RawUsbDeviceData = { + id: result.groups.id, + n: result.groups.n, + }; + + return parseBasicDevice(rawData); + }) + .filter((device): device is UsbDevice => device !== null); + }; + + const usbDevices = await execa('lsusb') + .then(({ stdout }) => { + const devices = parseUsbDevices(stdout); + return devices.filter(filterBootDrive).filter(filterUsbHubs).map(sanitizeVendorName); + }) + .catch(() => []); + + return usbDevices; + } +} diff --git a/api/src/unraid-api/graph/resolvers/info/info.model.ts b/api/src/unraid-api/graph/resolvers/info/info.model.ts index 585189a31..d4808a316 100644 --- a/api/src/unraid-api/graph/resolvers/info/info.model.ts +++ b/api/src/unraid-api/graph/resolvers/info/info.model.ts @@ -14,6 +14,20 @@ import { GraphQLBigInt, GraphQLJSON } from 'graphql-scalars'; import { ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js'; +// USB device interface for type safety +export interface UsbDevice { + id: string; + name: string; + guid: string; + vendorname: string; +} + +// Raw USB device data from lsusb parsing +export interface RawUsbDeviceData { + id: string; + n?: string; +} + export enum Temperature { C = 'C', F = 'F', diff --git a/api/src/unraid-api/graph/resolvers/info/info.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/info/info.resolver.spec.ts index 478ce2431..a2d4d2417 100644 --- a/api/src/unraid-api/graph/resolvers/info/info.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/info/info.resolver.spec.ts @@ -1,22 +1,382 @@ import type { TestingModule } from '@nestjs/testing'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Test } from '@nestjs/testing'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { DisplayService } from '@app/unraid-api/graph/resolvers/display/display.service.js'; +import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; import { InfoResolver } from '@app/unraid-api/graph/resolvers/info/info.resolver.js'; +import { InfoService } from '@app/unraid-api/graph/resolvers/info/info.service.js'; + +// Mock necessary modules +vi.mock('fs/promises', () => ({ + readFile: vi.fn().mockResolvedValue(''), +})); + +vi.mock('@app/core/pubsub.js', () => ({ + pubsub: { + publish: vi.fn().mockResolvedValue(undefined), + }, + PUBSUB_CHANNEL: { + INFO: 'info', + }, + createSubscription: vi.fn().mockReturnValue('mock-subscription'), +})); + +vi.mock('dockerode', () => { + return { + default: vi.fn().mockImplementation(() => ({ + listContainers: vi.fn(), + listNetworks: vi.fn(), + })), + }; +}); + +vi.mock('@app/store/index.js', () => ({ + getters: { + paths: () => ({ + 'docker-autostart': '/path/to/docker-autostart', + }), + }, +})); + +vi.mock('systeminformation', () => ({ + baseboard: vi.fn().mockResolvedValue({ + manufacturer: 'ASUS', + model: 'PRIME X570-P', + version: 'Rev X.0x', + serial: 'ABC123', + assetTag: 'Default string', + }), + system: vi.fn().mockResolvedValue({ + manufacturer: 'ASUS', + model: 'System Product Name', + version: 'System Version', + serial: 'System Serial Number', + uuid: '550e8400-e29b-41d4-a716-446655440000', + sku: 'SKU', + }), +})); + +vi.mock('@app/core/utils/misc/get-machine-id.js', () => ({ + getMachineId: vi.fn().mockResolvedValue('test-machine-id-123'), +})); + +// Mock Cache Manager +const mockCacheManager = { + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), +}; describe('InfoResolver', () => { let resolver: InfoResolver; + // Mock data for testing + const mockAppsData = { + id: 'info/apps', + installed: 5, + started: 3, + }; + + const mockCpuData = { + id: 'info/cpu', + manufacturer: 'AMD', + brand: 'AMD Ryzen 9 5900X', + vendor: 'AMD', + family: '19', + model: '33', + stepping: 0, + revision: '', + voltage: '1.4V', + speed: 3.7, + speedmin: 2.2, + speedmax: 4.8, + threads: 24, + cores: 12, + processors: 1, + socket: 'AM4', + cache: { l1d: 32768, l1i: 32768, l2: 524288, l3: 33554432 }, + flags: ['fpu', 'vme', 'de', 'pse'], + }; + + const mockDevicesData = { + id: 'info/devices', + gpu: [], + pci: [], + usb: [], + }; + + const mockDisplayData = { + id: 'display', + case: { + url: '', + icon: 'default', + error: '', + base64: '', + }, + theme: 'black', + unit: 'C', + scale: true, + tabs: false, + resize: true, + wwn: false, + total: true, + usage: false, + text: true, + warning: 40, + critical: 50, + hot: 60, + max: 80, + locale: 'en_US', + }; + + const mockMemoryData = { + id: 'info/memory', + max: 68719476736, + total: 67108864000, + free: 33554432000, + used: 33554432000, + active: 16777216000, + available: 50331648000, + buffcache: 8388608000, + swaptotal: 4294967296, + swapused: 0, + swapfree: 4294967296, + layout: [], + }; + + const mockOsData = { + id: 'info/os', + platform: 'linux', + distro: 'Unraid', + release: '6.12.0', + codename: '', + kernel: '6.1.0-unraid', + arch: 'x64', + hostname: 'Tower', + codepage: 'UTF-8', + logofile: 'unraid', + serial: '', + build: '', + uptime: '2024-01-01T00:00:00.000Z', + }; + + const mockVersionsData = { + id: 'info/versions', + unraid: '6.12.0', + kernel: '6.1.0', + node: '20.10.0', + npm: '10.2.3', + docker: '24.0.7', + }; + + // Mock InfoService + const mockInfoService = { + generateApps: vi.fn().mockResolvedValue(mockAppsData), + generateCpu: vi.fn().mockResolvedValue(mockCpuData), + generateDevices: vi.fn().mockResolvedValue(mockDevicesData), + generateMemory: vi.fn().mockResolvedValue(mockMemoryData), + generateOs: vi.fn().mockResolvedValue(mockOsData), + generateVersions: vi.fn().mockResolvedValue(mockVersionsData), + }; + + // Mock DisplayService + const mockDisplayService = { + generateDisplay: vi.fn().mockResolvedValue(mockDisplayData), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [InfoResolver], + providers: [ + InfoResolver, + { + provide: InfoService, + useValue: mockInfoService, + }, + { + provide: DisplayService, + useValue: mockDisplayService, + }, + { + provide: DockerService, + useValue: {}, + }, + { + provide: CACHE_MANAGER, + useValue: mockCacheManager, + }, + ], }).compile(); resolver = module.get(InfoResolver); + + // Reset mocks before each test + vi.clearAllMocks(); }); - it('should be defined', () => { - expect(resolver).toBeDefined(); + describe('info', () => { + it('should return basic info object', async () => { + const result = await resolver.info(); + + expect(result).toEqual({ + id: 'info', + }); + }); + }); + + describe('time', () => { + it('should return current date', async () => { + const beforeCall = new Date(); + const result = await resolver.time(); + const afterCall = new Date(); + + expect(result).toBeInstanceOf(Date); + expect(result.getTime()).toBeGreaterThanOrEqual(beforeCall.getTime()); + expect(result.getTime()).toBeLessThanOrEqual(afterCall.getTime()); + }); + }); + + describe('apps', () => { + it('should return apps info from service', async () => { + const result = await resolver.apps(); + + expect(mockInfoService.generateApps).toHaveBeenCalledOnce(); + expect(result).toEqual(mockAppsData); + }); + }); + + describe('baseboard', () => { + it('should return baseboard info with id', async () => { + const result = await resolver.baseboard(); + + expect(result).toEqual({ + id: 'baseboard', + manufacturer: 'ASUS', + model: 'PRIME X570-P', + version: 'Rev X.0x', + serial: 'ABC123', + assetTag: 'Default string', + }); + }); + }); + + describe('cpu', () => { + it('should return cpu info from service', async () => { + const result = await resolver.cpu(); + + expect(mockInfoService.generateCpu).toHaveBeenCalledOnce(); + expect(result).toEqual(mockCpuData); + }); + }); + + describe('devices', () => { + it('should return devices info from service', async () => { + const result = await resolver.devices(); + + expect(mockInfoService.generateDevices).toHaveBeenCalledOnce(); + expect(result).toEqual(mockDevicesData); + }); + }); + + describe('display', () => { + it('should return display info from display service', async () => { + const result = await resolver.display(); + + expect(mockDisplayService.generateDisplay).toHaveBeenCalledOnce(); + expect(result).toEqual(mockDisplayData); + }); + }); + + describe('machineId', () => { + it('should return machine id', async () => { + const result = await resolver.machineId(); + + expect(result).toBe('test-machine-id-123'); + }); + + it('should handle getMachineId errors gracefully', async () => { + const { getMachineId } = await import('@app/core/utils/misc/get-machine-id.js'); + vi.mocked(getMachineId).mockRejectedValueOnce(new Error('Machine ID error')); + + await expect(resolver.machineId()).rejects.toThrow('Machine ID error'); + }); + }); + + describe('memory', () => { + it('should return memory info from service', async () => { + const result = await resolver.memory(); + + expect(mockInfoService.generateMemory).toHaveBeenCalledOnce(); + expect(result).toEqual(mockMemoryData); + }); + }); + + describe('os', () => { + it('should return os info from service', async () => { + const result = await resolver.os(); + + expect(mockInfoService.generateOs).toHaveBeenCalledOnce(); + expect(result).toEqual(mockOsData); + }); + }); + + describe('system', () => { + it('should return system info with id', async () => { + const result = await resolver.system(); + + expect(result).toEqual({ + id: 'system', + manufacturer: 'ASUS', + model: 'System Product Name', + version: 'System Version', + serial: 'System Serial Number', + uuid: '550e8400-e29b-41d4-a716-446655440000', + sku: 'SKU', + }); + }); + }); + + describe('versions', () => { + it('should return versions info from service', async () => { + const result = await resolver.versions(); + + expect(mockInfoService.generateVersions).toHaveBeenCalledOnce(); + expect(result).toEqual(mockVersionsData); + }); + }); + + describe('infoSubscription', () => { + it('should create and return subscription', async () => { + const { createSubscription, PUBSUB_CHANNEL } = await import('@app/core/pubsub.js'); + + const result = await resolver.infoSubscription(); + + expect(createSubscription).toHaveBeenCalledWith(PUBSUB_CHANNEL.INFO); + expect(result).toBe('mock-subscription'); + }); + }); + + describe('error handling', () => { + it('should handle baseboard errors gracefully', async () => { + const { baseboard } = await import('systeminformation'); + vi.mocked(baseboard).mockRejectedValueOnce(new Error('Baseboard error')); + + await expect(resolver.baseboard()).rejects.toThrow('Baseboard error'); + }); + + it('should handle system errors gracefully', async () => { + const { system } = await import('systeminformation'); + vi.mocked(system).mockRejectedValueOnce(new Error('System error')); + + await expect(resolver.system()).rejects.toThrow('System error'); + }); + + it('should handle service errors gracefully', async () => { + mockInfoService.generateApps.mockRejectedValueOnce(new Error('Service error')); + + await expect(resolver.apps()).rejects.toThrow('Service error'); + }); }); }); diff --git a/api/src/unraid-api/graph/resolvers/info/info.resolver.ts b/api/src/unraid-api/graph/resolvers/info/info.resolver.ts index 6728de9d8..a461154e8 100644 --- a/api/src/unraid-api/graph/resolvers/info/info.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/info/info.resolver.ts @@ -10,15 +10,7 @@ import { baseboard as getBaseboard, system as getSystem } from 'systeminformatio import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; import { getMachineId } from '@app/core/utils/misc/get-machine-id.js'; -import { - generateApps, - generateCpu, - generateDevices, - generateDisplay, - generateMemory, - generateOs, - generateVersions, -} from '@app/graphql/resolvers/query/info.js'; +import { DisplayService } from '@app/unraid-api/graph/resolvers/display/display.service.js'; import { Baseboard, Devices, @@ -31,9 +23,15 @@ import { System, Versions, } from '@app/unraid-api/graph/resolvers/info/info.model.js'; +import { InfoService } from '@app/unraid-api/graph/resolvers/info/info.service.js'; @Resolver(() => Info) export class InfoResolver { + constructor( + private readonly infoService: InfoService, + private readonly displayService: DisplayService + ) {} + @Query(() => Info) @UsePermissions({ action: AuthActionVerb.READ, @@ -53,7 +51,7 @@ export class InfoResolver { @ResolveField(() => InfoApps) public async apps(): Promise { - return generateApps(); + return this.infoService.generateApps(); } @ResolveField(() => Baseboard) @@ -67,17 +65,17 @@ export class InfoResolver { @ResolveField(() => InfoCpu) public async cpu(): Promise { - return generateCpu(); + return this.infoService.generateCpu(); } @ResolveField(() => Devices) public async devices(): Promise { - return generateDevices(); + return this.infoService.generateDevices(); } @ResolveField(() => Display) public async display(): Promise { - return generateDisplay(); + return this.displayService.generateDisplay(); } @ResolveField(() => String, { nullable: true }) @@ -87,12 +85,12 @@ export class InfoResolver { @ResolveField(() => InfoMemory) public async memory(): Promise { - return generateMemory(); + return this.infoService.generateMemory(); } @ResolveField(() => Os) public async os(): Promise { - return generateOs(); + return this.infoService.generateOs(); } @ResolveField(() => System) @@ -106,7 +104,7 @@ export class InfoResolver { @ResolveField(() => Versions) public async versions(): Promise { - return generateVersions(); + return this.infoService.generateVersions(); } @Subscription(() => Info) diff --git a/api/src/unraid-api/graph/resolvers/info/info.service.spec.ts b/api/src/unraid-api/graph/resolvers/info/info.service.spec.ts new file mode 100644 index 000000000..ab9bafa7b --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/info.service.spec.ts @@ -0,0 +1,346 @@ +import type { TestingModule } from '@nestjs/testing'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Test } from '@nestjs/testing'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ContainerState } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; +import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; +import { InfoService } from '@app/unraid-api/graph/resolvers/info/info.service.js'; + +// Mock external dependencies +vi.mock('fs/promises', () => ({ + access: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(''), +})); + +vi.mock('execa', () => ({ + execa: vi.fn(), +})); + +vi.mock('path-type', () => ({ + isSymlink: vi.fn().mockResolvedValue(false), +})); + +vi.mock('systeminformation', () => ({ + cpu: vi.fn(), + cpuFlags: vi.fn(), + mem: vi.fn(), + memLayout: vi.fn(), + osInfo: vi.fn(), + versions: vi.fn(), +})); + +vi.mock('@app/common/dashboard/boot-timestamp.js', () => ({ + bootTimestamp: new Date('2024-01-01T00:00:00.000Z'), +})); + +vi.mock('@app/common/dashboard/get-unraid-version.js', () => ({ + getUnraidVersion: vi.fn(), +})); + +vi.mock('@app/core/pubsub.js', () => ({ + pubsub: { + publish: vi.fn().mockResolvedValue(undefined), + }, + PUBSUB_CHANNEL: { + INFO: 'info', + }, +})); + +vi.mock('dockerode', () => { + return { + default: vi.fn().mockImplementation(() => ({ + listContainers: vi.fn(), + listNetworks: vi.fn(), + })), + }; +}); + +vi.mock('@app/core/utils/misc/clean-stdout.js', () => ({ + cleanStdout: vi.fn((input) => input), +})); + +vi.mock('bytes', () => ({ + default: vi.fn((value) => { + if (value === '32 GB') return 34359738368; + if (value === '16 GB') return 17179869184; + if (value === '4 GB') return 4294967296; + return 0; + }), +})); + +vi.mock('@app/core/utils/misc/load-state.js', () => ({ + loadState: vi.fn(), +})); + +vi.mock('@app/store/index.js', () => ({ + getters: { + emhttp: () => ({ + var: { + name: 'test-hostname', + flashGuid: 'test-flash-guid', + }, + }), + paths: () => ({ + 'dynamix-config': ['/test/config/path'], + 'docker-autostart': '/path/to/docker-autostart', + }), + }, +})); + +// Mock Cache Manager +const mockCacheManager = { + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), +}; + +describe('InfoService', () => { + let service: InfoService; + let dockerService: DockerService; + let mockSystemInfo: any; + let mockExeca: any; + let mockGetUnraidVersion: any; + let mockLoadState: any; + + beforeEach(async () => { + // Reset all mocks + vi.clearAllMocks(); + mockCacheManager.get.mockReset(); + mockCacheManager.set.mockReset(); + mockCacheManager.del.mockReset(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + InfoService, + DockerService, + { + provide: CACHE_MANAGER, + useValue: mockCacheManager, + }, + ], + }).compile(); + + service = module.get(InfoService); + dockerService = module.get(DockerService); + + // Get mock references + mockSystemInfo = await import('systeminformation'); + mockExeca = await import('execa'); + mockGetUnraidVersion = await import('@app/common/dashboard/get-unraid-version.js'); + mockLoadState = await import('@app/core/utils/misc/load-state.js'); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('generateApps', () => { + it('should return docker container statistics', async () => { + const mockContainers = [ + { id: '1', state: ContainerState.RUNNING }, + { id: '2', state: ContainerState.EXITED }, + { id: '3', state: ContainerState.RUNNING }, + ]; + + mockCacheManager.get.mockResolvedValue(mockContainers); + + const result = await service.generateApps(); + + expect(result).toEqual({ + id: 'info/apps', + installed: 3, + started: 2, + }); + }); + + it('should handle docker errors gracefully', async () => { + mockCacheManager.get.mockResolvedValue([]); + + const result = await service.generateApps(); + + expect(result).toEqual({ + id: 'info/apps', + installed: 0, + started: 0, + }); + }); + }); + + describe('generateOs', () => { + it('should return OS information with hostname and uptime', async () => { + const mockOsInfo = { + platform: 'linux', + distro: 'Unraid', + release: '6.12.0', + kernel: '6.1.0-unraid', + }; + + mockSystemInfo.osInfo.mockResolvedValue(mockOsInfo); + + const result = await service.generateOs(); + + expect(result).toEqual({ + id: 'info/os', + ...mockOsInfo, + hostname: 'test-hostname', + uptime: '2024-01-01T00:00:00.000Z', + }); + }); + }); + + describe('generateCpu', () => { + it('should return CPU information with proper mapping', async () => { + const mockCpuInfo = { + manufacturer: 'Intel', + brand: 'Intel(R) Core(TM) i7-9700K', + family: '6', + model: '158', + cores: 16, + physicalCores: 8, + speedMin: 800, + speedMax: 4900, + stepping: '10', + cache: { l1d: 32768 }, + }; + + const mockFlags = 'fpu vme de pse tsc msr pae mce'; + + mockSystemInfo.cpu.mockResolvedValue(mockCpuInfo); + mockSystemInfo.cpuFlags.mockResolvedValue(mockFlags); + + const result = await service.generateCpu(); + + expect(result).toEqual({ + id: 'info/cpu', + manufacturer: 'Intel', + brand: 'Intel(R) Core(TM) i7-9700K', + family: '6', + model: '158', + cores: 8, // physicalCores + threads: 16, // cores + flags: ['fpu', 'vme', 'de', 'pse', 'tsc', 'msr', 'pae', 'mce'], + stepping: 10, + speedmin: 800, + speedmax: 4900, + cache: { l1d: 32768 }, + }); + }); + + it('should handle missing speed values', async () => { + const mockCpuInfo = { + manufacturer: 'AMD', + cores: 12, + physicalCores: 6, + stepping: '2', + }; + + mockSystemInfo.cpu.mockResolvedValue(mockCpuInfo); + mockSystemInfo.cpuFlags.mockResolvedValue('sse sse2'); + + const result = await service.generateCpu(); + + expect(result.speedmin).toBe(-1); + expect(result.speedmax).toBe(-1); + }); + + it('should handle cpuFlags error gracefully', async () => { + mockSystemInfo.cpu.mockResolvedValue({ cores: 8, physicalCores: 4, stepping: '1' }); + mockSystemInfo.cpuFlags.mockRejectedValue(new Error('CPU flags error')); + + const result = await service.generateCpu(); + + expect(result.flags).toEqual([]); + }); + }); + + describe('generateVersions', () => { + it('should return version information', async () => { + const mockUnraidVersion = '6.12.0'; + const mockSoftwareVersions = { + node: '18.17.0', + npm: '9.6.7', + docker: '24.0.0', + }; + + mockGetUnraidVersion.getUnraidVersion.mockResolvedValue(mockUnraidVersion); + mockSystemInfo.versions.mockResolvedValue(mockSoftwareVersions); + + const result = await service.generateVersions(); + + expect(result).toEqual({ + id: 'info/versions', + unraid: '6.12.0', + node: '18.17.0', + npm: '9.6.7', + docker: '24.0.0', + }); + }); + }); + + describe('generateMemory', () => { + it('should return memory information with layout', async () => { + const mockMemLayout = [ + { + size: 8589934592, + bank: 'BANK 0', + type: 'DDR4', + clockSpeed: 3200, + }, + ]; + + const mockMemInfo = { + total: 17179869184, + free: 8589934592, + used: 8589934592, + active: 4294967296, + available: 12884901888, + }; + + mockSystemInfo.memLayout.mockResolvedValue(mockMemLayout); + mockSystemInfo.mem.mockResolvedValue(mockMemInfo); + + const result = await service.generateMemory(); + + expect(result).toEqual({ + id: 'info/memory', + layout: mockMemLayout, + max: mockMemInfo.total, // No dmidecode output, so max = total + ...mockMemInfo, + }); + }); + + it('should handle memLayout error gracefully', async () => { + mockSystemInfo.memLayout.mockRejectedValue(new Error('Memory layout error')); + mockSystemInfo.mem.mockResolvedValue({ total: 1000 }); + + const result = await service.generateMemory(); + + expect(result.layout).toEqual([]); + }); + + it('should handle dmidecode parsing for maximum capacity', async () => { + mockSystemInfo.memLayout.mockResolvedValue([]); + mockSystemInfo.mem.mockResolvedValue({ total: 16000000000 }); + // Mock dmidecode command to throw error (simulating no dmidecode available) + mockExeca.execa.mockRejectedValue(new Error('dmidecode not found')); + + const result = await service.generateMemory(); + + // Should fallback to using mem.total when dmidecode fails + expect(result.max).toBe(16000000000); + expect(result.id).toBe('info/memory'); + }); + }); + + describe('generateDevices', () => { + it('should return basic devices object with empty arrays', async () => { + const result = await service.generateDevices(); + + expect(result).toEqual({ + id: 'info/devices', + }); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/info/info.service.ts b/api/src/unraid-api/graph/resolvers/info/info.service.ts new file mode 100644 index 000000000..b036dbb1f --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/info.service.ts @@ -0,0 +1,94 @@ +import { Injectable } from '@nestjs/common'; + +import { cpu, cpuFlags, mem, memLayout, osInfo, versions } from 'systeminformation'; + +import { bootTimestamp } from '@app/common/dashboard/boot-timestamp.js'; +import { getUnraidVersion } from '@app/common/dashboard/get-unraid-version.js'; +import { getters } from '@app/store/index.js'; +import { ContainerState } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; +import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; +import { + Devices, + InfoApps, + InfoCpu, + InfoMemory, + Os as InfoOs, + MemoryLayout, + Versions, +} from '@app/unraid-api/graph/resolvers/info/info.model.js'; + +@Injectable() +export class InfoService { + constructor(private readonly dockerService: DockerService) {} + + async generateApps(): Promise { + const containers = await this.dockerService.getContainers({ skipCache: false }); + const installed = containers.length; + const started = containers.filter( + (container) => container.state === ContainerState.RUNNING + ).length; + + return { id: 'info/apps', installed, started }; + } + + async generateOs(): Promise { + const os = await osInfo(); + + return { + id: 'info/os', + ...os, + hostname: getters.emhttp().var.name, + uptime: bootTimestamp.toISOString(), + }; + } + + async generateCpu(): Promise { + const { cores, physicalCores, speedMin, speedMax, stepping, ...rest } = await cpu(); + const flags = await cpuFlags() + .then((flags) => flags.split(' ')) + .catch(() => []); + + return { + id: 'info/cpu', + ...rest, + cores: physicalCores, + threads: cores, + flags, + stepping: Number(stepping), + speedmin: speedMin || -1, + speedmax: speedMax || -1, + }; + } + + async generateVersions(): Promise { + const unraid = await getUnraidVersion(); + const softwareVersions = await versions(); + + return { + id: 'info/versions', + unraid, + ...softwareVersions, + }; + } + + async generateMemory(): Promise { + const layout = await memLayout() + .then((dims) => dims.map((dim) => dim as MemoryLayout)) + .catch(() => []); + const info = await mem(); + + return { + id: 'info/memory', + layout, + max: info.total, + ...info, + }; + } + + async generateDevices(): Promise { + return { + id: 'info/devices', + // These fields will be resolved by DevicesResolver + } as Devices; + } +} diff --git a/api/src/unraid-api/graph/resolvers/resolvers.module.ts b/api/src/unraid-api/graph/resolvers/resolvers.module.ts index 3b4f37f8b..9ef7b9ca7 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -8,10 +8,14 @@ import { ConfigResolver } from '@app/unraid-api/graph/resolvers/config/config.re import { CustomizationModule } from '@app/unraid-api/graph/resolvers/customization/customization.module.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 { DisplayService } from '@app/unraid-api/graph/resolvers/display/display.service.js'; import { DockerModule } from '@app/unraid-api/graph/resolvers/docker/docker.module.js'; import { FlashBackupModule } from '@app/unraid-api/graph/resolvers/flash-backup/flash-backup.module.js'; import { FlashResolver } from '@app/unraid-api/graph/resolvers/flash/flash.resolver.js'; +import { DevicesResolver } from '@app/unraid-api/graph/resolvers/info/devices.resolver.js'; +import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices.service.js'; import { InfoResolver } from '@app/unraid-api/graph/resolvers/info/info.resolver.js'; +import { InfoService } from '@app/unraid-api/graph/resolvers/info/info.service.js'; import { LogsResolver } from '@app/unraid-api/graph/resolvers/logs/logs.resolver.js'; import { LogsService } from '@app/unraid-api/graph/resolvers/logs/logs.service.js'; import { RootMutationsResolver } from '@app/unraid-api/graph/resolvers/mutation/mutation.resolver.js'; @@ -45,9 +49,13 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js'; ], providers: [ ConfigResolver, + DevicesResolver, + DevicesService, DisplayResolver, + DisplayService, FlashResolver, InfoResolver, + InfoService, LogsResolver, LogsService, MeResolver,