mirror of
https://github.com/unraid/api.git
synced 2025-12-31 13:39:52 -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 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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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';
|
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',
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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 { 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user