feat: info resolver cleanup (#1425)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Eli Bosley
2025-06-20 17:22:54 -04:00
committed by GitHub
parent 68df344a4b
commit 1b279bbab3
16 changed files with 1766 additions and 508 deletions

View File

@@ -0,0 +1,16 @@
{
"wanaccess": false,
"wanport": 0,
"upnpEnabled": false,
"apikey": "",
"localApiKey": "",
"email": "",
"username": "",
"avatar": "",
"regWizTime": "",
"accesstoken": "",
"idtoken": "",
"refreshtoken": "",
"dynamicRemoteAccessType": "DISABLED",
"ssoSubIds": []
}

View File

@@ -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<InfoApps> => {
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<InfoOs> => {
const os = await osInfo();
return {
id: 'info/os',
...os,
hostname: getters.emhttp().var.name,
uptime: bootTimestamp.toISOString(),
};
};
export const generateCpu = async (): Promise<InfoCpu> => {
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<Display> => {
const filePaths = getters.paths()['dynamix-config'];
const state = filePaths.reduce<Partial<DynamixConfig>>(
(acc, filePath) => {
const state = loadState<DynamixConfig>(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<Versions> => {
const unraid = await getUnraidVersion();
const softwareVersions = await versions();
return {
id: 'info/versions',
unraid,
...softwareVersions,
};
};
export const generateMemory = async (): Promise<InfoMemory> => {
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<Devices> => {
/**
* 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>): 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(/.+\[(?<gpuName>.+)]/);
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<PciDevice[]> => {
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<PciDevice>) => {
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<Gpu[]> = 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<PciDevice>): boolean =>
emhttp.var.flashGuid !== device.guid;
// Remove usb hubs
const filterUsbHubs = (device: Readonly<PciDevice>): boolean => !usbHubs.includes(device.id);
// Clean up the name
const sanitizeVendorName = (device: Readonly<PciDevice>) => {
const vendorname = sanitizeVendor(device.vendorname || '');
return {
...device,
vendorname,
};
};
// Simplified basic device parsing without verbose details
const parseBasicDevice = async (device: PciDevice): Promise<PciDevice> => {
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 (?<id>\S+)(?<n>.*)$/);
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(),
};
};

View File

@@ -1,22 +1,91 @@
import type { TestingModule } from '@nestjs/testing'; import type { TestingModule } from '@nestjs/testing';
import { Test } 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 { 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', () => { describe('DisplayResolver', () => {
let resolver: 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 () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [DisplayResolver], providers: [
DisplayResolver,
{
provide: DisplayService,
useValue: mockDisplayService,
},
],
}).compile(); }).compile();
resolver = module.get<DisplayResolver>(DisplayResolver); resolver = module.get<DisplayResolver>(DisplayResolver);
displayService = module.get<DisplayService>(DisplayService);
// Reset mocks before each test
vi.clearAllMocks();
}); });
it('should be defined', () => { it('should be defined', () => {
expect(resolver).toBeDefined(); 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');
});
}); });
}); });

View File

@@ -1,7 +1,4 @@
import { Query, Resolver, Subscription } from '@nestjs/graphql'; 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 { Resource } from '@unraid/shared/graphql.model.js';
import { import {
@@ -11,59 +8,13 @@ import {
} from '@unraid/shared/use-permissions.directive.js'; } from '@unraid/shared/use-permissions.directive.js';
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.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'; 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) @Resolver(() => Display)
export class DisplayResolver { export class DisplayResolver {
constructor(private readonly displayService: DisplayService) {}
@UsePermissions({ @UsePermissions({
action: AuthActionVerb.READ, action: AuthActionVerb.READ,
resource: Resource.DISPLAY, resource: Resource.DISPLAY,
@@ -71,47 +22,7 @@ export class DisplayResolver {
}) })
@Query(() => Display) @Query(() => Display)
public async display(): Promise<Display> { public async display(): Promise<Display> {
/** return this.displayService.generateDisplay();
* 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,
};
} }
@Subscription(() => Display) @Subscription(() => Display)

View File

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

View File

@@ -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<Display> {
// 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<Partial<DynamixConfig>>((acc, filePath) => {
const state = loadState<DynamixConfig>(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',
};
}
}

View File

@@ -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>(DevicesResolver);
devicesService = module.get<DevicesService>(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);
});
});
});

View File

@@ -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<Gpu[]> {
return this.devicesService.generateGpu();
}
@ResolveField(() => [Pci])
public async pci(): Promise<Pci[]> {
return this.devicesService.generatePci();
}
@ResolveField(() => [Usb])
public async usb(): Promise<Usb[]> {
return this.devicesService.generateUsb();
}
}

View File

@@ -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>(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([]);
});
});
});

View File

@@ -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<Gpu[]> {
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<Pci[]> {
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<Usb[]> {
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>): PciDevice {
const modifiedDevice: PciDevice = {
...device,
class: 'other',
};
if (vmRegExps.allowedGpuClassId.test(device.typeid)) {
modifiedDevice.class = 'vga';
const regex = new RegExp(/.+\[(?<gpuName>.+)]/);
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<PciDevice[]> {
const devices = await getPciDevices();
const basePath = '/sys/bus/pci/devices/0000:';
const filteredDevices = await Promise.all(
devices.map(async (device: Readonly<PciDevice>) => {
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<UsbDevice[]> {
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 (?<id>\S+)(?<n>.*)$/;
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;
}
}

View File

@@ -14,6 +14,20 @@ import { GraphQLBigInt, GraphQLJSON } from 'graphql-scalars';
import { ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js'; 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 { export enum Temperature {
C = 'C', C = 'C',
F = 'F', F = 'F',

View File

@@ -1,22 +1,382 @@
import type { TestingModule } from '@nestjs/testing'; import type { TestingModule } from '@nestjs/testing';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Test } from '@nestjs/testing'; 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 { 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', () => { describe('InfoResolver', () => {
let resolver: 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 () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ 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(); }).compile();
resolver = module.get<InfoResolver>(InfoResolver); resolver = module.get<InfoResolver>(InfoResolver);
// Reset mocks before each test
vi.clearAllMocks();
}); });
it('should be defined', () => { describe('info', () => {
expect(resolver).toBeDefined(); 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');
});
}); });
}); });

View File

@@ -10,15 +10,7 @@ import { baseboard as getBaseboard, system as getSystem } from 'systeminformatio
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { getMachineId } from '@app/core/utils/misc/get-machine-id.js'; import { getMachineId } from '@app/core/utils/misc/get-machine-id.js';
import { import { DisplayService } from '@app/unraid-api/graph/resolvers/display/display.service.js';
generateApps,
generateCpu,
generateDevices,
generateDisplay,
generateMemory,
generateOs,
generateVersions,
} from '@app/graphql/resolvers/query/info.js';
import { import {
Baseboard, Baseboard,
Devices, Devices,
@@ -31,9 +23,15 @@ import {
System, System,
Versions, Versions,
} from '@app/unraid-api/graph/resolvers/info/info.model.js'; } from '@app/unraid-api/graph/resolvers/info/info.model.js';
import { InfoService } from '@app/unraid-api/graph/resolvers/info/info.service.js';
@Resolver(() => Info) @Resolver(() => Info)
export class InfoResolver { export class InfoResolver {
constructor(
private readonly infoService: InfoService,
private readonly displayService: DisplayService
) {}
@Query(() => Info) @Query(() => Info)
@UsePermissions({ @UsePermissions({
action: AuthActionVerb.READ, action: AuthActionVerb.READ,
@@ -53,7 +51,7 @@ export class InfoResolver {
@ResolveField(() => InfoApps) @ResolveField(() => InfoApps)
public async apps(): Promise<InfoApps> { public async apps(): Promise<InfoApps> {
return generateApps(); return this.infoService.generateApps();
} }
@ResolveField(() => Baseboard) @ResolveField(() => Baseboard)
@@ -67,17 +65,17 @@ export class InfoResolver {
@ResolveField(() => InfoCpu) @ResolveField(() => InfoCpu)
public async cpu(): Promise<InfoCpu> { public async cpu(): Promise<InfoCpu> {
return generateCpu(); return this.infoService.generateCpu();
} }
@ResolveField(() => Devices) @ResolveField(() => Devices)
public async devices(): Promise<Devices> { public async devices(): Promise<Devices> {
return generateDevices(); return this.infoService.generateDevices();
} }
@ResolveField(() => Display) @ResolveField(() => Display)
public async display(): Promise<Display> { public async display(): Promise<Display> {
return generateDisplay(); return this.displayService.generateDisplay();
} }
@ResolveField(() => String, { nullable: true }) @ResolveField(() => String, { nullable: true })
@@ -87,12 +85,12 @@ export class InfoResolver {
@ResolveField(() => InfoMemory) @ResolveField(() => InfoMemory)
public async memory(): Promise<InfoMemory> { public async memory(): Promise<InfoMemory> {
return generateMemory(); return this.infoService.generateMemory();
} }
@ResolveField(() => Os) @ResolveField(() => Os)
public async os(): Promise<Os> { public async os(): Promise<Os> {
return generateOs(); return this.infoService.generateOs();
} }
@ResolveField(() => System) @ResolveField(() => System)
@@ -106,7 +104,7 @@ export class InfoResolver {
@ResolveField(() => Versions) @ResolveField(() => Versions)
public async versions(): Promise<Versions> { public async versions(): Promise<Versions> {
return generateVersions(); return this.infoService.generateVersions();
} }
@Subscription(() => Info) @Subscription(() => Info)

View File

@@ -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>(InfoService);
dockerService = module.get<DockerService>(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',
});
});
});
});

View File

@@ -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<InfoApps> {
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<InfoOs> {
const os = await osInfo();
return {
id: 'info/os',
...os,
hostname: getters.emhttp().var.name,
uptime: bootTimestamp.toISOString(),
};
}
async generateCpu(): Promise<InfoCpu> {
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<Versions> {
const unraid = await getUnraidVersion();
const softwareVersions = await versions();
return {
id: 'info/versions',
unraid,
...softwareVersions,
};
}
async generateMemory(): Promise<InfoMemory> {
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<Devices> {
return {
id: 'info/devices',
// These fields will be resolved by DevicesResolver
} as Devices;
}
}

View File

@@ -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 { CustomizationModule } from '@app/unraid-api/graph/resolvers/customization/customization.module.js';
import { DisksModule } from '@app/unraid-api/graph/resolvers/disks/disks.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 { 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 { 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 { 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 { 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 { 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 { LogsResolver } from '@app/unraid-api/graph/resolvers/logs/logs.resolver.js';
import { LogsService } from '@app/unraid-api/graph/resolvers/logs/logs.service.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'; 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: [ providers: [
ConfigResolver, ConfigResolver,
DevicesResolver,
DevicesService,
DisplayResolver, DisplayResolver,
DisplayService,
FlashResolver, FlashResolver,
InfoResolver, InfoResolver,
InfoService,
LogsResolver, LogsResolver,
LogsService, LogsService,
MeResolver, MeResolver,