mirror of
https://github.com/unraid/api.git
synced 2025-12-31 05:29:48 -06:00
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:
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"wanaccess": false,
|
||||
"wanport": 0,
|
||||
"upnpEnabled": false,
|
||||
"apikey": "",
|
||||
"localApiKey": "",
|
||||
"email": "",
|
||||
"username": "",
|
||||
"avatar": "",
|
||||
"regWizTime": "",
|
||||
"accesstoken": "",
|
||||
"idtoken": "",
|
||||
"refreshtoken": "",
|
||||
"dynamicRemoteAccessType": "DISABLED",
|
||||
"ssoSubIds": []
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
};
|
||||
@@ -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>(DisplayResolver);
|
||||
displayService = module.get<DisplayService>(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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Display> {
|
||||
/**
|
||||
* 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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
143
api/src/unraid-api/graph/resolvers/display/display.service.ts
Normal file
143
api/src/unraid-api/graph/resolvers/display/display.service.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
101
api/src/unraid-api/graph/resolvers/info/devices.resolver.spec.ts
Normal file
101
api/src/unraid-api/graph/resolvers/info/devices.resolver.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
24
api/src/unraid-api/graph/resolvers/info/devices.resolver.ts
Normal file
24
api/src/unraid-api/graph/resolvers/info/devices.resolver.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
186
api/src/unraid-api/graph/resolvers/info/devices.service.spec.ts
Normal file
186
api/src/unraid-api/graph/resolvers/info/devices.service.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
225
api/src/unraid-api/graph/resolvers/info/devices.service.ts
Normal file
225
api/src/unraid-api/graph/resolvers/info/devices.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>(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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<InfoApps> {
|
||||
return generateApps();
|
||||
return this.infoService.generateApps();
|
||||
}
|
||||
|
||||
@ResolveField(() => Baseboard)
|
||||
@@ -67,17 +65,17 @@ export class InfoResolver {
|
||||
|
||||
@ResolveField(() => InfoCpu)
|
||||
public async cpu(): Promise<InfoCpu> {
|
||||
return generateCpu();
|
||||
return this.infoService.generateCpu();
|
||||
}
|
||||
|
||||
@ResolveField(() => Devices)
|
||||
public async devices(): Promise<Devices> {
|
||||
return generateDevices();
|
||||
return this.infoService.generateDevices();
|
||||
}
|
||||
|
||||
@ResolveField(() => Display)
|
||||
public async display(): Promise<Display> {
|
||||
return generateDisplay();
|
||||
return this.displayService.generateDisplay();
|
||||
}
|
||||
|
||||
@ResolveField(() => String, { nullable: true })
|
||||
@@ -87,12 +85,12 @@ export class InfoResolver {
|
||||
|
||||
@ResolveField(() => InfoMemory)
|
||||
public async memory(): Promise<InfoMemory> {
|
||||
return generateMemory();
|
||||
return this.infoService.generateMemory();
|
||||
}
|
||||
|
||||
@ResolveField(() => Os)
|
||||
public async os(): Promise<Os> {
|
||||
return generateOs();
|
||||
return this.infoService.generateOs();
|
||||
}
|
||||
|
||||
@ResolveField(() => System)
|
||||
@@ -106,7 +104,7 @@ export class InfoResolver {
|
||||
|
||||
@ResolveField(() => Versions)
|
||||
public async versions(): Promise<Versions> {
|
||||
return generateVersions();
|
||||
return this.infoService.generateVersions();
|
||||
}
|
||||
|
||||
@Subscription(() => Info)
|
||||
|
||||
346
api/src/unraid-api/graph/resolvers/info/info.service.spec.ts
Normal file
346
api/src/unraid-api/graph/resolvers/info/info.service.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
94
api/src/unraid-api/graph/resolvers/info/info.service.ts
Normal file
94
api/src/unraid-api/graph/resolvers/info/info.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user