mirror of
https://github.com/unraid/api.git
synced 2026-01-01 22:20:05 -06:00
Merge ee65e80435 into b7798b82f4
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.12.0",
|
||||
"version": "4.13.1",
|
||||
"extraOrigins": [],
|
||||
"sandbox": true,
|
||||
"ssoSubIds": [],
|
||||
|
||||
@@ -940,223 +940,6 @@ enum ThemeName {
|
||||
white
|
||||
}
|
||||
|
||||
type InfoApps implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""How many docker containers are installed"""
|
||||
installed: Int!
|
||||
|
||||
"""How many docker containers are running"""
|
||||
started: Int!
|
||||
}
|
||||
|
||||
type Baseboard implements Node {
|
||||
id: PrefixedID!
|
||||
manufacturer: String!
|
||||
model: String
|
||||
version: String
|
||||
serial: String
|
||||
assetTag: String
|
||||
}
|
||||
|
||||
type InfoCpu implements Node {
|
||||
id: PrefixedID!
|
||||
manufacturer: String!
|
||||
brand: String!
|
||||
vendor: String!
|
||||
family: String!
|
||||
model: String!
|
||||
stepping: Int!
|
||||
revision: String!
|
||||
voltage: String
|
||||
speed: Float!
|
||||
speedmin: Float!
|
||||
speedmax: Float!
|
||||
threads: Int!
|
||||
cores: Int!
|
||||
processors: Int!
|
||||
socket: String!
|
||||
cache: JSON!
|
||||
flags: [String!]!
|
||||
}
|
||||
|
||||
type Gpu implements Node {
|
||||
id: PrefixedID!
|
||||
type: String!
|
||||
typeid: String!
|
||||
vendorname: String!
|
||||
productid: String!
|
||||
blacklisted: Boolean!
|
||||
class: String!
|
||||
}
|
||||
|
||||
type Pci implements Node {
|
||||
id: PrefixedID!
|
||||
type: String
|
||||
typeid: String
|
||||
vendorname: String
|
||||
vendorid: String
|
||||
productname: String
|
||||
productid: String
|
||||
blacklisted: String
|
||||
class: String
|
||||
}
|
||||
|
||||
type Usb implements Node {
|
||||
id: PrefixedID!
|
||||
name: String
|
||||
}
|
||||
|
||||
type Devices implements Node {
|
||||
id: PrefixedID!
|
||||
gpu: [Gpu!]!
|
||||
pci: [Pci!]!
|
||||
usb: [Usb!]!
|
||||
}
|
||||
|
||||
type Case implements Node {
|
||||
id: PrefixedID!
|
||||
icon: String
|
||||
url: String
|
||||
error: String
|
||||
base64: String
|
||||
}
|
||||
|
||||
type Display implements Node {
|
||||
id: PrefixedID!
|
||||
case: Case
|
||||
date: String
|
||||
number: String
|
||||
scale: Boolean
|
||||
tabs: Boolean
|
||||
users: String
|
||||
resize: Boolean
|
||||
wwn: Boolean
|
||||
total: Boolean
|
||||
usage: Boolean
|
||||
banner: String
|
||||
dashapps: String
|
||||
theme: ThemeName
|
||||
text: Boolean
|
||||
unit: Temperature
|
||||
warning: Int
|
||||
critical: Int
|
||||
hot: Int
|
||||
max: Int
|
||||
locale: String
|
||||
}
|
||||
|
||||
"""Temperature unit (Celsius or Fahrenheit)"""
|
||||
enum Temperature {
|
||||
C
|
||||
F
|
||||
}
|
||||
|
||||
type MemoryLayout implements Node {
|
||||
id: PrefixedID!
|
||||
size: BigInt!
|
||||
bank: String
|
||||
type: String
|
||||
clockSpeed: Int
|
||||
formFactor: String
|
||||
manufacturer: String
|
||||
partNum: String
|
||||
serialNum: String
|
||||
voltageConfigured: Int
|
||||
voltageMin: Int
|
||||
voltageMax: Int
|
||||
}
|
||||
|
||||
type InfoMemory implements Node {
|
||||
id: PrefixedID!
|
||||
max: BigInt!
|
||||
total: BigInt!
|
||||
free: BigInt!
|
||||
used: BigInt!
|
||||
active: BigInt!
|
||||
available: BigInt!
|
||||
buffcache: BigInt!
|
||||
swaptotal: BigInt!
|
||||
swapused: BigInt!
|
||||
swapfree: BigInt!
|
||||
layout: [MemoryLayout!]!
|
||||
}
|
||||
|
||||
type Os implements Node {
|
||||
id: PrefixedID!
|
||||
platform: String
|
||||
distro: String
|
||||
release: String
|
||||
codename: String
|
||||
kernel: String
|
||||
arch: String
|
||||
hostname: String
|
||||
codepage: String
|
||||
logofile: String
|
||||
serial: String
|
||||
build: String
|
||||
uptime: String
|
||||
}
|
||||
|
||||
type System implements Node {
|
||||
id: PrefixedID!
|
||||
manufacturer: String
|
||||
model: String
|
||||
version: String
|
||||
serial: String
|
||||
uuid: String
|
||||
sku: String
|
||||
}
|
||||
|
||||
type Versions implements Node {
|
||||
id: PrefixedID!
|
||||
kernel: String
|
||||
openssl: String
|
||||
systemOpenssl: String
|
||||
systemOpensslLib: String
|
||||
node: String
|
||||
v8: String
|
||||
npm: String
|
||||
yarn: String
|
||||
pm2: String
|
||||
gulp: String
|
||||
grunt: String
|
||||
git: String
|
||||
tsc: String
|
||||
mysql: String
|
||||
redis: String
|
||||
mongodb: String
|
||||
apache: String
|
||||
nginx: String
|
||||
php: String
|
||||
docker: String
|
||||
postfix: String
|
||||
postgresql: String
|
||||
perl: String
|
||||
python: String
|
||||
gcc: String
|
||||
unraid: String
|
||||
}
|
||||
|
||||
type Info implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""Count of docker containers"""
|
||||
apps: InfoApps!
|
||||
baseboard: Baseboard!
|
||||
cpu: InfoCpu!
|
||||
devices: Devices!
|
||||
display: Display!
|
||||
|
||||
"""Machine ID"""
|
||||
machineId: PrefixedID
|
||||
memory: InfoMemory!
|
||||
os: Os!
|
||||
system: System!
|
||||
time: DateTime!
|
||||
versions: Versions!
|
||||
}
|
||||
|
||||
type ContainerPort {
|
||||
ip: String
|
||||
privatePort: Port
|
||||
@@ -1278,6 +1061,574 @@ type Flash implements Node {
|
||||
product: String!
|
||||
}
|
||||
|
||||
type InfoGpu implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""GPU type/manufacturer"""
|
||||
type: String!
|
||||
|
||||
"""GPU type identifier"""
|
||||
typeid: String!
|
||||
|
||||
"""Whether GPU is blacklisted"""
|
||||
blacklisted: Boolean!
|
||||
|
||||
"""Device class"""
|
||||
class: String!
|
||||
|
||||
"""Product ID"""
|
||||
productid: String!
|
||||
|
||||
"""Vendor name"""
|
||||
vendorname: String
|
||||
}
|
||||
|
||||
type InfoNetwork implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""Network interface name"""
|
||||
iface: String!
|
||||
|
||||
"""Network interface model"""
|
||||
model: String
|
||||
|
||||
"""Network vendor"""
|
||||
vendor: String
|
||||
|
||||
"""MAC address"""
|
||||
mac: String
|
||||
|
||||
"""Virtual interface flag"""
|
||||
virtual: Boolean
|
||||
|
||||
"""Network speed"""
|
||||
speed: String
|
||||
|
||||
"""DHCP enabled flag"""
|
||||
dhcp: Boolean
|
||||
}
|
||||
|
||||
type InfoPci implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""Device type/manufacturer"""
|
||||
type: String!
|
||||
|
||||
"""Type identifier"""
|
||||
typeid: String!
|
||||
|
||||
"""Vendor name"""
|
||||
vendorname: String
|
||||
|
||||
"""Vendor ID"""
|
||||
vendorid: String!
|
||||
|
||||
"""Product name"""
|
||||
productname: String
|
||||
|
||||
"""Product ID"""
|
||||
productid: String!
|
||||
|
||||
"""Blacklisted status"""
|
||||
blacklisted: String!
|
||||
|
||||
"""Device class"""
|
||||
class: String!
|
||||
}
|
||||
|
||||
type InfoUsb implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""USB device name"""
|
||||
name: String!
|
||||
|
||||
"""USB bus number"""
|
||||
bus: String
|
||||
|
||||
"""USB device number"""
|
||||
device: String
|
||||
}
|
||||
|
||||
type InfoDevices implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""List of GPU devices"""
|
||||
gpu: [InfoGpu!]
|
||||
|
||||
"""List of network interfaces"""
|
||||
network: [InfoNetwork!]
|
||||
|
||||
"""List of PCI devices"""
|
||||
pci: [InfoPci!]
|
||||
|
||||
"""List of USB devices"""
|
||||
usb: [InfoUsb!]
|
||||
}
|
||||
|
||||
type InfoDisplayCase implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""Case image URL"""
|
||||
url: String!
|
||||
|
||||
"""Case icon identifier"""
|
||||
icon: String!
|
||||
|
||||
"""Error message if any"""
|
||||
error: String!
|
||||
|
||||
"""Base64 encoded case image"""
|
||||
base64: String!
|
||||
}
|
||||
|
||||
type InfoDisplay implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""Case display configuration"""
|
||||
case: InfoDisplayCase!
|
||||
|
||||
"""UI theme name"""
|
||||
theme: ThemeName!
|
||||
|
||||
"""Temperature unit (C or F)"""
|
||||
unit: Temperature!
|
||||
|
||||
"""Enable UI scaling"""
|
||||
scale: Boolean!
|
||||
|
||||
"""Show tabs in UI"""
|
||||
tabs: Boolean!
|
||||
|
||||
"""Enable UI resize"""
|
||||
resize: Boolean!
|
||||
|
||||
"""Show WWN identifiers"""
|
||||
wwn: Boolean!
|
||||
|
||||
"""Show totals"""
|
||||
total: Boolean!
|
||||
|
||||
"""Show usage statistics"""
|
||||
usage: Boolean!
|
||||
|
||||
"""Show text labels"""
|
||||
text: Boolean!
|
||||
|
||||
"""Warning temperature threshold"""
|
||||
warning: Int!
|
||||
|
||||
"""Critical temperature threshold"""
|
||||
critical: Int!
|
||||
|
||||
"""Hot temperature threshold"""
|
||||
hot: Int!
|
||||
|
||||
"""Maximum temperature threshold"""
|
||||
max: Int
|
||||
|
||||
"""Locale setting"""
|
||||
locale: String
|
||||
}
|
||||
|
||||
"""Temperature unit"""
|
||||
enum Temperature {
|
||||
CELSIUS
|
||||
FAHRENHEIT
|
||||
}
|
||||
|
||||
"""CPU load for a single core"""
|
||||
type CpuLoad {
|
||||
"""The total CPU load on a single core, in percent."""
|
||||
percentTotal: Float!
|
||||
|
||||
"""The percentage of time the CPU spent in user space."""
|
||||
percentUser: Float!
|
||||
|
||||
"""The percentage of time the CPU spent in kernel space."""
|
||||
percentSystem: Float!
|
||||
|
||||
"""
|
||||
The percentage of time the CPU spent on low-priority (niced) user space processes.
|
||||
"""
|
||||
percentNice: Float!
|
||||
|
||||
"""The percentage of time the CPU was idle."""
|
||||
percentIdle: Float!
|
||||
|
||||
"""The percentage of time the CPU spent servicing hardware interrupts."""
|
||||
percentIrq: Float!
|
||||
}
|
||||
|
||||
type CpuUtilization implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""Total CPU load in percent"""
|
||||
percentTotal: Float!
|
||||
|
||||
"""CPU load for each core"""
|
||||
cpus: [CpuLoad!]!
|
||||
}
|
||||
|
||||
type InfoCpu implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""CPU manufacturer"""
|
||||
manufacturer: String
|
||||
|
||||
"""CPU brand name"""
|
||||
brand: String
|
||||
|
||||
"""CPU vendor"""
|
||||
vendor: String
|
||||
|
||||
"""CPU family"""
|
||||
family: String
|
||||
|
||||
"""CPU model"""
|
||||
model: String
|
||||
|
||||
"""CPU stepping"""
|
||||
stepping: Int
|
||||
|
||||
"""CPU revision"""
|
||||
revision: String
|
||||
|
||||
"""CPU voltage"""
|
||||
voltage: String
|
||||
|
||||
"""Current CPU speed in GHz"""
|
||||
speed: Float
|
||||
|
||||
"""Minimum CPU speed in GHz"""
|
||||
speedmin: Float
|
||||
|
||||
"""Maximum CPU speed in GHz"""
|
||||
speedmax: Float
|
||||
|
||||
"""Number of CPU threads"""
|
||||
threads: Int
|
||||
|
||||
"""Number of CPU cores"""
|
||||
cores: Int
|
||||
|
||||
"""Number of physical processors"""
|
||||
processors: Int
|
||||
|
||||
"""CPU socket type"""
|
||||
socket: String
|
||||
|
||||
"""CPU cache information"""
|
||||
cache: JSON
|
||||
|
||||
"""CPU feature flags"""
|
||||
flags: [String!]
|
||||
}
|
||||
|
||||
type MemoryLayout implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""Memory module size in bytes"""
|
||||
size: BigInt!
|
||||
|
||||
"""Memory bank location (e.g., BANK 0)"""
|
||||
bank: String
|
||||
|
||||
"""Memory type (e.g., DDR4, DDR5)"""
|
||||
type: String
|
||||
|
||||
"""Memory clock speed in MHz"""
|
||||
clockSpeed: Int
|
||||
|
||||
"""Part number of the memory module"""
|
||||
partNum: String
|
||||
|
||||
"""Serial number of the memory module"""
|
||||
serialNum: String
|
||||
|
||||
"""Memory manufacturer"""
|
||||
manufacturer: String
|
||||
|
||||
"""Form factor (e.g., DIMM, SODIMM)"""
|
||||
formFactor: String
|
||||
|
||||
"""Configured voltage in millivolts"""
|
||||
voltageConfigured: Int
|
||||
|
||||
"""Minimum voltage in millivolts"""
|
||||
voltageMin: Int
|
||||
|
||||
"""Maximum voltage in millivolts"""
|
||||
voltageMax: Int
|
||||
}
|
||||
|
||||
type MemoryUtilization implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""Total system memory in bytes"""
|
||||
total: BigInt!
|
||||
|
||||
"""Used memory in bytes"""
|
||||
used: BigInt!
|
||||
|
||||
"""Free memory in bytes"""
|
||||
free: BigInt!
|
||||
|
||||
"""Available memory in bytes"""
|
||||
available: BigInt!
|
||||
|
||||
"""Active memory in bytes"""
|
||||
active: BigInt!
|
||||
|
||||
"""Buffer/cache memory in bytes"""
|
||||
buffcache: BigInt!
|
||||
|
||||
"""Memory usage percentage"""
|
||||
percentTotal: Float!
|
||||
|
||||
"""Total swap memory in bytes"""
|
||||
swapTotal: BigInt!
|
||||
|
||||
"""Used swap memory in bytes"""
|
||||
swapUsed: BigInt!
|
||||
|
||||
"""Free swap memory in bytes"""
|
||||
swapFree: BigInt!
|
||||
|
||||
"""Swap usage percentage"""
|
||||
percentSwapTotal: Float!
|
||||
}
|
||||
|
||||
type InfoMemory implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""Physical memory layout"""
|
||||
layout: [MemoryLayout!]!
|
||||
}
|
||||
|
||||
type InfoOs implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""Operating system platform"""
|
||||
platform: String
|
||||
|
||||
"""Linux distribution name"""
|
||||
distro: String
|
||||
|
||||
"""OS release version"""
|
||||
release: String
|
||||
|
||||
"""OS codename"""
|
||||
codename: String
|
||||
|
||||
"""Kernel version"""
|
||||
kernel: String
|
||||
|
||||
"""OS architecture"""
|
||||
arch: String
|
||||
|
||||
"""Hostname"""
|
||||
hostname: String
|
||||
|
||||
"""Fully qualified domain name"""
|
||||
fqdn: String
|
||||
|
||||
"""OS build identifier"""
|
||||
build: String
|
||||
|
||||
"""Service pack version"""
|
||||
servicepack: String
|
||||
|
||||
"""Boot time ISO string"""
|
||||
uptime: String
|
||||
|
||||
"""OS logo name"""
|
||||
logofile: String
|
||||
|
||||
"""OS serial number"""
|
||||
serial: String
|
||||
|
||||
"""OS started via UEFI"""
|
||||
uefi: Boolean
|
||||
}
|
||||
|
||||
type InfoSystem implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""System manufacturer"""
|
||||
manufacturer: String
|
||||
|
||||
"""System model"""
|
||||
model: String
|
||||
|
||||
"""System version"""
|
||||
version: String
|
||||
|
||||
"""System serial number"""
|
||||
serial: String
|
||||
|
||||
"""System UUID"""
|
||||
uuid: String
|
||||
|
||||
"""System SKU"""
|
||||
sku: String
|
||||
|
||||
"""Virtual machine flag"""
|
||||
virtual: Boolean
|
||||
}
|
||||
|
||||
type InfoBaseboard implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""Motherboard manufacturer"""
|
||||
manufacturer: String
|
||||
|
||||
"""Motherboard model"""
|
||||
model: String
|
||||
|
||||
"""Motherboard version"""
|
||||
version: String
|
||||
|
||||
"""Motherboard serial number"""
|
||||
serial: String
|
||||
|
||||
"""Motherboard asset tag"""
|
||||
assetTag: String
|
||||
|
||||
"""Maximum memory capacity in bytes"""
|
||||
memMax: Float
|
||||
|
||||
"""Number of memory slots"""
|
||||
memSlots: Float
|
||||
}
|
||||
|
||||
type InfoVersions implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""Kernel version"""
|
||||
kernel: String
|
||||
|
||||
"""OpenSSL version"""
|
||||
openssl: String
|
||||
|
||||
"""System OpenSSL version"""
|
||||
systemOpenssl: String
|
||||
|
||||
"""Node.js version"""
|
||||
node: String
|
||||
|
||||
"""V8 engine version"""
|
||||
v8: String
|
||||
|
||||
"""npm version"""
|
||||
npm: String
|
||||
|
||||
"""Yarn version"""
|
||||
yarn: String
|
||||
|
||||
"""pm2 version"""
|
||||
pm2: String
|
||||
|
||||
"""Gulp version"""
|
||||
gulp: String
|
||||
|
||||
"""Grunt version"""
|
||||
grunt: String
|
||||
|
||||
"""Git version"""
|
||||
git: String
|
||||
|
||||
"""tsc version"""
|
||||
tsc: String
|
||||
|
||||
"""MySQL version"""
|
||||
mysql: String
|
||||
|
||||
"""Redis version"""
|
||||
redis: String
|
||||
|
||||
"""MongoDB version"""
|
||||
mongodb: String
|
||||
|
||||
"""Apache version"""
|
||||
apache: String
|
||||
|
||||
"""nginx version"""
|
||||
nginx: String
|
||||
|
||||
"""PHP version"""
|
||||
php: String
|
||||
|
||||
"""Postfix version"""
|
||||
postfix: String
|
||||
|
||||
"""PostgreSQL version"""
|
||||
postgresql: String
|
||||
|
||||
"""Perl version"""
|
||||
perl: String
|
||||
|
||||
"""Python version"""
|
||||
python: String
|
||||
|
||||
"""Python3 version"""
|
||||
python3: String
|
||||
|
||||
"""pip version"""
|
||||
pip: String
|
||||
|
||||
"""pip3 version"""
|
||||
pip3: String
|
||||
|
||||
"""Java version"""
|
||||
java: String
|
||||
|
||||
"""gcc version"""
|
||||
gcc: String
|
||||
|
||||
"""VirtualBox version"""
|
||||
virtualbox: String
|
||||
|
||||
"""Docker version"""
|
||||
docker: String
|
||||
|
||||
"""Unraid version"""
|
||||
unraid: String
|
||||
}
|
||||
|
||||
type Info implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""Current server time"""
|
||||
time: DateTime!
|
||||
|
||||
"""Motherboard information"""
|
||||
baseboard: InfoBaseboard!
|
||||
|
||||
"""CPU information"""
|
||||
cpu: InfoCpu!
|
||||
|
||||
"""Device information"""
|
||||
devices: InfoDevices!
|
||||
|
||||
"""Display configuration"""
|
||||
display: InfoDisplay!
|
||||
|
||||
"""Machine ID"""
|
||||
machineId: ID
|
||||
|
||||
"""Memory information"""
|
||||
memory: InfoMemory!
|
||||
|
||||
"""Operating system information"""
|
||||
os: InfoOs!
|
||||
|
||||
"""System information"""
|
||||
system: InfoSystem!
|
||||
|
||||
"""Software versions"""
|
||||
versions: InfoVersions!
|
||||
}
|
||||
|
||||
type LogFile {
|
||||
"""Name of the log file"""
|
||||
name: String!
|
||||
@@ -1306,6 +1657,17 @@ type LogFileContent {
|
||||
startLine: Int
|
||||
}
|
||||
|
||||
"""System metrics including CPU and memory utilization"""
|
||||
type Metrics implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""Current CPU utilization metrics"""
|
||||
cpu: CpuUtilization
|
||||
|
||||
"""Current memory utilization metrics"""
|
||||
memory: MemoryUtilization
|
||||
}
|
||||
|
||||
type NotificationCounts {
|
||||
info: Int!
|
||||
warning: Int!
|
||||
@@ -1917,9 +2279,7 @@ type Query {
|
||||
"""All possible permissions for API keys"""
|
||||
apiKeyPossiblePermissions: [Permission!]!
|
||||
config: Config!
|
||||
display: Display!
|
||||
flash: Flash!
|
||||
info: Info!
|
||||
logFiles: [LogFile!]!
|
||||
logFile(path: String!, lines: Int, startLine: Int): LogFileContent!
|
||||
me: UserAccount!
|
||||
@@ -1947,6 +2307,7 @@ type Query {
|
||||
disks: [Disk!]!
|
||||
disk(id: PrefixedID!): Disk!
|
||||
rclone: RCloneBackupSettings!
|
||||
info: Info!
|
||||
settings: Settings!
|
||||
isSSOEnabled: Boolean!
|
||||
|
||||
@@ -1961,6 +2322,7 @@ type Query {
|
||||
|
||||
"""Validate an OIDC session token (internal use for CLI validation)"""
|
||||
validateOidcSession(token: String!): OidcSessionValidation!
|
||||
metrics: Metrics!
|
||||
upsDevices: [UPSDevice!]!
|
||||
upsDeviceById(id: String!): UPSDevice
|
||||
upsConfiguration: UPSConfiguration!
|
||||
@@ -2201,8 +2563,6 @@ input AccessUrlInput {
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
displaySubscription: Display!
|
||||
infoSubscription: Info!
|
||||
logFile(path: String!): LogFileContent!
|
||||
notificationAdded: Notification!
|
||||
notificationsOverview: NotificationOverview!
|
||||
@@ -2210,6 +2570,8 @@ type Subscription {
|
||||
serversSubscription: Server!
|
||||
parityHistorySubscription: ParityCheck!
|
||||
arraySubscription: UnraidArray!
|
||||
systemMetricsCpu: CpuUtilization!
|
||||
systemMetricsMemory: MemoryUtilization!
|
||||
upsUpdates: UPSDevice!
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ export const pubsub = new PubSub({ eventEmitter });
|
||||
* Create a pubsub subscription.
|
||||
* @param channel The pubsub channel to subscribe to.
|
||||
*/
|
||||
export const createSubscription = (channel: GRAPHQL_PUBSUB_CHANNEL) => {
|
||||
return pubsub.asyncIterableIterator(channel);
|
||||
export const createSubscription = <T = any>(
|
||||
channel: GRAPHQL_PUBSUB_CHANNEL
|
||||
): AsyncIterableIterator<T> => {
|
||||
return pubsub.asyncIterableIterator<T>(channel);
|
||||
};
|
||||
|
||||
17
api/src/core/utils/validation/enum-validator.ts
Normal file
17
api/src/core/utils/validation/enum-validator.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function isValidEnumValue<T extends Record<string, string | number>>(
|
||||
value: unknown,
|
||||
enumObject: T
|
||||
): value is T[keyof T] {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.values(enumObject).includes(value as T[keyof T]);
|
||||
}
|
||||
|
||||
export function validateEnumValue<T extends Record<string, string | number>>(
|
||||
value: unknown,
|
||||
enumObject: T
|
||||
): T[keyof T] | undefined {
|
||||
return isValidEnumValue(value, enumObject) ? (value as T[keyof T]) : undefined;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
|
||||
import { AuthZGuard } from 'nest-authz';
|
||||
@@ -23,6 +24,7 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u
|
||||
GlobalDepsModule,
|
||||
LegacyConfigModule,
|
||||
PubSubModule,
|
||||
ScheduleModule.forRoot(),
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
logger: apiLogger,
|
||||
|
||||
@@ -399,16 +399,6 @@ export enum AuthorizationRuleMode {
|
||||
OR = 'OR'
|
||||
}
|
||||
|
||||
export type Baseboard = Node & {
|
||||
__typename?: 'Baseboard';
|
||||
assetTag?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
manufacturer: Scalars['String']['output'];
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
version?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Capacity = {
|
||||
__typename?: 'Capacity';
|
||||
/** Free capacity */
|
||||
@@ -419,15 +409,6 @@ export type Capacity = {
|
||||
used: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type Case = Node & {
|
||||
__typename?: 'Case';
|
||||
base64?: Maybe<Scalars['String']['output']>;
|
||||
error?: Maybe<Scalars['String']['output']>;
|
||||
icon?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
url?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Cloud = {
|
||||
__typename?: 'Cloud';
|
||||
allowedOrigins: Array<Scalars['String']['output']>;
|
||||
@@ -539,6 +520,32 @@ export enum ContainerState {
|
||||
RUNNING = 'RUNNING'
|
||||
}
|
||||
|
||||
/** CPU load for a single core */
|
||||
export type CpuLoad = {
|
||||
__typename?: 'CpuLoad';
|
||||
/** The percentage of time the CPU was idle. */
|
||||
percentIdle: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU spent servicing hardware interrupts. */
|
||||
percentIrq: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU spent on low-priority (niced) user space processes. */
|
||||
percentNice: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU spent in kernel space. */
|
||||
percentSystem: Scalars['Float']['output'];
|
||||
/** The total CPU load on a single core, in percent. */
|
||||
percentTotal: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU spent in user space. */
|
||||
percentUser: Scalars['Float']['output'];
|
||||
};
|
||||
|
||||
export type CpuUtilization = Node & {
|
||||
__typename?: 'CpuUtilization';
|
||||
/** CPU load for each core */
|
||||
cpus: Array<CpuLoad>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Total CPU load in percent */
|
||||
percentTotal: Scalars['Float']['output'];
|
||||
};
|
||||
|
||||
export type CreateApiKeyInput = {
|
||||
description?: InputMaybe<Scalars['String']['input']>;
|
||||
name: Scalars['String']['input'];
|
||||
@@ -569,14 +576,6 @@ export type DeleteRCloneRemoteInput = {
|
||||
name: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type Devices = Node & {
|
||||
__typename?: 'Devices';
|
||||
gpu: Array<Gpu>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
pci: Array<Pci>;
|
||||
usb: Array<Usb>;
|
||||
};
|
||||
|
||||
export type Disk = Node & {
|
||||
__typename?: 'Disk';
|
||||
/** The number of bytes per sector */
|
||||
@@ -653,31 +652,6 @@ export enum DiskSmartStatus {
|
||||
UNKNOWN = 'UNKNOWN'
|
||||
}
|
||||
|
||||
export type Display = Node & {
|
||||
__typename?: 'Display';
|
||||
banner?: Maybe<Scalars['String']['output']>;
|
||||
case?: Maybe<Case>;
|
||||
critical?: Maybe<Scalars['Int']['output']>;
|
||||
dashapps?: Maybe<Scalars['String']['output']>;
|
||||
date?: Maybe<Scalars['String']['output']>;
|
||||
hot?: Maybe<Scalars['Int']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
locale?: Maybe<Scalars['String']['output']>;
|
||||
max?: Maybe<Scalars['Int']['output']>;
|
||||
number?: Maybe<Scalars['String']['output']>;
|
||||
resize?: Maybe<Scalars['Boolean']['output']>;
|
||||
scale?: Maybe<Scalars['Boolean']['output']>;
|
||||
tabs?: Maybe<Scalars['Boolean']['output']>;
|
||||
text?: Maybe<Scalars['Boolean']['output']>;
|
||||
theme?: Maybe<ThemeName>;
|
||||
total?: Maybe<Scalars['Boolean']['output']>;
|
||||
unit?: Maybe<Temperature>;
|
||||
usage?: Maybe<Scalars['Boolean']['output']>;
|
||||
users?: Maybe<Scalars['String']['output']>;
|
||||
warning?: Maybe<Scalars['Int']['output']>;
|
||||
wwn?: Maybe<Scalars['Boolean']['output']>;
|
||||
};
|
||||
|
||||
export type Docker = Node & {
|
||||
__typename?: 'Docker';
|
||||
containers: Array<DockerContainer>;
|
||||
@@ -792,80 +766,340 @@ export type FlashBackupStatus = {
|
||||
status: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type Gpu = Node & {
|
||||
__typename?: 'Gpu';
|
||||
blacklisted: Scalars['Boolean']['output'];
|
||||
class: Scalars['String']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
productid: Scalars['String']['output'];
|
||||
type: Scalars['String']['output'];
|
||||
typeid: Scalars['String']['output'];
|
||||
vendorname: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type Info = Node & {
|
||||
__typename?: 'Info';
|
||||
/** Count of docker containers */
|
||||
apps: InfoApps;
|
||||
baseboard: Baseboard;
|
||||
/** Motherboard information */
|
||||
baseboard: InfoBaseboard;
|
||||
/** CPU information */
|
||||
cpu: InfoCpu;
|
||||
devices: Devices;
|
||||
display: Display;
|
||||
/** Device information */
|
||||
devices: InfoDevices;
|
||||
/** Display configuration */
|
||||
display: InfoDisplay;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Machine ID */
|
||||
machineId?: Maybe<Scalars['PrefixedID']['output']>;
|
||||
machineId?: Maybe<Scalars['ID']['output']>;
|
||||
/** Memory information */
|
||||
memory: InfoMemory;
|
||||
os: Os;
|
||||
system: System;
|
||||
/** Operating system information */
|
||||
os: InfoOs;
|
||||
/** System information */
|
||||
system: InfoSystem;
|
||||
/** Current server time */
|
||||
time: Scalars['DateTime']['output'];
|
||||
versions: Versions;
|
||||
/** Software versions */
|
||||
versions: InfoVersions;
|
||||
};
|
||||
|
||||
export type InfoApps = Node & {
|
||||
__typename?: 'InfoApps';
|
||||
export type InfoBaseboard = Node & {
|
||||
__typename?: 'InfoBaseboard';
|
||||
/** Motherboard asset tag */
|
||||
assetTag?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** How many docker containers are installed */
|
||||
installed: Scalars['Int']['output'];
|
||||
/** How many docker containers are running */
|
||||
started: Scalars['Int']['output'];
|
||||
/** Motherboard manufacturer */
|
||||
manufacturer?: Maybe<Scalars['String']['output']>;
|
||||
/** Maximum memory capacity in bytes */
|
||||
memMax?: Maybe<Scalars['Float']['output']>;
|
||||
/** Number of memory slots */
|
||||
memSlots?: Maybe<Scalars['Float']['output']>;
|
||||
/** Motherboard model */
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
/** Motherboard serial number */
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
/** Motherboard version */
|
||||
version?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type InfoCpu = Node & {
|
||||
__typename?: 'InfoCpu';
|
||||
brand: Scalars['String']['output'];
|
||||
cache: Scalars['JSON']['output'];
|
||||
cores: Scalars['Int']['output'];
|
||||
family: Scalars['String']['output'];
|
||||
flags: Array<Scalars['String']['output']>;
|
||||
/** CPU brand name */
|
||||
brand?: Maybe<Scalars['String']['output']>;
|
||||
/** CPU cache information */
|
||||
cache?: Maybe<Scalars['JSON']['output']>;
|
||||
/** Number of CPU cores */
|
||||
cores?: Maybe<Scalars['Int']['output']>;
|
||||
/** CPU family */
|
||||
family?: Maybe<Scalars['String']['output']>;
|
||||
/** CPU feature flags */
|
||||
flags?: Maybe<Array<Scalars['String']['output']>>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
manufacturer: Scalars['String']['output'];
|
||||
model: Scalars['String']['output'];
|
||||
processors: Scalars['Int']['output'];
|
||||
revision: Scalars['String']['output'];
|
||||
socket: Scalars['String']['output'];
|
||||
speed: Scalars['Float']['output'];
|
||||
speedmax: Scalars['Float']['output'];
|
||||
speedmin: Scalars['Float']['output'];
|
||||
stepping: Scalars['Int']['output'];
|
||||
threads: Scalars['Int']['output'];
|
||||
vendor: Scalars['String']['output'];
|
||||
/** CPU manufacturer */
|
||||
manufacturer?: Maybe<Scalars['String']['output']>;
|
||||
/** CPU model */
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
/** Number of physical processors */
|
||||
processors?: Maybe<Scalars['Int']['output']>;
|
||||
/** CPU revision */
|
||||
revision?: Maybe<Scalars['String']['output']>;
|
||||
/** CPU socket type */
|
||||
socket?: Maybe<Scalars['String']['output']>;
|
||||
/** Current CPU speed in GHz */
|
||||
speed?: Maybe<Scalars['Float']['output']>;
|
||||
/** Maximum CPU speed in GHz */
|
||||
speedmax?: Maybe<Scalars['Float']['output']>;
|
||||
/** Minimum CPU speed in GHz */
|
||||
speedmin?: Maybe<Scalars['Float']['output']>;
|
||||
/** CPU stepping */
|
||||
stepping?: Maybe<Scalars['Int']['output']>;
|
||||
/** Number of CPU threads */
|
||||
threads?: Maybe<Scalars['Int']['output']>;
|
||||
/** CPU vendor */
|
||||
vendor?: Maybe<Scalars['String']['output']>;
|
||||
/** CPU voltage */
|
||||
voltage?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type InfoDevices = Node & {
|
||||
__typename?: 'InfoDevices';
|
||||
/** List of GPU devices */
|
||||
gpu?: Maybe<Array<InfoGpu>>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** List of network interfaces */
|
||||
network?: Maybe<Array<InfoNetwork>>;
|
||||
/** List of PCI devices */
|
||||
pci?: Maybe<Array<InfoPci>>;
|
||||
/** List of USB devices */
|
||||
usb?: Maybe<Array<InfoUsb>>;
|
||||
};
|
||||
|
||||
export type InfoDisplay = Node & {
|
||||
__typename?: 'InfoDisplay';
|
||||
/** Case display configuration */
|
||||
case: InfoDisplayCase;
|
||||
/** Critical temperature threshold */
|
||||
critical: Scalars['Int']['output'];
|
||||
/** Hot temperature threshold */
|
||||
hot: Scalars['Int']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Locale setting */
|
||||
locale?: Maybe<Scalars['String']['output']>;
|
||||
/** Maximum temperature threshold */
|
||||
max?: Maybe<Scalars['Int']['output']>;
|
||||
/** Enable UI resize */
|
||||
resize: Scalars['Boolean']['output'];
|
||||
/** Enable UI scaling */
|
||||
scale: Scalars['Boolean']['output'];
|
||||
/** Show tabs in UI */
|
||||
tabs: Scalars['Boolean']['output'];
|
||||
/** Show text labels */
|
||||
text: Scalars['Boolean']['output'];
|
||||
/** UI theme name */
|
||||
theme: ThemeName;
|
||||
/** Show totals */
|
||||
total: Scalars['Boolean']['output'];
|
||||
/** Temperature unit (C or F) */
|
||||
unit: Temperature;
|
||||
/** Show usage statistics */
|
||||
usage: Scalars['Boolean']['output'];
|
||||
/** Warning temperature threshold */
|
||||
warning: Scalars['Int']['output'];
|
||||
/** Show WWN identifiers */
|
||||
wwn: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type InfoDisplayCase = Node & {
|
||||
__typename?: 'InfoDisplayCase';
|
||||
/** Base64 encoded case image */
|
||||
base64: Scalars['String']['output'];
|
||||
/** Error message if any */
|
||||
error: Scalars['String']['output'];
|
||||
/** Case icon identifier */
|
||||
icon: Scalars['String']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Case image URL */
|
||||
url: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type InfoGpu = Node & {
|
||||
__typename?: 'InfoGpu';
|
||||
/** Whether GPU is blacklisted */
|
||||
blacklisted: Scalars['Boolean']['output'];
|
||||
/** Device class */
|
||||
class: Scalars['String']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Product ID */
|
||||
productid: Scalars['String']['output'];
|
||||
/** GPU type/manufacturer */
|
||||
type: Scalars['String']['output'];
|
||||
/** GPU type identifier */
|
||||
typeid: Scalars['String']['output'];
|
||||
/** Vendor name */
|
||||
vendorname?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type InfoMemory = Node & {
|
||||
__typename?: 'InfoMemory';
|
||||
active: Scalars['BigInt']['output'];
|
||||
available: Scalars['BigInt']['output'];
|
||||
buffcache: Scalars['BigInt']['output'];
|
||||
free: Scalars['BigInt']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Physical memory layout */
|
||||
layout: Array<MemoryLayout>;
|
||||
max: Scalars['BigInt']['output'];
|
||||
swapfree: Scalars['BigInt']['output'];
|
||||
swaptotal: Scalars['BigInt']['output'];
|
||||
swapused: Scalars['BigInt']['output'];
|
||||
total: Scalars['BigInt']['output'];
|
||||
used: Scalars['BigInt']['output'];
|
||||
};
|
||||
|
||||
export type InfoNetwork = Node & {
|
||||
__typename?: 'InfoNetwork';
|
||||
/** DHCP enabled flag */
|
||||
dhcp?: Maybe<Scalars['Boolean']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Network interface name */
|
||||
iface: Scalars['String']['output'];
|
||||
/** MAC address */
|
||||
mac?: Maybe<Scalars['String']['output']>;
|
||||
/** Network interface model */
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
/** Network speed */
|
||||
speed?: Maybe<Scalars['String']['output']>;
|
||||
/** Network vendor */
|
||||
vendor?: Maybe<Scalars['String']['output']>;
|
||||
/** Virtual interface flag */
|
||||
virtual?: Maybe<Scalars['Boolean']['output']>;
|
||||
};
|
||||
|
||||
export type InfoOs = Node & {
|
||||
__typename?: 'InfoOs';
|
||||
/** OS architecture */
|
||||
arch?: Maybe<Scalars['String']['output']>;
|
||||
/** OS build identifier */
|
||||
build?: Maybe<Scalars['String']['output']>;
|
||||
/** OS codename */
|
||||
codename?: Maybe<Scalars['String']['output']>;
|
||||
/** Linux distribution name */
|
||||
distro?: Maybe<Scalars['String']['output']>;
|
||||
/** Fully qualified domain name */
|
||||
fqdn?: Maybe<Scalars['String']['output']>;
|
||||
/** Hostname */
|
||||
hostname?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Kernel version */
|
||||
kernel?: Maybe<Scalars['String']['output']>;
|
||||
/** OS logo name */
|
||||
logofile?: Maybe<Scalars['String']['output']>;
|
||||
/** Operating system platform */
|
||||
platform?: Maybe<Scalars['String']['output']>;
|
||||
/** OS release version */
|
||||
release?: Maybe<Scalars['String']['output']>;
|
||||
/** OS serial number */
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
/** Service pack version */
|
||||
servicepack?: Maybe<Scalars['String']['output']>;
|
||||
/** OS started via UEFI */
|
||||
uefi?: Maybe<Scalars['Boolean']['output']>;
|
||||
/** Boot time ISO string */
|
||||
uptime?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type InfoPci = Node & {
|
||||
__typename?: 'InfoPci';
|
||||
/** Blacklisted status */
|
||||
blacklisted: Scalars['String']['output'];
|
||||
/** Device class */
|
||||
class: Scalars['String']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Product ID */
|
||||
productid: Scalars['String']['output'];
|
||||
/** Product name */
|
||||
productname?: Maybe<Scalars['String']['output']>;
|
||||
/** Device type/manufacturer */
|
||||
type: Scalars['String']['output'];
|
||||
/** Type identifier */
|
||||
typeid: Scalars['String']['output'];
|
||||
/** Vendor ID */
|
||||
vendorid: Scalars['String']['output'];
|
||||
/** Vendor name */
|
||||
vendorname?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type InfoSystem = Node & {
|
||||
__typename?: 'InfoSystem';
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** System manufacturer */
|
||||
manufacturer?: Maybe<Scalars['String']['output']>;
|
||||
/** System model */
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
/** System serial number */
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
/** System SKU */
|
||||
sku?: Maybe<Scalars['String']['output']>;
|
||||
/** System UUID */
|
||||
uuid?: Maybe<Scalars['String']['output']>;
|
||||
/** System version */
|
||||
version?: Maybe<Scalars['String']['output']>;
|
||||
/** Virtual machine flag */
|
||||
virtual?: Maybe<Scalars['Boolean']['output']>;
|
||||
};
|
||||
|
||||
export type InfoUsb = Node & {
|
||||
__typename?: 'InfoUsb';
|
||||
/** USB bus number */
|
||||
bus?: Maybe<Scalars['String']['output']>;
|
||||
/** USB device number */
|
||||
device?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** USB device name */
|
||||
name: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type InfoVersions = Node & {
|
||||
__typename?: 'InfoVersions';
|
||||
/** Apache version */
|
||||
apache?: Maybe<Scalars['String']['output']>;
|
||||
/** Docker version */
|
||||
docker?: Maybe<Scalars['String']['output']>;
|
||||
/** gcc version */
|
||||
gcc?: Maybe<Scalars['String']['output']>;
|
||||
/** Git version */
|
||||
git?: Maybe<Scalars['String']['output']>;
|
||||
/** Grunt version */
|
||||
grunt?: Maybe<Scalars['String']['output']>;
|
||||
/** Gulp version */
|
||||
gulp?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Java version */
|
||||
java?: Maybe<Scalars['String']['output']>;
|
||||
/** Kernel version */
|
||||
kernel?: Maybe<Scalars['String']['output']>;
|
||||
/** MongoDB version */
|
||||
mongodb?: Maybe<Scalars['String']['output']>;
|
||||
/** MySQL version */
|
||||
mysql?: Maybe<Scalars['String']['output']>;
|
||||
/** nginx version */
|
||||
nginx?: Maybe<Scalars['String']['output']>;
|
||||
/** Node.js version */
|
||||
node?: Maybe<Scalars['String']['output']>;
|
||||
/** npm version */
|
||||
npm?: Maybe<Scalars['String']['output']>;
|
||||
/** OpenSSL version */
|
||||
openssl?: Maybe<Scalars['String']['output']>;
|
||||
/** Perl version */
|
||||
perl?: Maybe<Scalars['String']['output']>;
|
||||
/** PHP version */
|
||||
php?: Maybe<Scalars['String']['output']>;
|
||||
/** pip version */
|
||||
pip?: Maybe<Scalars['String']['output']>;
|
||||
/** pip3 version */
|
||||
pip3?: Maybe<Scalars['String']['output']>;
|
||||
/** pm2 version */
|
||||
pm2?: Maybe<Scalars['String']['output']>;
|
||||
/** Postfix version */
|
||||
postfix?: Maybe<Scalars['String']['output']>;
|
||||
/** PostgreSQL version */
|
||||
postgresql?: Maybe<Scalars['String']['output']>;
|
||||
/** Python version */
|
||||
python?: Maybe<Scalars['String']['output']>;
|
||||
/** Python3 version */
|
||||
python3?: Maybe<Scalars['String']['output']>;
|
||||
/** Redis version */
|
||||
redis?: Maybe<Scalars['String']['output']>;
|
||||
/** System OpenSSL version */
|
||||
systemOpenssl?: Maybe<Scalars['String']['output']>;
|
||||
/** tsc version */
|
||||
tsc?: Maybe<Scalars['String']['output']>;
|
||||
/** Unraid version */
|
||||
unraid?: Maybe<Scalars['String']['output']>;
|
||||
/** V8 engine version */
|
||||
v8?: Maybe<Scalars['String']['output']>;
|
||||
/** VirtualBox version */
|
||||
virtualbox?: Maybe<Scalars['String']['output']>;
|
||||
/** Yarn version */
|
||||
yarn?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type InitiateFlashBackupInput = {
|
||||
@@ -911,20 +1145,68 @@ export type LogFileContent = {
|
||||
|
||||
export type MemoryLayout = Node & {
|
||||
__typename?: 'MemoryLayout';
|
||||
/** Memory bank location (e.g., BANK 0) */
|
||||
bank?: Maybe<Scalars['String']['output']>;
|
||||
/** Memory clock speed in MHz */
|
||||
clockSpeed?: Maybe<Scalars['Int']['output']>;
|
||||
/** Form factor (e.g., DIMM, SODIMM) */
|
||||
formFactor?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Memory manufacturer */
|
||||
manufacturer?: Maybe<Scalars['String']['output']>;
|
||||
/** Part number of the memory module */
|
||||
partNum?: Maybe<Scalars['String']['output']>;
|
||||
/** Serial number of the memory module */
|
||||
serialNum?: Maybe<Scalars['String']['output']>;
|
||||
/** Memory module size in bytes */
|
||||
size: Scalars['BigInt']['output'];
|
||||
/** Memory type (e.g., DDR4, DDR5) */
|
||||
type?: Maybe<Scalars['String']['output']>;
|
||||
/** Configured voltage in millivolts */
|
||||
voltageConfigured?: Maybe<Scalars['Int']['output']>;
|
||||
/** Maximum voltage in millivolts */
|
||||
voltageMax?: Maybe<Scalars['Int']['output']>;
|
||||
/** Minimum voltage in millivolts */
|
||||
voltageMin?: Maybe<Scalars['Int']['output']>;
|
||||
};
|
||||
|
||||
export type MemoryUtilization = Node & {
|
||||
__typename?: 'MemoryUtilization';
|
||||
/** Active memory in bytes */
|
||||
active: Scalars['BigInt']['output'];
|
||||
/** Available memory in bytes */
|
||||
available: Scalars['BigInt']['output'];
|
||||
/** Buffer/cache memory in bytes */
|
||||
buffcache: Scalars['BigInt']['output'];
|
||||
/** Free memory in bytes */
|
||||
free: Scalars['BigInt']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Swap usage percentage */
|
||||
percentSwapTotal: Scalars['Float']['output'];
|
||||
/** Memory usage percentage */
|
||||
percentTotal: Scalars['Float']['output'];
|
||||
/** Free swap memory in bytes */
|
||||
swapFree: Scalars['BigInt']['output'];
|
||||
/** Total swap memory in bytes */
|
||||
swapTotal: Scalars['BigInt']['output'];
|
||||
/** Used swap memory in bytes */
|
||||
swapUsed: Scalars['BigInt']['output'];
|
||||
/** Total system memory in bytes */
|
||||
total: Scalars['BigInt']['output'];
|
||||
/** Used memory in bytes */
|
||||
used: Scalars['BigInt']['output'];
|
||||
};
|
||||
|
||||
/** System metrics including CPU and memory utilization */
|
||||
export type Metrics = Node & {
|
||||
__typename?: 'Metrics';
|
||||
/** Current CPU utilization metrics */
|
||||
cpu?: Maybe<CpuUtilization>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Current memory utilization metrics */
|
||||
memory?: Maybe<MemoryUtilization>;
|
||||
};
|
||||
|
||||
/** The status of the minigraph */
|
||||
export enum MinigraphStatus {
|
||||
CONNECTED = 'CONNECTED',
|
||||
@@ -1237,23 +1519,6 @@ export type OrganizerResource = {
|
||||
type: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type Os = Node & {
|
||||
__typename?: 'Os';
|
||||
arch?: Maybe<Scalars['String']['output']>;
|
||||
build?: Maybe<Scalars['String']['output']>;
|
||||
codename?: Maybe<Scalars['String']['output']>;
|
||||
codepage?: Maybe<Scalars['String']['output']>;
|
||||
distro?: Maybe<Scalars['String']['output']>;
|
||||
hostname?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
kernel?: Maybe<Scalars['String']['output']>;
|
||||
logofile?: Maybe<Scalars['String']['output']>;
|
||||
platform?: Maybe<Scalars['String']['output']>;
|
||||
release?: Maybe<Scalars['String']['output']>;
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
uptime?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Owner = {
|
||||
__typename?: 'Owner';
|
||||
avatar: Scalars['String']['output'];
|
||||
@@ -1302,19 +1567,6 @@ export type ParityCheckMutationsStartArgs = {
|
||||
correct: Scalars['Boolean']['input'];
|
||||
};
|
||||
|
||||
export type Pci = Node & {
|
||||
__typename?: 'Pci';
|
||||
blacklisted?: Maybe<Scalars['String']['output']>;
|
||||
class?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
productid?: Maybe<Scalars['String']['output']>;
|
||||
productname?: Maybe<Scalars['String']['output']>;
|
||||
type?: Maybe<Scalars['String']['output']>;
|
||||
typeid?: Maybe<Scalars['String']['output']>;
|
||||
vendorid?: Maybe<Scalars['String']['output']>;
|
||||
vendorname?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Permission = {
|
||||
__typename?: 'Permission';
|
||||
actions: Array<Scalars['String']['output']>;
|
||||
@@ -1385,7 +1637,6 @@ export type Query = {
|
||||
customization?: Maybe<Customization>;
|
||||
disk: Disk;
|
||||
disks: Array<Disk>;
|
||||
display: Display;
|
||||
docker: Docker;
|
||||
flash: Flash;
|
||||
info: Info;
|
||||
@@ -1394,6 +1645,7 @@ export type Query = {
|
||||
logFile: LogFileContent;
|
||||
logFiles: Array<LogFile>;
|
||||
me: UserAccount;
|
||||
metrics: Metrics;
|
||||
network: Network;
|
||||
/** Get all notifications */
|
||||
notifications: Notifications;
|
||||
@@ -1743,14 +1995,14 @@ export type SsoSettings = Node & {
|
||||
export type Subscription = {
|
||||
__typename?: 'Subscription';
|
||||
arraySubscription: UnraidArray;
|
||||
displaySubscription: Display;
|
||||
infoSubscription: Info;
|
||||
logFile: LogFileContent;
|
||||
notificationAdded: Notification;
|
||||
notificationsOverview: NotificationOverview;
|
||||
ownerSubscription: Owner;
|
||||
parityHistorySubscription: ParityCheck;
|
||||
serversSubscription: Server;
|
||||
systemMetricsCpu: CpuUtilization;
|
||||
systemMetricsMemory: MemoryUtilization;
|
||||
upsUpdates: UpsDevice;
|
||||
};
|
||||
|
||||
@@ -1759,21 +2011,10 @@ export type SubscriptionLogFileArgs = {
|
||||
path: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type System = Node & {
|
||||
__typename?: 'System';
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
manufacturer?: Maybe<Scalars['String']['output']>;
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
sku?: Maybe<Scalars['String']['output']>;
|
||||
uuid?: Maybe<Scalars['String']['output']>;
|
||||
version?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
/** Temperature unit (Celsius or Fahrenheit) */
|
||||
/** Temperature unit */
|
||||
export enum Temperature {
|
||||
C = 'C',
|
||||
F = 'F'
|
||||
CELSIUS = 'CELSIUS',
|
||||
FAHRENHEIT = 'FAHRENHEIT'
|
||||
}
|
||||
|
||||
export type Theme = {
|
||||
@@ -1985,12 +2226,6 @@ export type Uptime = {
|
||||
timestamp?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Usb = Node & {
|
||||
__typename?: 'Usb';
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type UserAccount = Node & {
|
||||
__typename?: 'UserAccount';
|
||||
/** A description of the user */
|
||||
@@ -2168,37 +2403,6 @@ export type Vars = Node & {
|
||||
workgroup?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Versions = Node & {
|
||||
__typename?: 'Versions';
|
||||
apache?: Maybe<Scalars['String']['output']>;
|
||||
docker?: Maybe<Scalars['String']['output']>;
|
||||
gcc?: Maybe<Scalars['String']['output']>;
|
||||
git?: Maybe<Scalars['String']['output']>;
|
||||
grunt?: Maybe<Scalars['String']['output']>;
|
||||
gulp?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
kernel?: Maybe<Scalars['String']['output']>;
|
||||
mongodb?: Maybe<Scalars['String']['output']>;
|
||||
mysql?: Maybe<Scalars['String']['output']>;
|
||||
nginx?: Maybe<Scalars['String']['output']>;
|
||||
node?: Maybe<Scalars['String']['output']>;
|
||||
npm?: Maybe<Scalars['String']['output']>;
|
||||
openssl?: Maybe<Scalars['String']['output']>;
|
||||
perl?: Maybe<Scalars['String']['output']>;
|
||||
php?: Maybe<Scalars['String']['output']>;
|
||||
pm2?: Maybe<Scalars['String']['output']>;
|
||||
postfix?: Maybe<Scalars['String']['output']>;
|
||||
postgresql?: Maybe<Scalars['String']['output']>;
|
||||
python?: Maybe<Scalars['String']['output']>;
|
||||
redis?: Maybe<Scalars['String']['output']>;
|
||||
systemOpenssl?: Maybe<Scalars['String']['output']>;
|
||||
systemOpensslLib?: Maybe<Scalars['String']['output']>;
|
||||
tsc?: Maybe<Scalars['String']['output']>;
|
||||
unraid?: Maybe<Scalars['String']['output']>;
|
||||
v8?: Maybe<Scalars['String']['output']>;
|
||||
yarn?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type VmDomain = Node & {
|
||||
__typename?: 'VmDomain';
|
||||
/** The unique identifier for the vm (uuid) */
|
||||
@@ -2349,7 +2553,7 @@ export type GetSsoUsersQuery = { __typename?: 'Query', settings: { __typename?:
|
||||
export type SystemReportQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type SystemReportQuery = { __typename?: 'Query', info: { __typename?: 'Info', id: any, machineId?: any | null, system: { __typename?: 'System', manufacturer?: string | null, model?: string | null, version?: string | null, sku?: string | null, serial?: string | null, uuid?: string | null }, versions: { __typename?: 'Versions', unraid?: string | null, kernel?: string | null, openssl?: string | null } }, config: { __typename?: 'Config', id: any, valid?: boolean | null, error?: string | null }, server?: { __typename?: 'Server', id: any, name: string } | null };
|
||||
export type SystemReportQuery = { __typename?: 'Query', info: { __typename?: 'Info', id: any, machineId?: string | null, system: { __typename?: 'InfoSystem', manufacturer?: string | null, model?: string | null, version?: string | null, sku?: string | null, serial?: string | null, uuid?: string | null }, versions: { __typename?: 'InfoVersions', unraid?: string | null, kernel?: string | null, openssl?: string | null } }, config: { __typename?: 'Config', id: any, valid?: boolean | null, error?: string | null }, server?: { __typename?: 'Server', id: any, name: string } | null };
|
||||
|
||||
export type ConnectStatusQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { LogRotateService } from '@app/unraid-api/cron/log-rotate.service.js';
|
||||
import { WriteFlashFileService } from '@app/unraid-api/cron/write-flash-file.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot()],
|
||||
imports: [],
|
||||
providers: [WriteFlashFileService, LogRotateService],
|
||||
})
|
||||
export class CronModule {}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Test } from '@nestjs/testing';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DisplayResolver } from '@app/unraid-api/graph/resolvers/display/display.resolver.js';
|
||||
import { DisplayService } from '@app/unraid-api/graph/resolvers/display/display.service.js';
|
||||
import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js';
|
||||
|
||||
// Mock the pubsub module
|
||||
vi.mock('@app/core/pubsub.js', () => ({
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.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/display/display.model.js';
|
||||
import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js';
|
||||
|
||||
@Resolver(() => Display)
|
||||
export class DisplayResolver {
|
||||
|
||||
93
api/src/unraid-api/graph/resolvers/info/cpu/cpu.model.ts
Normal file
93
api/src/unraid-api/graph/resolvers/info/cpu/cpu.model.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Field, Float, Int, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { Node } from '@unraid/shared/graphql.model.js';
|
||||
import { GraphQLJSON } from 'graphql-scalars';
|
||||
|
||||
@ObjectType({ description: 'CPU load for a single core' })
|
||||
export class CpuLoad {
|
||||
@Field(() => Float, { description: 'The total CPU load on a single core, in percent.' })
|
||||
percentTotal!: number;
|
||||
|
||||
@Field(() => Float, { description: 'The percentage of time the CPU spent in user space.' })
|
||||
percentUser!: number;
|
||||
|
||||
@Field(() => Float, { description: 'The percentage of time the CPU spent in kernel space.' })
|
||||
percentSystem!: number;
|
||||
|
||||
@Field(() => Float, {
|
||||
description:
|
||||
'The percentage of time the CPU spent on low-priority (niced) user space processes.',
|
||||
})
|
||||
percentNice!: number;
|
||||
|
||||
@Field(() => Float, { description: 'The percentage of time the CPU was idle.' })
|
||||
percentIdle!: number;
|
||||
|
||||
@Field(() => Float, {
|
||||
description: 'The percentage of time the CPU spent servicing hardware interrupts.',
|
||||
})
|
||||
percentIrq!: number;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class CpuUtilization extends Node {
|
||||
@Field(() => Float, { description: 'Total CPU load in percent' })
|
||||
percentTotal!: number;
|
||||
|
||||
@Field(() => [CpuLoad], { description: 'CPU load for each core' })
|
||||
cpus!: CpuLoad[];
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoCpu extends Node {
|
||||
@Field(() => String, { nullable: true, description: 'CPU manufacturer' })
|
||||
manufacturer?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'CPU brand name' })
|
||||
brand?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'CPU vendor' })
|
||||
vendor?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'CPU family' })
|
||||
family?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'CPU model' })
|
||||
model?: string;
|
||||
|
||||
@Field(() => Int, { nullable: true, description: 'CPU stepping' })
|
||||
stepping?: number;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'CPU revision' })
|
||||
revision?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'CPU voltage' })
|
||||
voltage?: string;
|
||||
|
||||
@Field(() => Float, { nullable: true, description: 'Current CPU speed in GHz' })
|
||||
speed?: number;
|
||||
|
||||
@Field(() => Float, { nullable: true, description: 'Minimum CPU speed in GHz' })
|
||||
speedmin?: number;
|
||||
|
||||
@Field(() => Float, { nullable: true, description: 'Maximum CPU speed in GHz' })
|
||||
speedmax?: number;
|
||||
|
||||
@Field(() => Int, { nullable: true, description: 'Number of CPU threads' })
|
||||
threads?: number;
|
||||
|
||||
@Field(() => Int, { nullable: true, description: 'Number of CPU cores' })
|
||||
cores?: number;
|
||||
|
||||
@Field(() => Int, { nullable: true, description: 'Number of physical processors' })
|
||||
processors?: number;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'CPU socket type' })
|
||||
socket?: string;
|
||||
|
||||
@Field(() => GraphQLJSON, { nullable: true, description: 'CPU cache information' })
|
||||
cache?: Record<string, any>;
|
||||
|
||||
@Field(() => [String], { nullable: true, description: 'CPU feature flags' })
|
||||
flags?: string[];
|
||||
}
|
||||
43
api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.ts
Normal file
43
api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { cpu, cpuFlags, currentLoad } from 'systeminformation';
|
||||
|
||||
import { CpuUtilization, InfoCpu } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js';
|
||||
|
||||
@Injectable()
|
||||
export class CpuService {
|
||||
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 generateCpuLoad(): Promise<CpuUtilization> {
|
||||
const loadData = await currentLoad();
|
||||
|
||||
return {
|
||||
id: 'info/cpu-load',
|
||||
percentTotal: loadData.currentLoad,
|
||||
cpus: loadData.cpus.map((cpu) => ({
|
||||
percentTotal: cpu.load,
|
||||
percentUser: cpu.loadUser,
|
||||
percentSystem: cpu.loadSystem,
|
||||
percentNice: cpu.loadNice,
|
||||
percentIdle: cpu.loadIdle,
|
||||
percentIrq: cpu.loadIrq,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
102
api/src/unraid-api/graph/resolvers/info/devices/devices.model.ts
Normal file
102
api/src/unraid-api/graph/resolvers/info/devices/devices.model.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { Node } from '@unraid/shared/graphql.model.js';
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoGpu extends Node {
|
||||
@Field(() => String, { description: 'GPU type/manufacturer' })
|
||||
type!: string;
|
||||
|
||||
@Field(() => String, { description: 'GPU type identifier' })
|
||||
typeid!: string;
|
||||
|
||||
@Field(() => Boolean, { description: 'Whether GPU is blacklisted' })
|
||||
blacklisted!: boolean;
|
||||
|
||||
@Field(() => String, { description: 'Device class' })
|
||||
class!: string;
|
||||
|
||||
@Field(() => String, { description: 'Product ID' })
|
||||
productid!: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Vendor name' })
|
||||
vendorname?: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoNetwork extends Node {
|
||||
@Field(() => String, { description: 'Network interface name' })
|
||||
iface!: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Network interface model' })
|
||||
model?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Network vendor' })
|
||||
vendor?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'MAC address' })
|
||||
mac?: string;
|
||||
|
||||
@Field(() => Boolean, { nullable: true, description: 'Virtual interface flag' })
|
||||
virtual?: boolean;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Network speed' })
|
||||
speed?: string;
|
||||
|
||||
@Field(() => Boolean, { nullable: true, description: 'DHCP enabled flag' })
|
||||
dhcp?: boolean;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoPci extends Node {
|
||||
@Field(() => String, { description: 'Device type/manufacturer' })
|
||||
type!: string;
|
||||
|
||||
@Field(() => String, { description: 'Type identifier' })
|
||||
typeid!: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Vendor name' })
|
||||
vendorname?: string;
|
||||
|
||||
@Field(() => String, { description: 'Vendor ID' })
|
||||
vendorid!: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Product name' })
|
||||
productname?: string;
|
||||
|
||||
@Field(() => String, { description: 'Product ID' })
|
||||
productid!: string;
|
||||
|
||||
@Field(() => String, { description: 'Blacklisted status' })
|
||||
blacklisted!: string;
|
||||
|
||||
@Field(() => String, { description: 'Device class' })
|
||||
class!: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoUsb extends Node {
|
||||
@Field(() => String, { description: 'USB device name' })
|
||||
name!: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'USB bus number' })
|
||||
bus?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'USB device number' })
|
||||
device?: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoDevices extends Node {
|
||||
@Field(() => [InfoGpu], { nullable: true, description: 'List of GPU devices' })
|
||||
gpu?: InfoGpu[];
|
||||
|
||||
@Field(() => [InfoNetwork], { nullable: true, description: 'List of network interfaces' })
|
||||
network?: InfoNetwork[];
|
||||
|
||||
@Field(() => [InfoPci], { nullable: true, description: 'List of PCI devices' })
|
||||
pci?: InfoPci[];
|
||||
|
||||
@Field(() => [InfoUsb], { nullable: true, description: 'List of USB devices' })
|
||||
usb?: InfoUsb[];
|
||||
}
|
||||
@@ -3,8 +3,8 @@ 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';
|
||||
import { DevicesResolver } from '@app/unraid-api/graph/resolvers/info/devices/devices.resolver.js';
|
||||
import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices/devices.service.js';
|
||||
|
||||
describe('DevicesResolver', () => {
|
||||
let resolver: DevicesResolver;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
InfoDevices,
|
||||
InfoGpu,
|
||||
InfoNetwork,
|
||||
InfoPci,
|
||||
InfoUsb,
|
||||
} from '@app/unraid-api/graph/resolvers/info/devices/devices.model.js';
|
||||
import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices/devices.service.js';
|
||||
|
||||
@Resolver(() => InfoDevices)
|
||||
export class DevicesResolver {
|
||||
constructor(private readonly devicesService: DevicesService) {}
|
||||
|
||||
@ResolveField(() => [InfoGpu])
|
||||
public async gpu(): Promise<InfoGpu[]> {
|
||||
return this.devicesService.generateGpu();
|
||||
}
|
||||
|
||||
@ResolveField(() => [InfoNetwork])
|
||||
public async network(): Promise<InfoNetwork[]> {
|
||||
return this.devicesService.generateNetwork();
|
||||
}
|
||||
|
||||
@ResolveField(() => [InfoPci])
|
||||
public async pci(): Promise<InfoPci[]> {
|
||||
return this.devicesService.generatePci();
|
||||
}
|
||||
|
||||
@ResolveField(() => [InfoUsb])
|
||||
public async usb(): Promise<InfoUsb[]> {
|
||||
return this.devicesService.generateUsb();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ 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';
|
||||
import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices/devices.service.js';
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock('fs/promises', () => ({
|
||||
@@ -13,24 +13,35 @@ 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';
|
||||
InfoGpu,
|
||||
InfoNetwork,
|
||||
InfoPci,
|
||||
InfoUsb,
|
||||
} from '@app/unraid-api/graph/resolvers/info/devices/devices.model.js';
|
||||
|
||||
interface RawUsbDeviceData {
|
||||
id: string;
|
||||
n?: string;
|
||||
}
|
||||
|
||||
interface UsbDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
guid: string;
|
||||
vendorname?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DevicesService {
|
||||
private readonly logger = new Logger(DevicesService.name);
|
||||
|
||||
async generateGpu(): Promise<Gpu[]> {
|
||||
async generateGpu(): Promise<InfoGpu[]> {
|
||||
try {
|
||||
const systemPciDevices = await this.getSystemPciDevices();
|
||||
return systemPciDevices
|
||||
.filter((device) => device.class === 'vga' && !device.allowed)
|
||||
.map((entry) => {
|
||||
const gpu: Gpu = {
|
||||
const gpu: InfoGpu = {
|
||||
id: `gpu/${entry.id}`,
|
||||
blacklisted: entry.allowed,
|
||||
class: entry.class,
|
||||
@@ -50,7 +61,7 @@ export class DevicesService {
|
||||
}
|
||||
}
|
||||
|
||||
async generatePci(): Promise<Pci[]> {
|
||||
async generatePci(): Promise<InfoPci[]> {
|
||||
try {
|
||||
const devices = await this.getSystemPciDevices();
|
||||
return devices.map((device) => ({
|
||||
@@ -73,7 +84,21 @@ export class DevicesService {
|
||||
}
|
||||
}
|
||||
|
||||
async generateUsb(): Promise<Usb[]> {
|
||||
async generateNetwork(): Promise<InfoNetwork[]> {
|
||||
try {
|
||||
// For now, return empty array. This can be implemented later to fetch actual network interfaces
|
||||
// using systeminformation or similar libraries
|
||||
return [];
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(
|
||||
`Failed to generate network devices: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async generateUsb(): Promise<InfoUsb[]> {
|
||||
try {
|
||||
const usbDevices = await this.getSystemUSBDevices();
|
||||
return usbDevices.map((device) => ({
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Field, Float, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { Node } from '@unraid/shared/graphql.model.js';
|
||||
|
||||
import { ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js';
|
||||
|
||||
export enum Temperature {
|
||||
CELSIUS = 'C',
|
||||
FAHRENHEIT = 'F',
|
||||
}
|
||||
|
||||
registerEnumType(Temperature, {
|
||||
name: 'Temperature',
|
||||
description: 'Temperature unit',
|
||||
});
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoDisplayCase extends Node {
|
||||
@Field(() => String, { description: 'Case image URL' })
|
||||
url!: string;
|
||||
|
||||
@Field(() => String, { description: 'Case icon identifier' })
|
||||
icon!: string;
|
||||
|
||||
@Field(() => String, { description: 'Error message if any' })
|
||||
error!: string;
|
||||
|
||||
@Field(() => String, { description: 'Base64 encoded case image' })
|
||||
base64!: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoDisplay extends Node {
|
||||
@Field(() => InfoDisplayCase, { description: 'Case display configuration' })
|
||||
case!: InfoDisplayCase;
|
||||
|
||||
@Field(() => ThemeName, { description: 'UI theme name' })
|
||||
theme!: ThemeName;
|
||||
|
||||
@Field(() => Temperature, { description: 'Temperature unit (C or F)' })
|
||||
unit!: Temperature;
|
||||
|
||||
@Field(() => Boolean, { description: 'Enable UI scaling' })
|
||||
scale!: boolean;
|
||||
|
||||
@Field(() => Boolean, { description: 'Show tabs in UI' })
|
||||
tabs!: boolean;
|
||||
|
||||
@Field(() => Boolean, { description: 'Enable UI resize' })
|
||||
resize!: boolean;
|
||||
|
||||
@Field(() => Boolean, { description: 'Show WWN identifiers' })
|
||||
wwn!: boolean;
|
||||
|
||||
@Field(() => Boolean, { description: 'Show totals' })
|
||||
total!: boolean;
|
||||
|
||||
@Field(() => Boolean, { description: 'Show usage statistics' })
|
||||
usage!: boolean;
|
||||
|
||||
@Field(() => Boolean, { description: 'Show text labels' })
|
||||
text!: boolean;
|
||||
|
||||
@Field(() => Int, { description: 'Warning temperature threshold' })
|
||||
warning!: number;
|
||||
|
||||
@Field(() => Int, { description: 'Critical temperature threshold' })
|
||||
critical!: number;
|
||||
|
||||
@Field(() => Int, { description: 'Hot temperature threshold' })
|
||||
hot!: number;
|
||||
|
||||
@Field(() => Int, { nullable: true, description: 'Maximum temperature threshold' })
|
||||
max?: number;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Locale setting' })
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
// Export aliases for backward compatibility with the main DisplayResolver
|
||||
export { InfoDisplay as Display };
|
||||
export { InfoDisplayCase as DisplayCase };
|
||||
@@ -3,7 +3,7 @@ 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';
|
||||
import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js';
|
||||
|
||||
// Mock fs/promises at the module level only for specific test cases
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
@@ -37,7 +37,7 @@ describe('DisplayService', () => {
|
||||
const result = await service.generateDisplay();
|
||||
|
||||
// Verify basic structure
|
||||
expect(result).toHaveProperty('id', 'display');
|
||||
expect(result).toHaveProperty('id', 'info/display');
|
||||
expect(result).toHaveProperty('case');
|
||||
expect(result.case).toHaveProperty('url');
|
||||
expect(result.case).toHaveProperty('icon');
|
||||
@@ -69,6 +69,7 @@ describe('DisplayService', () => {
|
||||
const result = await service.generateDisplay();
|
||||
|
||||
expect(result.case).toEqual({
|
||||
id: 'display/case',
|
||||
url: '',
|
||||
icon: 'custom',
|
||||
error: 'could-not-read-config-file',
|
||||
@@ -90,7 +91,7 @@ describe('DisplayService', () => {
|
||||
const result = await service.generateDisplay();
|
||||
|
||||
// Should still return basic structure even if some config is missing
|
||||
expect(result).toHaveProperty('id', 'display');
|
||||
expect(result).toHaveProperty('id', 'info/display');
|
||||
expect(result).toHaveProperty('case');
|
||||
// The actual config depends on what's in the dev files
|
||||
});
|
||||
@@ -114,11 +115,6 @@ describe('DisplayService', () => {
|
||||
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
|
||||
});
|
||||
|
||||
@@ -140,6 +136,7 @@ describe('DisplayService', () => {
|
||||
const result = await service.generateDisplay();
|
||||
|
||||
expect(result.case).toEqual({
|
||||
id: 'display/case',
|
||||
url: '',
|
||||
icon: 'default',
|
||||
error: '',
|
||||
@@ -6,19 +6,22 @@ import { type DynamixConfig } from '@app/core/types/ini.js';
|
||||
import { toBoolean } from '@app/core/utils/casting.js';
|
||||
import { fileExists } from '@app/core/utils/files/file-exists.js';
|
||||
import { loadState } from '@app/core/utils/misc/load-state.js';
|
||||
import { validateEnumValue } from '@app/core/utils/validation/enum-validator.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';
|
||||
import { Display, Temperature } from '@app/unraid-api/graph/resolvers/info/display/display.model.js';
|
||||
|
||||
const states = {
|
||||
// Success
|
||||
custom: {
|
||||
id: 'display/case',
|
||||
url: '',
|
||||
icon: 'custom',
|
||||
error: '',
|
||||
base64: '',
|
||||
},
|
||||
default: {
|
||||
id: 'display/case',
|
||||
url: '',
|
||||
icon: 'default',
|
||||
error: '',
|
||||
@@ -27,30 +30,35 @@ const states = {
|
||||
|
||||
// Errors
|
||||
couldNotReadConfigFile: {
|
||||
id: 'display/case',
|
||||
url: '',
|
||||
icon: 'custom',
|
||||
error: 'could-not-read-config-file',
|
||||
base64: '',
|
||||
},
|
||||
couldNotReadImage: {
|
||||
id: 'display/case',
|
||||
url: '',
|
||||
icon: 'custom',
|
||||
error: 'could-not-read-image',
|
||||
base64: '',
|
||||
},
|
||||
imageMissing: {
|
||||
id: 'display/case',
|
||||
url: '',
|
||||
icon: 'custom',
|
||||
error: 'image-missing',
|
||||
base64: '',
|
||||
},
|
||||
imageTooBig: {
|
||||
id: 'display/case',
|
||||
url: '',
|
||||
icon: 'custom',
|
||||
error: 'image-too-big',
|
||||
base64: '',
|
||||
},
|
||||
imageCorrupt: {
|
||||
id: 'display/case',
|
||||
url: '',
|
||||
icon: 'custom',
|
||||
error: 'image-corrupt',
|
||||
@@ -67,11 +75,26 @@ export class DisplayService {
|
||||
// Get display configuration
|
||||
const config = await this.getDisplayConfig();
|
||||
|
||||
return {
|
||||
id: 'display',
|
||||
const display: Display = {
|
||||
id: 'info/display',
|
||||
case: caseInfo,
|
||||
...config,
|
||||
theme: config.theme ?? ThemeName.white,
|
||||
unit: config.unit ?? Temperature.CELSIUS,
|
||||
scale: config.scale ?? false,
|
||||
tabs: config.tabs ?? true,
|
||||
resize: config.resize ?? true,
|
||||
wwn: config.wwn ?? false,
|
||||
total: config.total ?? true,
|
||||
usage: config.usage ?? true,
|
||||
text: config.text ?? true,
|
||||
warning: config.warning ?? 60,
|
||||
critical: config.critical ?? 80,
|
||||
hot: config.hot ?? 90,
|
||||
max: config.max,
|
||||
locale: config.locale,
|
||||
};
|
||||
|
||||
return display;
|
||||
}
|
||||
|
||||
private async getCaseInfo() {
|
||||
@@ -102,11 +125,12 @@ export class DisplayService {
|
||||
// Non-custom icon
|
||||
return {
|
||||
...states.default,
|
||||
id: 'display/case',
|
||||
icon: serverCase,
|
||||
};
|
||||
}
|
||||
|
||||
private async getDisplayConfig() {
|
||||
private async getDisplayConfig(): Promise<Partial<Omit<Display, 'id' | 'case'>>> {
|
||||
const filePaths = getters.paths()['dynamix-config'];
|
||||
|
||||
const state = filePaths.reduce<Partial<DynamixConfig>>((acc, filePath) => {
|
||||
@@ -122,10 +146,11 @@ export class DisplayService {
|
||||
}
|
||||
|
||||
const { theme, unit, ...display } = state.display;
|
||||
|
||||
return {
|
||||
...display,
|
||||
theme: theme as ThemeName,
|
||||
unit: unit as Temperature,
|
||||
theme: validateEnumValue(theme, ThemeName),
|
||||
unit: validateEnumValue(unit, Temperature),
|
||||
scale: toBoolean(display.scale),
|
||||
tabs: toBoolean(display.tabs),
|
||||
resize: toBoolean(display.resize),
|
||||
@@ -1,552 +1,44 @@
|
||||
import {
|
||||
Field,
|
||||
Float,
|
||||
GraphQLISODateTime,
|
||||
ID,
|
||||
Int,
|
||||
ObjectType,
|
||||
registerEnumType,
|
||||
} from '@nestjs/graphql';
|
||||
import { Field, GraphQLISODateTime, ID, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { Node } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import { GraphQLBigInt, GraphQLJSON } from 'graphql-scalars';
|
||||
|
||||
import { ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js';
|
||||
|
||||
// USB device interface for type safety
|
||||
export interface UsbDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
guid: string;
|
||||
vendorname: string;
|
||||
}
|
||||
|
||||
// Raw USB device data from lsusb parsing
|
||||
export interface RawUsbDeviceData {
|
||||
id: string;
|
||||
n?: string;
|
||||
}
|
||||
|
||||
export enum Temperature {
|
||||
C = 'C',
|
||||
F = 'F',
|
||||
}
|
||||
|
||||
registerEnumType(Temperature, {
|
||||
name: 'Temperature',
|
||||
description: 'Temperature unit (Celsius or Fahrenheit)',
|
||||
});
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoApps extends Node {
|
||||
@Field(() => Int, { description: 'How many docker containers are installed' })
|
||||
installed!: number;
|
||||
|
||||
@Field(() => Int, { description: 'How many docker containers are running' })
|
||||
started!: number;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class Baseboard extends Node {
|
||||
@Field(() => String)
|
||||
manufacturer!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
model?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
version?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
serial?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
assetTag?: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoCpu extends Node {
|
||||
@Field(() => String)
|
||||
manufacturer!: string;
|
||||
|
||||
@Field(() => String)
|
||||
brand!: string;
|
||||
|
||||
@Field(() => String)
|
||||
vendor!: string;
|
||||
|
||||
@Field(() => String)
|
||||
family!: string;
|
||||
|
||||
@Field(() => String)
|
||||
model!: string;
|
||||
|
||||
@Field(() => Int)
|
||||
stepping!: number;
|
||||
|
||||
@Field(() => String)
|
||||
revision!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
voltage?: string;
|
||||
|
||||
@Field(() => Float)
|
||||
speed!: number;
|
||||
|
||||
@Field(() => Float)
|
||||
speedmin!: number;
|
||||
|
||||
@Field(() => Float)
|
||||
speedmax!: number;
|
||||
|
||||
@Field(() => Int)
|
||||
threads!: number;
|
||||
|
||||
@Field(() => Int)
|
||||
cores!: number;
|
||||
|
||||
@Field(() => Int)
|
||||
processors!: number;
|
||||
|
||||
@Field(() => String)
|
||||
socket!: string;
|
||||
|
||||
@Field(() => GraphQLJSON)
|
||||
cache!: Record<string, any>;
|
||||
|
||||
@Field(() => [String])
|
||||
flags!: string[];
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class Gpu extends Node {
|
||||
@Field(() => String)
|
||||
type!: string;
|
||||
|
||||
@Field(() => String)
|
||||
typeid!: string;
|
||||
|
||||
@Field(() => String)
|
||||
vendorname!: string;
|
||||
|
||||
@Field(() => String)
|
||||
productid!: string;
|
||||
|
||||
@Field(() => Boolean)
|
||||
blacklisted!: boolean;
|
||||
|
||||
@Field(() => String)
|
||||
class!: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class Network extends Node {
|
||||
@Field(() => String, { nullable: true })
|
||||
iface?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
ifaceName?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
ipv4?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
ipv6?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
mac?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
internal?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
operstate?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
type?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
duplex?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
mtu?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
speed?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
carrierChanges?: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class Pci extends Node {
|
||||
@Field(() => String, { nullable: true })
|
||||
type?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
typeid?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
vendorname?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
vendorid?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
productname?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
productid?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
blacklisted?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
class?: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class Usb extends Node {
|
||||
@Field(() => String, { nullable: true })
|
||||
name?: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class Devices extends Node {
|
||||
@Field(() => [Gpu])
|
||||
gpu!: Gpu[];
|
||||
|
||||
@Field(() => [Pci])
|
||||
pci!: Pci[];
|
||||
|
||||
@Field(() => [Usb])
|
||||
usb!: Usb[];
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class Case {
|
||||
@Field(() => String, { nullable: true })
|
||||
icon?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
url?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
error?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
base64?: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class Display extends Node {
|
||||
@Field(() => Case, { nullable: true })
|
||||
case?: Case;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
date?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
number?: string;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
scale?: boolean;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
tabs?: boolean;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
users?: string;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
resize?: boolean;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
wwn?: boolean;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
total?: boolean;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
usage?: boolean;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
banner?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
dashapps?: string;
|
||||
|
||||
@Field(() => ThemeName, { nullable: true })
|
||||
theme?: ThemeName;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
text?: boolean;
|
||||
|
||||
@Field(() => Temperature, { nullable: true })
|
||||
unit?: Temperature;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
warning?: number;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
critical?: number;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
hot?: number;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
max?: number;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class MemoryLayout extends Node {
|
||||
@Field(() => GraphQLBigInt)
|
||||
size!: number;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
bank?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
type?: string;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
clockSpeed?: number;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
formFactor?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
manufacturer?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
partNum?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
serialNum?: string;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
voltageConfigured?: number;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
voltageMin?: number;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
voltageMax?: number;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoMemory extends Node {
|
||||
@Field(() => GraphQLBigInt)
|
||||
max!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt)
|
||||
total!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt)
|
||||
free!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt)
|
||||
used!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt)
|
||||
active!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt)
|
||||
available!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt)
|
||||
buffcache!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt)
|
||||
swaptotal!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt)
|
||||
swapused!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt)
|
||||
swapfree!: number;
|
||||
|
||||
@Field(() => [MemoryLayout])
|
||||
layout!: MemoryLayout[];
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class Os extends Node {
|
||||
@Field(() => String, { nullable: true })
|
||||
platform?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
distro?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
release?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
codename?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
kernel?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
arch?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
hostname?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
codepage?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
logofile?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
serial?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
build?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
uptime?: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class System extends Node {
|
||||
@Field(() => String, { nullable: true })
|
||||
manufacturer?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
model?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
version?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
serial?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
uuid?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
sku?: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class Versions extends Node {
|
||||
@Field(() => String, { nullable: true })
|
||||
kernel?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
openssl?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
systemOpenssl?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
systemOpensslLib?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
node?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
v8?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
npm?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
yarn?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
pm2?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
gulp?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
grunt?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
git?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
tsc?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
mysql?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
redis?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
mongodb?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
apache?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
nginx?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
php?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
docker?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
postfix?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
postgresql?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
perl?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
python?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
gcc?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
unraid?: string;
|
||||
}
|
||||
import { InfoCpu } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js';
|
||||
import { InfoDevices } from '@app/unraid-api/graph/resolvers/info/devices/devices.model.js';
|
||||
import { InfoDisplay } from '@app/unraid-api/graph/resolvers/info/display/display.model.js';
|
||||
import { InfoMemory } from '@app/unraid-api/graph/resolvers/info/memory/memory.model.js';
|
||||
import { InfoOs } from '@app/unraid-api/graph/resolvers/info/os/os.model.js';
|
||||
import { InfoBaseboard, InfoSystem } from '@app/unraid-api/graph/resolvers/info/system/system.model.js';
|
||||
import { InfoVersions } from '@app/unraid-api/graph/resolvers/info/versions/versions.model.js';
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class Info extends Node {
|
||||
@Field(() => InfoApps, { description: 'Count of docker containers' })
|
||||
apps!: InfoApps;
|
||||
|
||||
@Field(() => Baseboard)
|
||||
baseboard!: Baseboard;
|
||||
|
||||
@Field(() => InfoCpu)
|
||||
cpu!: InfoCpu;
|
||||
|
||||
@Field(() => Devices)
|
||||
devices!: Devices;
|
||||
|
||||
@Field(() => Display)
|
||||
display!: Display;
|
||||
|
||||
@Field(() => PrefixedID, { description: 'Machine ID', nullable: true })
|
||||
machineId?: string;
|
||||
|
||||
@Field(() => InfoMemory)
|
||||
memory!: InfoMemory;
|
||||
|
||||
@Field(() => Os)
|
||||
os!: Os;
|
||||
|
||||
@Field(() => System)
|
||||
system!: System;
|
||||
|
||||
@Field(() => GraphQLISODateTime)
|
||||
@Field(() => GraphQLISODateTime, { description: 'Current server time' })
|
||||
time!: Date;
|
||||
|
||||
@Field(() => Versions)
|
||||
versions!: Versions;
|
||||
@Field(() => InfoBaseboard, { description: 'Motherboard information' })
|
||||
baseboard!: InfoBaseboard;
|
||||
|
||||
@Field(() => InfoCpu, { description: 'CPU information' })
|
||||
cpu!: InfoCpu;
|
||||
|
||||
@Field(() => InfoDevices, { description: 'Device information' })
|
||||
devices!: InfoDevices;
|
||||
|
||||
@Field(() => InfoDisplay, { description: 'Display configuration' })
|
||||
display!: InfoDisplay;
|
||||
|
||||
@Field(() => ID, { nullable: true, description: 'Machine ID' })
|
||||
machineId?: string;
|
||||
|
||||
@Field(() => InfoMemory, { description: 'Memory information' })
|
||||
memory!: InfoMemory;
|
||||
|
||||
@Field(() => InfoOs, { description: 'Operating system information' })
|
||||
os!: InfoOs;
|
||||
|
||||
@Field(() => InfoSystem, { description: 'System information' })
|
||||
system!: InfoSystem;
|
||||
|
||||
@Field(() => InfoVersions, { description: 'Software versions' })
|
||||
versions!: InfoVersions;
|
||||
}
|
||||
|
||||
33
api/src/unraid-api/graph/resolvers/info/info.module.ts
Normal file
33
api/src/unraid-api/graph/resolvers/info/info.module.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
|
||||
import { DevicesResolver } from '@app/unraid-api/graph/resolvers/info/devices/devices.resolver.js';
|
||||
import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices/devices.service.js';
|
||||
import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js';
|
||||
import { InfoResolver } from '@app/unraid-api/graph/resolvers/info/info.resolver.js';
|
||||
import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js';
|
||||
import { OsService } from '@app/unraid-api/graph/resolvers/info/os/os.service.js';
|
||||
import { VersionsService } from '@app/unraid-api/graph/resolvers/info/versions/versions.service.js';
|
||||
import { ServicesModule } from '@app/unraid-api/graph/services/services.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule, ServicesModule],
|
||||
providers: [
|
||||
// Main resolver
|
||||
InfoResolver,
|
||||
|
||||
// Sub-resolvers
|
||||
DevicesResolver,
|
||||
|
||||
// Services
|
||||
CpuService,
|
||||
MemoryService,
|
||||
DevicesService,
|
||||
OsService,
|
||||
VersionsService,
|
||||
DisplayService,
|
||||
],
|
||||
exports: [InfoResolver, DevicesResolver, DisplayService],
|
||||
})
|
||||
export class InfoModule {}
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
|
||||
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
|
||||
import { DevicesResolver } from '@app/unraid-api/graph/resolvers/info/devices/devices.resolver.js';
|
||||
import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices/devices.service.js';
|
||||
import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js';
|
||||
import { InfoResolver } from '@app/unraid-api/graph/resolvers/info/info.resolver.js';
|
||||
import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js';
|
||||
import { OsService } from '@app/unraid-api/graph/resolvers/info/os/os.service.js';
|
||||
import { VersionsService } from '@app/unraid-api/graph/resolvers/info/versions/versions.service.js';
|
||||
import { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js';
|
||||
import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js';
|
||||
|
||||
describe('InfoResolver Integration Tests', () => {
|
||||
let infoResolver: InfoResolver;
|
||||
let devicesResolver: DevicesResolver;
|
||||
let module: TestingModule;
|
||||
|
||||
beforeEach(async () => {
|
||||
module = await Test.createTestingModule({
|
||||
providers: [
|
||||
InfoResolver,
|
||||
DevicesResolver,
|
||||
CpuService,
|
||||
MemoryService,
|
||||
DevicesService,
|
||||
OsService,
|
||||
VersionsService,
|
||||
DisplayService,
|
||||
{
|
||||
provide: SubscriptionTrackerService,
|
||||
useValue: {
|
||||
trackActiveSubscriptions: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SubscriptionHelperService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: (key: string) => {
|
||||
if (key === 'store.emhttp.var.version') {
|
||||
return '6.12.0';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DockerService,
|
||||
useValue: {
|
||||
getContainers: async () => [],
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CACHE_MANAGER,
|
||||
useValue: {
|
||||
get: async () => null,
|
||||
set: async () => {},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
infoResolver = module.get<InfoResolver>(InfoResolver);
|
||||
devicesResolver = module.get<DevicesResolver>(DevicesResolver);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (module) {
|
||||
await module.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe('InfoResolver ResolveFields', () => {
|
||||
it('should return basic info object', async () => {
|
||||
const result = await infoResolver.info();
|
||||
expect(result).toEqual({
|
||||
id: 'info',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return current time', async () => {
|
||||
const before = new Date();
|
||||
const result = await infoResolver.time();
|
||||
const after = new Date();
|
||||
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
expect(result.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||||
expect(result.getTime()).toBeLessThanOrEqual(after.getTime());
|
||||
});
|
||||
|
||||
it('should return full cpu object from service', async () => {
|
||||
const result = await infoResolver.cpu();
|
||||
|
||||
expect(result).toHaveProperty('id', 'info/cpu');
|
||||
expect(result).toHaveProperty('manufacturer');
|
||||
expect(result).toHaveProperty('brand');
|
||||
});
|
||||
|
||||
it('should return full memory object from service', async () => {
|
||||
const result = await infoResolver.memory();
|
||||
|
||||
expect(result).toHaveProperty('id', 'info/memory');
|
||||
expect(result).toHaveProperty('layout');
|
||||
expect(result.layout).toBeInstanceOf(Array);
|
||||
});
|
||||
|
||||
it('should return minimal devices stub for sub-resolver', () => {
|
||||
const result = infoResolver.devices();
|
||||
|
||||
expect(result).toHaveProperty('id', 'info/devices');
|
||||
expect(Object.keys(result)).toEqual(['id']);
|
||||
});
|
||||
|
||||
it('should return full display object from service', async () => {
|
||||
const result = await infoResolver.display();
|
||||
|
||||
expect(result).toHaveProperty('id', 'info/display');
|
||||
expect(result).toHaveProperty('theme');
|
||||
expect(result).toHaveProperty('unit');
|
||||
});
|
||||
|
||||
it('should return baseboard data', async () => {
|
||||
const result = await infoResolver.baseboard();
|
||||
|
||||
expect(result).toHaveProperty('id', 'info/baseboard');
|
||||
expect(result).toHaveProperty('manufacturer');
|
||||
expect(result).toHaveProperty('model');
|
||||
expect(result).toHaveProperty('version');
|
||||
// These are the actual properties from systeminformation
|
||||
expect(typeof result.manufacturer).toBe('string');
|
||||
});
|
||||
|
||||
it('should return system data', async () => {
|
||||
const result = await infoResolver.system();
|
||||
|
||||
expect(result).toHaveProperty('id', 'info/system');
|
||||
expect(result).toHaveProperty('manufacturer');
|
||||
expect(result).toHaveProperty('model');
|
||||
expect(result).toHaveProperty('version');
|
||||
expect(result).toHaveProperty('serial');
|
||||
expect(result).toHaveProperty('uuid');
|
||||
// Verify types
|
||||
expect(typeof result.manufacturer).toBe('string');
|
||||
});
|
||||
|
||||
it('should return os data from service', async () => {
|
||||
const result = await infoResolver.os();
|
||||
|
||||
expect(result).toHaveProperty('id', 'info/os');
|
||||
expect(result).toHaveProperty('platform');
|
||||
expect(result).toHaveProperty('distro');
|
||||
expect(result).toHaveProperty('release');
|
||||
expect(result).toHaveProperty('kernel');
|
||||
// Verify platform is a string (could be linux, darwin, win32, etc)
|
||||
expect(typeof result.platform).toBe('string');
|
||||
});
|
||||
|
||||
it.skipIf(process.env.CI)('should return versions data from service', async () => {
|
||||
const result = await infoResolver.versions();
|
||||
|
||||
expect(result).toHaveProperty('id', 'info/versions');
|
||||
expect(result).toHaveProperty('unraid');
|
||||
expect(result).toHaveProperty('kernel');
|
||||
expect(result).toHaveProperty('node');
|
||||
expect(result).toHaveProperty('npm');
|
||||
// Verify unraid version from mock
|
||||
expect(result.unraid).toBe('6.12.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sub-Resolver Integration', () => {
|
||||
it('should resolve device fields through DevicesResolver', async () => {
|
||||
const gpu = await devicesResolver.gpu();
|
||||
const network = await devicesResolver.network();
|
||||
const pci = await devicesResolver.pci();
|
||||
const usb = await devicesResolver.usb();
|
||||
|
||||
expect(gpu).toBeInstanceOf(Array);
|
||||
expect(network).toBeInstanceOf(Array);
|
||||
expect(pci).toBeInstanceOf(Array);
|
||||
expect(usb).toBeInstanceOf(Array);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,225 +1,115 @@
|
||||
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 { DisplayService } from '@app/unraid-api/graph/resolvers/display/display.service.js';
|
||||
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
|
||||
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
|
||||
import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js';
|
||||
import { InfoResolver } from '@app/unraid-api/graph/resolvers/info/info.resolver.js';
|
||||
import { InfoService } from '@app/unraid-api/graph/resolvers/info/info.service.js';
|
||||
import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js';
|
||||
import { OsService } from '@app/unraid-api/graph/resolvers/info/os/os.service.js';
|
||||
import { VersionsService } from '@app/unraid-api/graph/resolvers/info/versions/versions.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('@app/core/utils/misc/get-machine-id.js', () => ({
|
||||
getMachineId: vi.fn().mockResolvedValue('test-machine-id-123'),
|
||||
}));
|
||||
|
||||
vi.mock('systeminformation', () => ({
|
||||
baseboard: vi.fn().mockResolvedValue({
|
||||
manufacturer: 'ASUS',
|
||||
model: 'PRIME X570-P',
|
||||
version: 'Rev X.0x',
|
||||
serial: 'ABC123',
|
||||
assetTag: 'Default string',
|
||||
model: 'ROG STRIX',
|
||||
version: '1.0',
|
||||
}),
|
||||
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',
|
||||
model: 'System Model',
|
||||
version: '1.0',
|
||||
serial: '123456',
|
||||
uuid: 'test-uuid',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@app/core/utils/misc/get-machine-id.js', () => ({
|
||||
getMachineId: vi.fn().mockResolvedValue('test-machine-id-123'),
|
||||
}));
|
||||
|
||||
// Mock Cache Manager
|
||||
const mockCacheManager = {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
del: vi.fn(),
|
||||
};
|
||||
|
||||
describe('InfoResolver', () => {
|
||||
let resolver: InfoResolver;
|
||||
|
||||
// Mock data for testing
|
||||
const mockAppsData = {
|
||||
id: 'info/apps',
|
||||
installed: 5,
|
||||
started: 3,
|
||||
};
|
||||
|
||||
const mockCpuData = {
|
||||
id: 'info/cpu',
|
||||
manufacturer: 'AMD',
|
||||
brand: 'AMD Ryzen 9 5900X',
|
||||
vendor: 'AMD',
|
||||
family: '19',
|
||||
model: '33',
|
||||
stepping: 0,
|
||||
revision: '',
|
||||
voltage: '1.4V',
|
||||
speed: 3.7,
|
||||
speedmin: 2.2,
|
||||
speedmax: 4.8,
|
||||
threads: 24,
|
||||
cores: 12,
|
||||
processors: 1,
|
||||
socket: 'AM4',
|
||||
cache: { l1d: 32768, l1i: 32768, l2: 524288, l3: 33554432 },
|
||||
flags: ['fpu', 'vme', 'de', 'pse'],
|
||||
};
|
||||
|
||||
const mockDevicesData = {
|
||||
id: 'info/devices',
|
||||
gpu: [],
|
||||
pci: [],
|
||||
usb: [],
|
||||
};
|
||||
|
||||
const mockDisplayData = {
|
||||
id: 'display',
|
||||
case: {
|
||||
url: '',
|
||||
icon: 'default',
|
||||
error: '',
|
||||
base64: '',
|
||||
},
|
||||
theme: 'black',
|
||||
unit: 'C',
|
||||
scale: true,
|
||||
tabs: false,
|
||||
resize: true,
|
||||
wwn: false,
|
||||
total: true,
|
||||
usage: false,
|
||||
text: true,
|
||||
warning: 40,
|
||||
critical: 50,
|
||||
hot: 60,
|
||||
max: 80,
|
||||
locale: 'en_US',
|
||||
};
|
||||
|
||||
const mockMemoryData = {
|
||||
id: 'info/memory',
|
||||
max: 68719476736,
|
||||
total: 67108864000,
|
||||
free: 33554432000,
|
||||
used: 33554432000,
|
||||
active: 16777216000,
|
||||
available: 50331648000,
|
||||
buffcache: 8388608000,
|
||||
swaptotal: 4294967296,
|
||||
swapused: 0,
|
||||
swapfree: 4294967296,
|
||||
layout: [],
|
||||
};
|
||||
|
||||
const mockOsData = {
|
||||
id: 'info/os',
|
||||
platform: 'linux',
|
||||
distro: 'Unraid',
|
||||
release: '6.12.0',
|
||||
codename: '',
|
||||
kernel: '6.1.0-unraid',
|
||||
arch: 'x64',
|
||||
hostname: 'Tower',
|
||||
codepage: 'UTF-8',
|
||||
logofile: 'unraid',
|
||||
serial: '',
|
||||
build: '',
|
||||
uptime: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const mockVersionsData = {
|
||||
id: 'info/versions',
|
||||
unraid: '6.12.0',
|
||||
kernel: '6.1.0',
|
||||
node: '20.10.0',
|
||||
npm: '10.2.3',
|
||||
docker: '24.0.7',
|
||||
};
|
||||
|
||||
// Mock InfoService
|
||||
const mockInfoService = {
|
||||
generateApps: vi.fn().mockResolvedValue(mockAppsData),
|
||||
generateCpu: vi.fn().mockResolvedValue(mockCpuData),
|
||||
generateDevices: vi.fn().mockResolvedValue(mockDevicesData),
|
||||
generateMemory: vi.fn().mockResolvedValue(mockMemoryData),
|
||||
generateOs: vi.fn().mockResolvedValue(mockOsData),
|
||||
generateVersions: vi.fn().mockResolvedValue(mockVersionsData),
|
||||
};
|
||||
|
||||
// Mock DisplayService
|
||||
const mockDisplayService = {
|
||||
generateDisplay: vi.fn().mockResolvedValue(mockDisplayData),
|
||||
};
|
||||
let cpuService: CpuService;
|
||||
let memoryService: MemoryService;
|
||||
let module: TestingModule;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
module = await Test.createTestingModule({
|
||||
providers: [
|
||||
InfoResolver,
|
||||
{
|
||||
provide: InfoService,
|
||||
useValue: mockInfoService,
|
||||
provide: CpuService,
|
||||
useValue: {
|
||||
generateCpu: vi.fn().mockResolvedValue({
|
||||
id: 'info/cpu',
|
||||
manufacturer: 'Intel',
|
||||
brand: 'Core i7',
|
||||
cores: 8,
|
||||
threads: 16,
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MemoryService,
|
||||
useValue: {
|
||||
generateMemory: vi.fn().mockResolvedValue({
|
||||
id: 'info/memory',
|
||||
layout: [
|
||||
{
|
||||
id: 'mem-1',
|
||||
size: 8589934592,
|
||||
bank: 'BANK 0',
|
||||
type: 'DDR4',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DisplayService,
|
||||
useValue: mockDisplayService,
|
||||
useValue: {
|
||||
generateDisplay: vi.fn().mockResolvedValue({
|
||||
id: 'info/display',
|
||||
theme: 'dark',
|
||||
unit: 'metric',
|
||||
scale: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DockerService,
|
||||
useValue: {},
|
||||
provide: OsService,
|
||||
useValue: {
|
||||
generateOs: vi.fn().mockResolvedValue({
|
||||
id: 'info/os',
|
||||
platform: 'linux',
|
||||
distro: 'Unraid',
|
||||
release: '6.12.0',
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CACHE_MANAGER,
|
||||
useValue: mockCacheManager,
|
||||
provide: VersionsService,
|
||||
useValue: {
|
||||
generateVersions: vi.fn().mockResolvedValue({
|
||||
id: 'info/versions',
|
||||
unraid: '6.12.0',
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
resolver = module.get<InfoResolver>(InfoResolver);
|
||||
|
||||
// Reset mocks before each test
|
||||
vi.clearAllMocks();
|
||||
cpuService = module.get<CpuService>(CpuService);
|
||||
memoryService = module.get<MemoryService>(MemoryService);
|
||||
});
|
||||
|
||||
describe('info', () => {
|
||||
it('should return basic info object', async () => {
|
||||
const result = await resolver.info();
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'info',
|
||||
});
|
||||
@@ -228,155 +118,129 @@ describe('InfoResolver', () => {
|
||||
|
||||
describe('time', () => {
|
||||
it('should return current date', async () => {
|
||||
const beforeCall = new Date();
|
||||
const before = new Date();
|
||||
const result = await resolver.time();
|
||||
const afterCall = new Date();
|
||||
const after = 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);
|
||||
expect(result.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||||
expect(result.getTime()).toBeLessThanOrEqual(after.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('baseboard', () => {
|
||||
it('should return baseboard info with id', async () => {
|
||||
it('should return baseboard data from systeminformation', async () => {
|
||||
const result = await resolver.baseboard();
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'baseboard',
|
||||
id: 'info/baseboard',
|
||||
manufacturer: 'ASUS',
|
||||
model: 'PRIME X570-P',
|
||||
version: 'Rev X.0x',
|
||||
serial: 'ABC123',
|
||||
assetTag: 'Default string',
|
||||
model: 'ROG STRIX',
|
||||
version: '1.0',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cpu', () => {
|
||||
it('should return cpu info from service', async () => {
|
||||
it('should return full cpu data from service', async () => {
|
||||
const result = await resolver.cpu();
|
||||
|
||||
expect(mockInfoService.generateCpu).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual(mockCpuData);
|
||||
expect(cpuService.generateCpu).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
id: 'info/cpu',
|
||||
manufacturer: 'Intel',
|
||||
brand: 'Core i7',
|
||||
cores: 8,
|
||||
threads: 16,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('devices', () => {
|
||||
it('should return devices info from service', async () => {
|
||||
const result = await resolver.devices();
|
||||
|
||||
expect(mockInfoService.generateDevices).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual(mockDevicesData);
|
||||
it('should return devices stub for sub-resolver', () => {
|
||||
const result = resolver.devices();
|
||||
expect(result).toEqual({
|
||||
id: 'info/devices',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('display', () => {
|
||||
it('should return display info from display service', async () => {
|
||||
it('should return display data from service', async () => {
|
||||
const displayService = module.get<DisplayService>(DisplayService);
|
||||
const result = await resolver.display();
|
||||
|
||||
expect(mockDisplayService.generateDisplay).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual(mockDisplayData);
|
||||
expect(displayService.generateDisplay).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
id: 'info/display',
|
||||
theme: 'dark',
|
||||
unit: 'metric',
|
||||
scale: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
const result = await resolver.machineId();
|
||||
expect(getMachineId).toHaveBeenCalled();
|
||||
expect(result).toBe('test-machine-id-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory', () => {
|
||||
it('should return memory info from service', async () => {
|
||||
it('should return full memory data from service', async () => {
|
||||
const result = await resolver.memory();
|
||||
|
||||
expect(mockInfoService.generateMemory).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual(mockMemoryData);
|
||||
expect(memoryService.generateMemory).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
id: 'info/memory',
|
||||
layout: [
|
||||
{
|
||||
id: 'mem-1',
|
||||
size: 8589934592,
|
||||
bank: 'BANK 0',
|
||||
type: 'DDR4',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('os', () => {
|
||||
it('should return os info from service', async () => {
|
||||
it('should return os data from service', async () => {
|
||||
const osService = module.get<OsService>(OsService);
|
||||
const result = await resolver.os();
|
||||
|
||||
expect(mockInfoService.generateOs).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual(mockOsData);
|
||||
expect(osService.generateOs).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
id: 'info/os',
|
||||
platform: 'linux',
|
||||
distro: 'Unraid',
|
||||
release: '6.12.0',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('system', () => {
|
||||
it('should return system info with id', async () => {
|
||||
it('should return system data from systeminformation', async () => {
|
||||
const result = await resolver.system();
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'system',
|
||||
id: 'info/system',
|
||||
manufacturer: 'ASUS',
|
||||
model: 'System Product Name',
|
||||
version: 'System Version',
|
||||
serial: 'System Serial Number',
|
||||
uuid: '550e8400-e29b-41d4-a716-446655440000',
|
||||
sku: 'SKU',
|
||||
model: 'System Model',
|
||||
version: '1.0',
|
||||
serial: '123456',
|
||||
uuid: 'test-uuid',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('versions', () => {
|
||||
it('should return versions info from service', async () => {
|
||||
it('should return versions data from service', async () => {
|
||||
const versionsService = module.get<VersionsService>(VersionsService);
|
||||
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');
|
||||
expect(versionsService.generateVersions).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
id: 'info/versions',
|
||||
unraid: '6.12.0',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql';
|
||||
import { GraphQLISODateTime, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
@@ -8,28 +8,29 @@ import {
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { baseboard as getBaseboard, system as getSystem } from 'systeminformation';
|
||||
|
||||
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { getMachineId } from '@app/core/utils/misc/get-machine-id.js';
|
||||
import { DisplayService } from '@app/unraid-api/graph/resolvers/display/display.service.js';
|
||||
import {
|
||||
Baseboard,
|
||||
Devices,
|
||||
Display,
|
||||
Info,
|
||||
InfoApps,
|
||||
InfoCpu,
|
||||
InfoMemory,
|
||||
Os,
|
||||
System,
|
||||
Versions,
|
||||
} from '@app/unraid-api/graph/resolvers/info/info.model.js';
|
||||
import { InfoService } from '@app/unraid-api/graph/resolvers/info/info.service.js';
|
||||
import { InfoCpu } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js';
|
||||
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
|
||||
import { InfoDevices } from '@app/unraid-api/graph/resolvers/info/devices/devices.model.js';
|
||||
import { InfoDisplay } from '@app/unraid-api/graph/resolvers/info/display/display.model.js';
|
||||
import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js';
|
||||
import { Info } from '@app/unraid-api/graph/resolvers/info/info.model.js';
|
||||
import { InfoMemory } from '@app/unraid-api/graph/resolvers/info/memory/memory.model.js';
|
||||
import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js';
|
||||
import { InfoOs } from '@app/unraid-api/graph/resolvers/info/os/os.model.js';
|
||||
import { OsService } from '@app/unraid-api/graph/resolvers/info/os/os.service.js';
|
||||
import { InfoBaseboard, InfoSystem } from '@app/unraid-api/graph/resolvers/info/system/system.model.js';
|
||||
import { InfoVersions } from '@app/unraid-api/graph/resolvers/info/versions/versions.model.js';
|
||||
import { VersionsService } from '@app/unraid-api/graph/resolvers/info/versions/versions.service.js';
|
||||
|
||||
@Resolver(() => Info)
|
||||
export class InfoResolver {
|
||||
constructor(
|
||||
private readonly infoService: InfoService,
|
||||
private readonly displayService: DisplayService
|
||||
private readonly cpuService: CpuService,
|
||||
private readonly memoryService: MemoryService,
|
||||
private readonly displayService: DisplayService,
|
||||
private readonly osService: OsService,
|
||||
private readonly versionsService: VersionsService
|
||||
) {}
|
||||
|
||||
@Query(() => Info)
|
||||
@@ -44,37 +45,30 @@ export class InfoResolver {
|
||||
};
|
||||
}
|
||||
|
||||
@ResolveField(() => Date)
|
||||
@ResolveField(() => GraphQLISODateTime)
|
||||
public async time(): Promise<Date> {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
@ResolveField(() => InfoApps)
|
||||
public async apps(): Promise<InfoApps> {
|
||||
return this.infoService.generateApps();
|
||||
}
|
||||
|
||||
@ResolveField(() => Baseboard)
|
||||
public async baseboard(): Promise<Baseboard> {
|
||||
@ResolveField(() => InfoBaseboard)
|
||||
public async baseboard(): Promise<InfoBaseboard> {
|
||||
const baseboard = await getBaseboard();
|
||||
return {
|
||||
id: 'baseboard',
|
||||
...baseboard,
|
||||
};
|
||||
return { id: 'info/baseboard', ...baseboard } as InfoBaseboard;
|
||||
}
|
||||
|
||||
@ResolveField(() => InfoCpu)
|
||||
public async cpu(): Promise<InfoCpu> {
|
||||
return this.infoService.generateCpu();
|
||||
return this.cpuService.generateCpu();
|
||||
}
|
||||
|
||||
@ResolveField(() => Devices)
|
||||
public async devices(): Promise<Devices> {
|
||||
return this.infoService.generateDevices();
|
||||
@ResolveField(() => InfoDevices)
|
||||
public devices(): Partial<InfoDevices> {
|
||||
// Return minimal stub, let InfoDevicesResolver handle all fields
|
||||
return { id: 'info/devices' };
|
||||
}
|
||||
|
||||
@ResolveField(() => Display)
|
||||
public async display(): Promise<Display> {
|
||||
@ResolveField(() => InfoDisplay)
|
||||
public async display(): Promise<InfoDisplay> {
|
||||
return this.displayService.generateDisplay();
|
||||
}
|
||||
|
||||
@@ -85,35 +79,22 @@ export class InfoResolver {
|
||||
|
||||
@ResolveField(() => InfoMemory)
|
||||
public async memory(): Promise<InfoMemory> {
|
||||
return this.infoService.generateMemory();
|
||||
return this.memoryService.generateMemory();
|
||||
}
|
||||
|
||||
@ResolveField(() => Os)
|
||||
public async os(): Promise<Os> {
|
||||
return this.infoService.generateOs();
|
||||
@ResolveField(() => InfoOs)
|
||||
public async os(): Promise<InfoOs> {
|
||||
return this.osService.generateOs();
|
||||
}
|
||||
|
||||
@ResolveField(() => System)
|
||||
public async system(): Promise<System> {
|
||||
@ResolveField(() => InfoSystem)
|
||||
public async system(): Promise<InfoSystem> {
|
||||
const system = await getSystem();
|
||||
return {
|
||||
id: 'system',
|
||||
...system,
|
||||
};
|
||||
return { id: 'info/system', ...system } as InfoSystem;
|
||||
}
|
||||
|
||||
@ResolveField(() => Versions)
|
||||
public async versions(): Promise<Versions> {
|
||||
return this.infoService.generateVersions();
|
||||
}
|
||||
|
||||
@Subscription(() => Info)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.INFO,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async infoSubscription() {
|
||||
return createSubscription(PUBSUB_CHANNEL.INFO);
|
||||
@ResolveField(() => InfoVersions)
|
||||
public async versions(): Promise<InfoVersions> {
|
||||
return this.versionsService.generateVersions();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Field, Float, Int, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { Node } from '@unraid/shared/graphql.model.js';
|
||||
import { GraphQLBigInt } from 'graphql-scalars';
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class MemoryLayout extends Node {
|
||||
@Field(() => GraphQLBigInt, { description: 'Memory module size in bytes' })
|
||||
size!: number;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Memory bank location (e.g., BANK 0)' })
|
||||
bank?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Memory type (e.g., DDR4, DDR5)' })
|
||||
type?: string;
|
||||
|
||||
@Field(() => Int, { nullable: true, description: 'Memory clock speed in MHz' })
|
||||
clockSpeed?: number;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Part number of the memory module' })
|
||||
partNum?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Serial number of the memory module' })
|
||||
serialNum?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Memory manufacturer' })
|
||||
manufacturer?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Form factor (e.g., DIMM, SODIMM)' })
|
||||
formFactor?: string;
|
||||
|
||||
@Field(() => Int, { nullable: true, description: 'Configured voltage in millivolts' })
|
||||
voltageConfigured?: number;
|
||||
|
||||
@Field(() => Int, { nullable: true, description: 'Minimum voltage in millivolts' })
|
||||
voltageMin?: number;
|
||||
|
||||
@Field(() => Int, { nullable: true, description: 'Maximum voltage in millivolts' })
|
||||
voltageMax?: number;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class MemoryUtilization extends Node {
|
||||
@Field(() => GraphQLBigInt, { description: 'Total system memory in bytes' })
|
||||
total!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt, { description: 'Used memory in bytes' })
|
||||
used!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt, { description: 'Free memory in bytes' })
|
||||
free!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt, { description: 'Available memory in bytes' })
|
||||
available!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt, { description: 'Active memory in bytes' })
|
||||
active!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt, { description: 'Buffer/cache memory in bytes' })
|
||||
buffcache!: number;
|
||||
|
||||
@Field(() => Float, { description: 'Memory usage percentage' })
|
||||
percentTotal!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt, { description: 'Total swap memory in bytes' })
|
||||
swapTotal!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt, { description: 'Used swap memory in bytes' })
|
||||
swapUsed!: number;
|
||||
|
||||
@Field(() => GraphQLBigInt, { description: 'Free swap memory in bytes' })
|
||||
swapFree!: number;
|
||||
|
||||
@Field(() => Float, { description: 'Swap usage percentage' })
|
||||
percentSwapTotal!: number;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoMemory extends Node {
|
||||
@Field(() => [MemoryLayout], { description: 'Physical memory layout' })
|
||||
layout!: MemoryLayout[];
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { mem, memLayout } from 'systeminformation';
|
||||
|
||||
import {
|
||||
InfoMemory,
|
||||
MemoryLayout,
|
||||
MemoryUtilization,
|
||||
} from '@app/unraid-api/graph/resolvers/info/memory/memory.model.js';
|
||||
|
||||
@Injectable()
|
||||
export class MemoryService {
|
||||
async generateMemory(): Promise<InfoMemory> {
|
||||
const layout = await memLayout()
|
||||
.then((dims) =>
|
||||
dims.map(
|
||||
(dim, index) =>
|
||||
({
|
||||
...dim,
|
||||
id: `memory-layout-${index}`,
|
||||
}) as MemoryLayout
|
||||
)
|
||||
)
|
||||
.catch(() => []);
|
||||
|
||||
return {
|
||||
id: 'info/memory',
|
||||
layout,
|
||||
};
|
||||
}
|
||||
|
||||
async generateMemoryLoad(): Promise<MemoryUtilization> {
|
||||
const memInfo = await mem();
|
||||
|
||||
return {
|
||||
id: 'memory-utilization',
|
||||
total: Math.floor(memInfo.total),
|
||||
used: Math.floor(memInfo.used),
|
||||
free: Math.floor(memInfo.free),
|
||||
available: Math.floor(memInfo.available),
|
||||
active: Math.floor(memInfo.active),
|
||||
buffcache: Math.floor(memInfo.buffcache),
|
||||
percentTotal:
|
||||
memInfo.total > 0 ? ((memInfo.total - memInfo.available) / memInfo.total) * 100 : 0,
|
||||
swapTotal: Math.floor(memInfo.swaptotal),
|
||||
swapUsed: Math.floor(memInfo.swapused),
|
||||
swapFree: Math.floor(memInfo.swapfree),
|
||||
percentSwapTotal: memInfo.swaptotal > 0 ? (memInfo.swapused / memInfo.swaptotal) * 100 : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
48
api/src/unraid-api/graph/resolvers/info/os/os.model.ts
Normal file
48
api/src/unraid-api/graph/resolvers/info/os/os.model.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { Node } from '@unraid/shared/graphql.model.js';
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoOs extends Node {
|
||||
@Field(() => String, { nullable: true, description: 'Operating system platform' })
|
||||
platform?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Linux distribution name' })
|
||||
distro?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'OS release version' })
|
||||
release?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'OS codename' })
|
||||
codename?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Kernel version' })
|
||||
kernel?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'OS architecture' })
|
||||
arch?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Hostname' })
|
||||
hostname?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Fully qualified domain name' })
|
||||
fqdn?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'OS build identifier' })
|
||||
build?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Service pack version' })
|
||||
servicepack?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Boot time ISO string' })
|
||||
uptime?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'OS logo name' })
|
||||
logofile?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'OS serial number' })
|
||||
serial?: string;
|
||||
|
||||
@Field(() => Boolean, { nullable: true, description: 'OS started via UEFI' })
|
||||
uefi?: boolean | null;
|
||||
}
|
||||
21
api/src/unraid-api/graph/resolvers/info/os/os.service.ts
Normal file
21
api/src/unraid-api/graph/resolvers/info/os/os.service.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { osInfo } from 'systeminformation';
|
||||
|
||||
import { bootTimestamp } from '@app/common/dashboard/boot-timestamp.js';
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { InfoOs } from '@app/unraid-api/graph/resolvers/info/os/os.model.js';
|
||||
|
||||
@Injectable()
|
||||
export class OsService {
|
||||
async generateOs(): Promise<InfoOs> {
|
||||
const os = await osInfo();
|
||||
|
||||
return {
|
||||
id: 'info/os',
|
||||
...os,
|
||||
hostname: getters.emhttp().var.name,
|
||||
uptime: bootTimestamp.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { Node } from '@unraid/shared/graphql.model.js';
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoSystem extends Node {
|
||||
@Field(() => String, { nullable: true, description: 'System manufacturer' })
|
||||
manufacturer?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'System model' })
|
||||
model?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'System version' })
|
||||
version?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'System serial number' })
|
||||
serial?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'System UUID' })
|
||||
uuid?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'System SKU' })
|
||||
sku?: string;
|
||||
|
||||
@Field(() => Boolean, { nullable: true, description: 'Virtual machine flag' })
|
||||
virtual?: boolean;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoBaseboard extends Node {
|
||||
@Field(() => String, { nullable: true, description: 'Motherboard manufacturer' })
|
||||
manufacturer?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Motherboard model' })
|
||||
model?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Motherboard version' })
|
||||
version?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Motherboard serial number' })
|
||||
serial?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Motherboard asset tag' })
|
||||
assetTag?: string;
|
||||
|
||||
@Field(() => Number, { nullable: true, description: 'Maximum memory capacity in bytes' })
|
||||
memMax?: number | null;
|
||||
|
||||
@Field(() => Number, { nullable: true, description: 'Number of memory slots' })
|
||||
memSlots?: number;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { Node } from '@unraid/shared/graphql.model.js';
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoVersions extends Node {
|
||||
@Field(() => String, { nullable: true, description: 'Kernel version' })
|
||||
kernel?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'OpenSSL version' })
|
||||
openssl?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'System OpenSSL version' })
|
||||
systemOpenssl?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Node.js version' })
|
||||
node?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'V8 engine version' })
|
||||
v8?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'npm version' })
|
||||
npm?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Yarn version' })
|
||||
yarn?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'pm2 version' })
|
||||
pm2?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Gulp version' })
|
||||
gulp?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Grunt version' })
|
||||
grunt?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Git version' })
|
||||
git?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'tsc version' })
|
||||
tsc?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'MySQL version' })
|
||||
mysql?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Redis version' })
|
||||
redis?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'MongoDB version' })
|
||||
mongodb?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Apache version' })
|
||||
apache?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'nginx version' })
|
||||
nginx?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'PHP version' })
|
||||
php?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Postfix version' })
|
||||
postfix?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'PostgreSQL version' })
|
||||
postgresql?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Perl version' })
|
||||
perl?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Python version' })
|
||||
python?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Python3 version' })
|
||||
python3?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'pip version' })
|
||||
pip?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'pip3 version' })
|
||||
pip3?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Java version' })
|
||||
java?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'gcc version' })
|
||||
gcc?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'VirtualBox version' })
|
||||
virtualbox?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Docker version' })
|
||||
docker?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Unraid version' })
|
||||
unraid?: string;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { versions } from 'systeminformation';
|
||||
|
||||
import { InfoVersions } from '@app/unraid-api/graph/resolvers/info/versions/versions.model.js';
|
||||
|
||||
@Injectable()
|
||||
export class VersionsService {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
async generateVersions(): Promise<InfoVersions> {
|
||||
const unraid = this.configService.get<string>('store.emhttp.var.version') || 'unknown';
|
||||
const softwareVersions = await versions();
|
||||
|
||||
return {
|
||||
id: 'info/versions',
|
||||
unraid,
|
||||
...softwareVersions,
|
||||
};
|
||||
}
|
||||
}
|
||||
21
api/src/unraid-api/graph/resolvers/metrics/metrics.model.ts
Normal file
21
api/src/unraid-api/graph/resolvers/metrics/metrics.model.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { Node } from '@unraid/shared/graphql.model.js';
|
||||
|
||||
import { CpuUtilization } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js';
|
||||
import { MemoryUtilization } from '@app/unraid-api/graph/resolvers/info/memory/memory.model.js';
|
||||
|
||||
@ObjectType({
|
||||
implements: () => Node,
|
||||
description: 'System metrics including CPU and memory utilization',
|
||||
})
|
||||
export class Metrics extends Node {
|
||||
@Field(() => CpuUtilization, { description: 'Current CPU utilization metrics', nullable: true })
|
||||
cpu?: CpuUtilization;
|
||||
|
||||
@Field(() => MemoryUtilization, {
|
||||
description: 'Current memory utilization metrics',
|
||||
nullable: true,
|
||||
})
|
||||
memory?: MemoryUtilization;
|
||||
}
|
||||
13
api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts
Normal file
13
api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
|
||||
import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js';
|
||||
import { MetricsResolver } from '@app/unraid-api/graph/resolvers/metrics/metrics.resolver.js';
|
||||
import { ServicesModule } from '@app/unraid-api/graph/services/services.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [ServicesModule],
|
||||
providers: [MetricsResolver, CpuService, MemoryService],
|
||||
exports: [MetricsResolver],
|
||||
})
|
||||
export class MetricsModule {}
|
||||
@@ -0,0 +1,272 @@
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
|
||||
import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js';
|
||||
import { MetricsResolver } from '@app/unraid-api/graph/resolvers/metrics/metrics.resolver.js';
|
||||
import { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js';
|
||||
import { SubscriptionPollingService } from '@app/unraid-api/graph/services/subscription-polling.service.js';
|
||||
import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js';
|
||||
|
||||
describe('MetricsResolver Integration Tests', () => {
|
||||
let metricsResolver: MetricsResolver;
|
||||
let module: TestingModule;
|
||||
|
||||
beforeEach(async () => {
|
||||
module = await Test.createTestingModule({
|
||||
imports: [ScheduleModule.forRoot()],
|
||||
providers: [
|
||||
MetricsResolver,
|
||||
CpuService,
|
||||
MemoryService,
|
||||
SubscriptionTrackerService,
|
||||
SubscriptionHelperService,
|
||||
SubscriptionPollingService,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
metricsResolver = module.get<MetricsResolver>(MetricsResolver);
|
||||
// Initialize the module to register polling topics
|
||||
metricsResolver.onModuleInit();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up polling service
|
||||
const pollingService = module.get<SubscriptionPollingService>(SubscriptionPollingService);
|
||||
pollingService.stopAll();
|
||||
await module.close();
|
||||
});
|
||||
|
||||
describe('Metrics Query', () => {
|
||||
it('should return metrics root object', async () => {
|
||||
const result = await metricsResolver.metrics();
|
||||
expect(result).toEqual({
|
||||
id: 'metrics',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return CPU utilization metrics', async () => {
|
||||
const result = await metricsResolver.cpu();
|
||||
|
||||
expect(result).toHaveProperty('id', 'info/cpu-load');
|
||||
expect(result).toHaveProperty('percentTotal');
|
||||
expect(result).toHaveProperty('cpus');
|
||||
expect(result.cpus).toBeInstanceOf(Array);
|
||||
expect(result.percentTotal).toBeGreaterThanOrEqual(0);
|
||||
expect(result.percentTotal).toBeLessThanOrEqual(100);
|
||||
|
||||
if (result.cpus.length > 0) {
|
||||
const firstCpu = result.cpus[0];
|
||||
expect(firstCpu).toHaveProperty('percentTotal');
|
||||
expect(firstCpu).toHaveProperty('percentUser');
|
||||
expect(firstCpu).toHaveProperty('percentSystem');
|
||||
expect(firstCpu).toHaveProperty('percentIdle');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return memory utilization metrics', async () => {
|
||||
const result = await metricsResolver.memory();
|
||||
|
||||
expect(result).toHaveProperty('id', 'memory-utilization');
|
||||
expect(result).toHaveProperty('total');
|
||||
expect(result).toHaveProperty('used');
|
||||
expect(result).toHaveProperty('free');
|
||||
expect(result).toHaveProperty('available');
|
||||
expect(result).toHaveProperty('percentTotal');
|
||||
expect(result).toHaveProperty('swapTotal');
|
||||
expect(result).toHaveProperty('swapUsed');
|
||||
expect(result).toHaveProperty('swapFree');
|
||||
expect(result).toHaveProperty('percentSwapTotal');
|
||||
|
||||
expect(result.total).toBeGreaterThan(0);
|
||||
expect(result.percentTotal).toBeGreaterThanOrEqual(0);
|
||||
expect(result.percentTotal).toBeLessThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Polling Mechanism', () => {
|
||||
it('should prevent concurrent CPU polling executions', async () => {
|
||||
const trackerService = module.get<SubscriptionTrackerService>(SubscriptionTrackerService);
|
||||
const cpuService = module.get<CpuService>(CpuService);
|
||||
let executionCount = 0;
|
||||
|
||||
vi.spyOn(cpuService, 'generateCpuLoad').mockImplementation(async () => {
|
||||
executionCount++;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50)); // Simulate slow operation
|
||||
return {
|
||||
id: 'info/cpu-load',
|
||||
percentTotal: 50,
|
||||
cpus: [],
|
||||
};
|
||||
});
|
||||
|
||||
// Trigger polling by simulating subscription
|
||||
trackerService.subscribe(PUBSUB_CHANNEL.CPU_UTILIZATION);
|
||||
|
||||
// Wait a bit for potential multiple executions
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Should only execute once despite potential concurrent attempts
|
||||
expect(executionCount).toBeLessThanOrEqual(2); // Allow for initial execution
|
||||
});
|
||||
|
||||
it('should prevent concurrent memory polling executions', async () => {
|
||||
const trackerService = module.get<SubscriptionTrackerService>(SubscriptionTrackerService);
|
||||
const memoryService = module.get<MemoryService>(MemoryService);
|
||||
let executionCount = 0;
|
||||
|
||||
vi.spyOn(memoryService, 'generateMemoryLoad').mockImplementation(async () => {
|
||||
executionCount++;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50)); // Simulate slow operation
|
||||
return {
|
||||
id: 'memory-utilization',
|
||||
total: 16000000000,
|
||||
used: 8000000000,
|
||||
free: 8000000000,
|
||||
available: 8000000000,
|
||||
active: 4000000000,
|
||||
buffcache: 2000000000,
|
||||
percentTotal: 50,
|
||||
swapTotal: 0,
|
||||
swapUsed: 0,
|
||||
swapFree: 0,
|
||||
percentSwapTotal: 0,
|
||||
} as any;
|
||||
});
|
||||
|
||||
// Trigger polling by simulating subscription
|
||||
trackerService.subscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION);
|
||||
|
||||
// Wait a bit for potential multiple executions
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Should only execute once despite potential concurrent attempts
|
||||
expect(executionCount).toBeLessThanOrEqual(2); // Allow for initial execution
|
||||
});
|
||||
|
||||
it('should publish CPU metrics to pubsub', async () => {
|
||||
const publishSpy = vi.spyOn(pubsub, 'publish');
|
||||
const trackerService = module.get<SubscriptionTrackerService>(SubscriptionTrackerService);
|
||||
|
||||
// Trigger polling by starting subscription
|
||||
trackerService.subscribe(PUBSUB_CHANNEL.CPU_UTILIZATION);
|
||||
|
||||
// Wait for the polling interval to trigger (1000ms for CPU)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
expect(publishSpy).toHaveBeenCalledWith(
|
||||
PUBSUB_CHANNEL.CPU_UTILIZATION,
|
||||
expect.objectContaining({
|
||||
systemMetricsCpu: expect.objectContaining({
|
||||
id: 'info/cpu-load',
|
||||
percentTotal: expect.any(Number),
|
||||
cpus: expect.any(Array),
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
trackerService.unsubscribe(PUBSUB_CHANNEL.CPU_UTILIZATION);
|
||||
publishSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should publish memory metrics to pubsub', async () => {
|
||||
const publishSpy = vi.spyOn(pubsub, 'publish');
|
||||
const trackerService = module.get<SubscriptionTrackerService>(SubscriptionTrackerService);
|
||||
|
||||
// Trigger polling by starting subscription
|
||||
trackerService.subscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION);
|
||||
|
||||
// Wait for the polling interval to trigger (2000ms for memory)
|
||||
await new Promise((resolve) => setTimeout(resolve, 2100));
|
||||
|
||||
expect(publishSpy).toHaveBeenCalledWith(
|
||||
PUBSUB_CHANNEL.MEMORY_UTILIZATION,
|
||||
expect.objectContaining({
|
||||
systemMetricsMemory: expect.objectContaining({
|
||||
id: 'memory-utilization',
|
||||
used: expect.any(Number),
|
||||
free: expect.any(Number),
|
||||
percentTotal: expect.any(Number),
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
trackerService.unsubscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION);
|
||||
publishSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle errors in CPU polling gracefully', async () => {
|
||||
const service = module.get<CpuService>(CpuService);
|
||||
const trackerService = module.get<SubscriptionTrackerService>(SubscriptionTrackerService);
|
||||
const pollingService = module.get<SubscriptionPollingService>(SubscriptionPollingService);
|
||||
|
||||
// Mock logger to capture error logs
|
||||
const loggerSpy = vi.spyOn(pollingService['logger'], 'error').mockImplementation(() => {});
|
||||
vi.spyOn(service, 'generateCpuLoad').mockRejectedValueOnce(new Error('CPU error'));
|
||||
|
||||
// Trigger polling
|
||||
trackerService.subscribe(PUBSUB_CHANNEL.CPU_UTILIZATION);
|
||||
|
||||
// Wait for polling interval to trigger and handle error (1000ms for CPU)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
expect(loggerSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Error in polling task'),
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
trackerService.unsubscribe(PUBSUB_CHANNEL.CPU_UTILIZATION);
|
||||
loggerSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle errors in memory polling gracefully', async () => {
|
||||
const service = module.get<MemoryService>(MemoryService);
|
||||
const trackerService = module.get<SubscriptionTrackerService>(SubscriptionTrackerService);
|
||||
const pollingService = module.get<SubscriptionPollingService>(SubscriptionPollingService);
|
||||
|
||||
// Mock logger to capture error logs
|
||||
const loggerSpy = vi.spyOn(pollingService['logger'], 'error').mockImplementation(() => {});
|
||||
vi.spyOn(service, 'generateMemoryLoad').mockRejectedValueOnce(new Error('Memory error'));
|
||||
|
||||
// Trigger polling
|
||||
trackerService.subscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION);
|
||||
|
||||
// Wait for polling interval to trigger and handle error (2000ms for memory)
|
||||
await new Promise((resolve) => setTimeout(resolve, 2100));
|
||||
|
||||
expect(loggerSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Error in polling task'),
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
trackerService.unsubscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION);
|
||||
loggerSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Polling cleanup on module destroy', () => {
|
||||
it('should clean up timers when module is destroyed', async () => {
|
||||
const trackerService = module.get<SubscriptionTrackerService>(SubscriptionTrackerService);
|
||||
const pollingService = module.get<SubscriptionPollingService>(SubscriptionPollingService);
|
||||
|
||||
// Start polling
|
||||
trackerService.subscribe(PUBSUB_CHANNEL.CPU_UTILIZATION);
|
||||
trackerService.subscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION);
|
||||
|
||||
// Verify polling is active
|
||||
expect(pollingService.isPolling(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(true);
|
||||
expect(pollingService.isPolling(PUBSUB_CHANNEL.MEMORY_UTILIZATION)).toBe(true);
|
||||
|
||||
// Clean up the module
|
||||
await module.close();
|
||||
|
||||
// Timers should be cleaned up
|
||||
expect(pollingService.isPolling(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(false);
|
||||
expect(pollingService.isPolling(PUBSUB_CHANNEL.MEMORY_UTILIZATION)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
|
||||
import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js';
|
||||
import { MetricsResolver } from '@app/unraid-api/graph/resolvers/metrics/metrics.resolver.js';
|
||||
import { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js';
|
||||
import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js';
|
||||
|
||||
describe('MetricsResolver', () => {
|
||||
let resolver: MetricsResolver;
|
||||
let cpuService: CpuService;
|
||||
let memoryService: MemoryService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
MetricsResolver,
|
||||
{
|
||||
provide: CpuService,
|
||||
useValue: {
|
||||
generateCpuLoad: vi.fn().mockResolvedValue({
|
||||
id: 'info/cpu-load',
|
||||
load: 25.5,
|
||||
cpus: [
|
||||
{
|
||||
load: 30.0,
|
||||
loadUser: 20.0,
|
||||
loadSystem: 10.0,
|
||||
loadNice: 0,
|
||||
loadIdle: 70.0,
|
||||
loadIrq: 0,
|
||||
},
|
||||
{
|
||||
load: 21.0,
|
||||
loadUser: 15.0,
|
||||
loadSystem: 6.0,
|
||||
loadNice: 0,
|
||||
loadIdle: 79.0,
|
||||
loadIrq: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MemoryService,
|
||||
useValue: {
|
||||
generateMemoryLoad: vi.fn().mockResolvedValue({
|
||||
id: 'memory-utilization',
|
||||
total: 16777216000,
|
||||
used: 8388608000,
|
||||
free: 8388608000,
|
||||
available: 10000000000,
|
||||
active: 5000000000,
|
||||
buffcache: 2000000000,
|
||||
usedPercent: 50.0,
|
||||
swapTotal: 4294967296,
|
||||
swapUsed: 0,
|
||||
swapFree: 4294967296,
|
||||
swapUsedPercent: 0,
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SubscriptionTrackerService,
|
||||
useValue: {
|
||||
registerTopic: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SubscriptionHelperService,
|
||||
useValue: {
|
||||
createTrackedSubscription: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
resolver = module.get<MetricsResolver>(MetricsResolver);
|
||||
cpuService = module.get<CpuService>(CpuService);
|
||||
memoryService = module.get<MemoryService>(MemoryService);
|
||||
});
|
||||
|
||||
describe('metrics', () => {
|
||||
it('should return basic metrics object', async () => {
|
||||
const result = await resolver.metrics();
|
||||
expect(result).toEqual({
|
||||
id: 'metrics',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cpu', () => {
|
||||
it('should return CPU utilization data', async () => {
|
||||
const result = await resolver.cpu();
|
||||
|
||||
expect(cpuService.generateCpuLoad).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
id: 'info/cpu-load',
|
||||
load: 25.5,
|
||||
cpus: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
load: 30.0,
|
||||
loadUser: 20.0,
|
||||
loadSystem: 10.0,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
load: 21.0,
|
||||
loadUser: 15.0,
|
||||
loadSystem: 6.0,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle CPU service errors gracefully', async () => {
|
||||
vi.mocked(cpuService.generateCpuLoad).mockRejectedValueOnce(new Error('CPU error'));
|
||||
|
||||
await expect(resolver.cpu()).rejects.toThrow('CPU error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory', () => {
|
||||
it('should return memory utilization data', async () => {
|
||||
const result = await resolver.memory();
|
||||
|
||||
expect(memoryService.generateMemoryLoad).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
id: 'memory-utilization',
|
||||
total: 16777216000,
|
||||
used: 8388608000,
|
||||
free: 8388608000,
|
||||
available: 10000000000,
|
||||
active: 5000000000,
|
||||
buffcache: 2000000000,
|
||||
usedPercent: 50.0,
|
||||
swapTotal: 4294967296,
|
||||
swapUsed: 0,
|
||||
swapFree: 4294967296,
|
||||
swapUsedPercent: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle memory service errors gracefully', async () => {
|
||||
vi.mocked(memoryService.generateMemoryLoad).mockRejectedValueOnce(new Error('Memory error'));
|
||||
|
||||
await expect(resolver.memory()).rejects.toThrow('Memory error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onModuleInit', () => {
|
||||
it('should register CPU and memory polling topics', () => {
|
||||
const subscriptionTracker = {
|
||||
registerTopic: vi.fn(),
|
||||
};
|
||||
|
||||
const testModule = new MetricsResolver(
|
||||
cpuService,
|
||||
memoryService,
|
||||
subscriptionTracker as any,
|
||||
{} as any
|
||||
);
|
||||
|
||||
testModule.onModuleInit();
|
||||
|
||||
expect(subscriptionTracker.registerTopic).toHaveBeenCalledTimes(2);
|
||||
expect(subscriptionTracker.registerTopic).toHaveBeenCalledWith(
|
||||
'CPU_UTILIZATION',
|
||||
expect.any(Function),
|
||||
1000
|
||||
);
|
||||
expect(subscriptionTracker.registerTopic).toHaveBeenCalledWith(
|
||||
'MEMORY_UTILIZATION',
|
||||
expect.any(Function),
|
||||
2000
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { OnModuleInit } from '@nestjs/common';
|
||||
import { Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { CpuUtilization } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js';
|
||||
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
|
||||
import { MemoryUtilization } from '@app/unraid-api/graph/resolvers/info/memory/memory.model.js';
|
||||
import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js';
|
||||
import { Metrics } from '@app/unraid-api/graph/resolvers/metrics/metrics.model.js';
|
||||
import { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js';
|
||||
import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js';
|
||||
|
||||
@Resolver(() => Metrics)
|
||||
export class MetricsResolver implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly cpuService: CpuService,
|
||||
private readonly memoryService: MemoryService,
|
||||
private readonly subscriptionTracker: SubscriptionTrackerService,
|
||||
private readonly subscriptionHelper: SubscriptionHelperService
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
// Register CPU polling with 1 second interval
|
||||
this.subscriptionTracker.registerTopic(
|
||||
PUBSUB_CHANNEL.CPU_UTILIZATION,
|
||||
async () => {
|
||||
const payload = await this.cpuService.generateCpuLoad();
|
||||
pubsub.publish(PUBSUB_CHANNEL.CPU_UTILIZATION, { systemMetricsCpu: payload });
|
||||
},
|
||||
1000
|
||||
);
|
||||
|
||||
// Register memory polling with 2 second interval
|
||||
this.subscriptionTracker.registerTopic(
|
||||
PUBSUB_CHANNEL.MEMORY_UTILIZATION,
|
||||
async () => {
|
||||
const payload = await this.memoryService.generateMemoryLoad();
|
||||
pubsub.publish(PUBSUB_CHANNEL.MEMORY_UTILIZATION, { systemMetricsMemory: payload });
|
||||
},
|
||||
2000
|
||||
);
|
||||
}
|
||||
|
||||
@Query(() => Metrics)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.INFO,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async metrics(): Promise<Partial<Metrics>> {
|
||||
return {
|
||||
id: 'metrics',
|
||||
};
|
||||
}
|
||||
|
||||
@ResolveField(() => CpuUtilization, { nullable: true })
|
||||
public async cpu(): Promise<CpuUtilization> {
|
||||
return this.cpuService.generateCpuLoad();
|
||||
}
|
||||
|
||||
@ResolveField(() => MemoryUtilization, { nullable: true })
|
||||
public async memory(): Promise<MemoryUtilization> {
|
||||
return this.memoryService.generateMemoryLoad();
|
||||
}
|
||||
|
||||
@Subscription(() => CpuUtilization, {
|
||||
name: 'systemMetricsCpu',
|
||||
resolve: (value) => value.systemMetricsCpu,
|
||||
})
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.INFO,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async systemMetricsCpuSubscription() {
|
||||
return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION);
|
||||
}
|
||||
|
||||
@Subscription(() => MemoryUtilization, {
|
||||
name: 'systemMetricsMemory',
|
||||
resolve: (value) => value.systemMetricsMemory,
|
||||
})
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.INFO,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async systemMetricsMemorySubscription() {
|
||||
return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.MEMORY_UTILIZATION);
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,13 @@ import { ArrayModule } from '@app/unraid-api/graph/resolvers/array/array.module.
|
||||
import { ConfigResolver } from '@app/unraid-api/graph/resolvers/config/config.resolver.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 { DisplayResolver } from '@app/unraid-api/graph/resolvers/display/display.resolver.js';
|
||||
import { DisplayService } from '@app/unraid-api/graph/resolvers/display/display.service.js';
|
||||
import { DockerModule } from '@app/unraid-api/graph/resolvers/docker/docker.module.js';
|
||||
import { FlashBackupModule } from '@app/unraid-api/graph/resolvers/flash-backup/flash-backup.module.js';
|
||||
import { FlashResolver } from '@app/unraid-api/graph/resolvers/flash/flash.resolver.js';
|
||||
import { DevicesResolver } from '@app/unraid-api/graph/resolvers/info/devices.resolver.js';
|
||||
import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices.service.js';
|
||||
import { InfoResolver } from '@app/unraid-api/graph/resolvers/info/info.resolver.js';
|
||||
import { InfoService } from '@app/unraid-api/graph/resolvers/info/info.service.js';
|
||||
import { InfoModule } from '@app/unraid-api/graph/resolvers/info/info.module.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 { MetricsModule } from '@app/unraid-api/graph/resolvers/metrics/metrics.module.js';
|
||||
import { RootMutationsResolver } from '@app/unraid-api/graph/resolvers/mutation/mutation.resolver.js';
|
||||
import { NotificationsResolver } from '@app/unraid-api/graph/resolvers/notifications/notifications.resolver.js';
|
||||
import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js';
|
||||
@@ -33,12 +29,14 @@ import { VarsResolver } from '@app/unraid-api/graph/resolvers/vars/vars.resolver
|
||||
import { VmMutationsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.mutations.resolver.js';
|
||||
import { VmsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.resolver.js';
|
||||
import { VmsService } from '@app/unraid-api/graph/resolvers/vms/vms.service.js';
|
||||
import { ServicesModule } from '@app/unraid-api/graph/services/services.module.js';
|
||||
import { ServicesResolver } from '@app/unraid-api/graph/services/services.resolver.js';
|
||||
import { SharesResolver } from '@app/unraid-api/graph/shares/shares.resolver.js';
|
||||
import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ServicesModule,
|
||||
ArrayModule,
|
||||
ApiKeyModule,
|
||||
AuthModule,
|
||||
@@ -46,20 +44,16 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js';
|
||||
DockerModule,
|
||||
DisksModule,
|
||||
FlashBackupModule,
|
||||
InfoModule,
|
||||
RCloneModule,
|
||||
SettingsModule,
|
||||
SsoModule,
|
||||
MetricsModule,
|
||||
UPSModule,
|
||||
],
|
||||
providers: [
|
||||
ConfigResolver,
|
||||
DevicesResolver,
|
||||
DevicesService,
|
||||
DisplayResolver,
|
||||
DisplayService,
|
||||
FlashResolver,
|
||||
InfoResolver,
|
||||
InfoService,
|
||||
LogsResolver,
|
||||
LogsService,
|
||||
MeResolver,
|
||||
|
||||
13
api/src/unraid-api/graph/services/services.module.ts
Normal file
13
api/src/unraid-api/graph/services/services.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
|
||||
import { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js';
|
||||
import { SubscriptionPollingService } from '@app/unraid-api/graph/services/subscription-polling.service.js';
|
||||
import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: [SubscriptionTrackerService, SubscriptionHelperService, SubscriptionPollingService],
|
||||
exports: [SubscriptionTrackerService, SubscriptionHelperService, SubscriptionPollingService],
|
||||
})
|
||||
export class ServicesModule {}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { PubSub } from 'graphql-subscriptions';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js';
|
||||
import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js';
|
||||
|
||||
describe('SubscriptionHelperService', () => {
|
||||
let helperService: SubscriptionHelperService;
|
||||
let trackerService: SubscriptionTrackerService;
|
||||
let loggerSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockPollingService = {
|
||||
startPolling: vi.fn(),
|
||||
stopPolling: vi.fn(),
|
||||
};
|
||||
trackerService = new SubscriptionTrackerService(mockPollingService as any);
|
||||
helperService = new SubscriptionHelperService(trackerService);
|
||||
loggerSpy = vi.spyOn(Logger.prototype, 'debug').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createTrackedSubscription', () => {
|
||||
it('should create an async iterator that tracks subscriptions', async () => {
|
||||
const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION);
|
||||
|
||||
expect(iterator).toBeDefined();
|
||||
expect(iterator.next).toBeDefined();
|
||||
expect(iterator.return).toBeDefined();
|
||||
expect(iterator.throw).toBeDefined();
|
||||
expect(iterator[Symbol.asyncIterator]).toBeDefined();
|
||||
|
||||
// Should have subscribed
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1);
|
||||
});
|
||||
|
||||
it('should return itself when Symbol.asyncIterator is called', () => {
|
||||
const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION);
|
||||
|
||||
expect(iterator[Symbol.asyncIterator]()).toBe(iterator);
|
||||
});
|
||||
|
||||
it('should unsubscribe when return() is called', async () => {
|
||||
const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION);
|
||||
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1);
|
||||
|
||||
await iterator.return?.();
|
||||
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0);
|
||||
});
|
||||
|
||||
it('should unsubscribe when throw() is called', async () => {
|
||||
const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION);
|
||||
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1);
|
||||
|
||||
try {
|
||||
await iterator.throw?.(new Error('Test error'));
|
||||
} catch (e) {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with pubsub', () => {
|
||||
it('should receive published messages', async () => {
|
||||
const iterator = helperService.createTrackedSubscription<{ cpuUtilization: any }>(
|
||||
PUBSUB_CHANNEL.CPU_UTILIZATION
|
||||
);
|
||||
|
||||
const testData = {
|
||||
cpuUtilization: {
|
||||
id: 'test',
|
||||
load: 50,
|
||||
cpus: [],
|
||||
},
|
||||
};
|
||||
|
||||
// Set up the consumption promise first
|
||||
const consumePromise = iterator.next();
|
||||
|
||||
// Give a small delay to ensure subscription is fully set up
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// Publish a message
|
||||
await (pubsub as PubSub).publish(PUBSUB_CHANNEL.CPU_UTILIZATION, testData);
|
||||
|
||||
// Wait for the message
|
||||
const result = await consumePromise;
|
||||
|
||||
expect(result.done).toBe(false);
|
||||
expect(result.value).toEqual(testData);
|
||||
|
||||
await iterator.return?.();
|
||||
});
|
||||
|
||||
it('should handle multiple subscribers independently', async () => {
|
||||
// Register handlers to verify start/stop behavior
|
||||
const onStart = vi.fn();
|
||||
const onStop = vi.fn();
|
||||
trackerService.registerTopic(PUBSUB_CHANNEL.CPU_UTILIZATION, onStart, onStop);
|
||||
|
||||
// Create first subscriber
|
||||
const iterator1 = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION);
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1);
|
||||
expect(onStart).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Create second subscriber
|
||||
const iterator2 = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION);
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(2);
|
||||
expect(onStart).toHaveBeenCalledTimes(1); // Should not call again
|
||||
|
||||
// Create third subscriber
|
||||
const iterator3 = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION);
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(3);
|
||||
|
||||
// Set up consumption promises first
|
||||
const consume1 = iterator1.next();
|
||||
const consume2 = iterator2.next();
|
||||
const consume3 = iterator3.next();
|
||||
|
||||
// Give a small delay to ensure subscriptions are fully set up
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// Publish a message - all should receive it
|
||||
const testData = { cpuUtilization: { id: 'test', load: 75, cpus: [] } };
|
||||
await (pubsub as PubSub).publish(PUBSUB_CHANNEL.CPU_UTILIZATION, testData);
|
||||
|
||||
const [result1, result2, result3] = await Promise.all([consume1, consume2, consume3]);
|
||||
|
||||
expect(result1.value).toEqual(testData);
|
||||
expect(result2.value).toEqual(testData);
|
||||
expect(result3.value).toEqual(testData);
|
||||
|
||||
// Clean up first subscriber
|
||||
await iterator1.return?.();
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(2);
|
||||
expect(onStop).not.toHaveBeenCalled();
|
||||
|
||||
// Clean up second subscriber
|
||||
await iterator2.return?.();
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1);
|
||||
expect(onStop).not.toHaveBeenCalled();
|
||||
|
||||
// Clean up last subscriber - should trigger onStop
|
||||
await iterator3.return?.();
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0);
|
||||
expect(onStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle rapid subscribe/unsubscribe cycles', async () => {
|
||||
const iterations = 10;
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION);
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1);
|
||||
|
||||
await iterator.return?.();
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should properly clean up on error', async () => {
|
||||
const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION);
|
||||
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1);
|
||||
|
||||
const testError = new Error('Test error');
|
||||
|
||||
try {
|
||||
await iterator.throw?.(testError);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (error) {
|
||||
expect(error).toBe(testError);
|
||||
}
|
||||
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0);
|
||||
});
|
||||
|
||||
it('should log debug messages for subscription lifecycle', async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION);
|
||||
|
||||
expect(loggerSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Subscription added for topic')
|
||||
);
|
||||
|
||||
await iterator.return?.();
|
||||
|
||||
expect(loggerSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Subscription removed for topic')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('different topic types', () => {
|
||||
it('should handle INFO channel subscriptions', async () => {
|
||||
const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.INFO);
|
||||
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.INFO)).toBe(1);
|
||||
|
||||
// Set up consumption promise first
|
||||
const consumePromise = iterator.next();
|
||||
|
||||
// Give a small delay to ensure subscription is fully set up
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const testData = { info: { id: 'test-info' } };
|
||||
await (pubsub as PubSub).publish(PUBSUB_CHANNEL.INFO, testData);
|
||||
|
||||
const result = await consumePromise;
|
||||
expect(result.value).toEqual(testData);
|
||||
|
||||
await iterator.return?.();
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.INFO)).toBe(0);
|
||||
});
|
||||
|
||||
it('should track multiple different topics independently', async () => {
|
||||
const cpuIterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION);
|
||||
const infoIterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.INFO);
|
||||
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1);
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.INFO)).toBe(1);
|
||||
|
||||
const allCounts = trackerService.getAllSubscriberCounts();
|
||||
expect(allCounts.get(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1);
|
||||
expect(allCounts.get(PUBSUB_CHANNEL.INFO)).toBe(1);
|
||||
|
||||
await cpuIterator.return?.();
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0);
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.INFO)).toBe(1);
|
||||
|
||||
await infoIterator.return?.();
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.INFO)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle return() called multiple times', async () => {
|
||||
const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION);
|
||||
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(1);
|
||||
|
||||
await iterator.return?.();
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0);
|
||||
|
||||
// Second return should be idempotent
|
||||
await iterator.return?.();
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0);
|
||||
|
||||
// Check that idempotent message was logged
|
||||
expect(loggerSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('no active subscribers (idempotent)')
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle async iterator protocol correctly', async () => {
|
||||
const iterator = helperService.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION);
|
||||
|
||||
// Test that it works in for-await loop (would use Symbol.asyncIterator)
|
||||
const receivedMessages: any[] = [];
|
||||
const maxMessages = 3;
|
||||
|
||||
// Start consuming in background
|
||||
const consumePromise = (async () => {
|
||||
let count = 0;
|
||||
for await (const message of iterator) {
|
||||
receivedMessages.push(message);
|
||||
count++;
|
||||
if (count >= maxMessages) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// Publish messages
|
||||
for (let i = 0; i < maxMessages; i++) {
|
||||
await (pubsub as PubSub).publish(PUBSUB_CHANNEL.CPU_UTILIZATION, {
|
||||
cpuUtilization: { id: `test-${i}`, load: i * 10, cpus: [] },
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for consumption to complete
|
||||
await consumePromise;
|
||||
|
||||
expect(receivedMessages).toHaveLength(maxMessages);
|
||||
expect(receivedMessages[0].cpuUtilization.load).toBe(0);
|
||||
expect(receivedMessages[1].cpuUtilization.load).toBe(10);
|
||||
expect(receivedMessages[2].cpuUtilization.load).toBe(20);
|
||||
|
||||
// Clean up
|
||||
await iterator.return?.();
|
||||
expect(trackerService.getSubscriberCount(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js';
|
||||
|
||||
/**
|
||||
* Helper service for creating tracked GraphQL subscriptions with automatic cleanup
|
||||
*/
|
||||
@Injectable()
|
||||
export class SubscriptionHelperService {
|
||||
constructor(private readonly subscriptionTracker: SubscriptionTrackerService) {}
|
||||
|
||||
/**
|
||||
* Creates a tracked async iterator that automatically handles subscription/unsubscription
|
||||
* @param topic The subscription topic/channel to subscribe to
|
||||
* @returns A proxy async iterator with automatic cleanup
|
||||
*/
|
||||
public createTrackedSubscription<T = any>(topic: PUBSUB_CHANNEL): AsyncIterableIterator<T> {
|
||||
const innerIterator = createSubscription<T>(topic);
|
||||
|
||||
// Subscribe when the subscription starts
|
||||
this.subscriptionTracker.subscribe(topic);
|
||||
|
||||
// Return a proxy async iterator that properly handles cleanup
|
||||
const proxyIterator: AsyncIterableIterator<T> = {
|
||||
next: () => innerIterator.next(),
|
||||
|
||||
return: async () => {
|
||||
// Cleanup: unsubscribe from tracker
|
||||
this.subscriptionTracker.unsubscribe(topic);
|
||||
|
||||
// Forward the return call to inner iterator
|
||||
if (innerIterator.return) {
|
||||
return innerIterator.return();
|
||||
}
|
||||
return Promise.resolve({ value: undefined, done: true });
|
||||
},
|
||||
|
||||
throw: async (error?: any) => {
|
||||
// Cleanup: unsubscribe from tracker on error
|
||||
this.subscriptionTracker.unsubscribe(topic);
|
||||
|
||||
// Forward the throw call to inner iterator
|
||||
if (innerIterator.throw) {
|
||||
return innerIterator.throw(error);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
|
||||
// The proxy iterator returns itself for Symbol.asyncIterator
|
||||
[Symbol.asyncIterator]: () => proxyIterator,
|
||||
};
|
||||
|
||||
return proxyIterator;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
|
||||
export interface PollingConfig {
|
||||
name: string;
|
||||
intervalMs: number;
|
||||
callback: () => Promise<void>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SubscriptionPollingService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(SubscriptionPollingService.name);
|
||||
private readonly activePollers = new Map<string, { isPolling: boolean }>();
|
||||
|
||||
constructor(private readonly schedulerRegistry: SchedulerRegistry) {}
|
||||
|
||||
onModuleDestroy() {
|
||||
this.stopAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for a specific subscription topic
|
||||
*/
|
||||
startPolling(config: PollingConfig): void {
|
||||
const { name, intervalMs, callback } = config;
|
||||
|
||||
// Clean up any existing interval
|
||||
this.stopPolling(name);
|
||||
|
||||
// Initialize polling state
|
||||
this.activePollers.set(name, { isPolling: false });
|
||||
|
||||
// Create the polling function with guard against overlapping executions
|
||||
const pollFunction = async () => {
|
||||
const poller = this.activePollers.get(name);
|
||||
if (!poller || poller.isPolling) {
|
||||
return;
|
||||
}
|
||||
|
||||
poller.isPolling = true;
|
||||
try {
|
||||
await callback();
|
||||
} catch (error) {
|
||||
this.logger.error(`Error in polling task '${name}'`, error);
|
||||
} finally {
|
||||
if (poller) {
|
||||
poller.isPolling = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create and register the interval
|
||||
const interval = setInterval(pollFunction, intervalMs);
|
||||
this.schedulerRegistry.addInterval(name, interval);
|
||||
|
||||
this.logger.debug(`Started polling for '${name}' every ${intervalMs}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop polling for a specific subscription topic
|
||||
*/
|
||||
stopPolling(name: string): void {
|
||||
try {
|
||||
if (this.schedulerRegistry.doesExist('interval', name)) {
|
||||
this.schedulerRegistry.deleteInterval(name);
|
||||
this.logger.debug(`Stopped polling for '${name}'`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Interval doesn't exist, which is fine
|
||||
}
|
||||
|
||||
// Clean up polling state
|
||||
this.activePollers.delete(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all active polling tasks
|
||||
*/
|
||||
stopAll(): void {
|
||||
const intervals = this.schedulerRegistry.getIntervals();
|
||||
intervals.forEach((key) => this.stopPolling(key));
|
||||
this.activePollers.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if polling is active for a given name
|
||||
*/
|
||||
isPolling(name: string): boolean {
|
||||
return this.schedulerRegistry.doesExist('interval', name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js';
|
||||
|
||||
describe('SubscriptionTrackerService', () => {
|
||||
let service: SubscriptionTrackerService;
|
||||
let loggerSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockPollingService = {
|
||||
startPolling: vi.fn(),
|
||||
stopPolling: vi.fn(),
|
||||
};
|
||||
service = new SubscriptionTrackerService(mockPollingService as any);
|
||||
// Spy on logger methods
|
||||
loggerSpy = vi.spyOn(Logger.prototype, 'debug').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('registerTopic', () => {
|
||||
it('should register topic handlers', () => {
|
||||
const onStart = vi.fn();
|
||||
const onStop = vi.fn();
|
||||
|
||||
service.registerTopic('TEST_TOPIC', onStart, onStop);
|
||||
|
||||
// Verify handlers are stored (indirectly through subscribe/unsubscribe)
|
||||
service.subscribe('TEST_TOPIC');
|
||||
expect(onStart).toHaveBeenCalledTimes(1);
|
||||
|
||||
service.unsubscribe('TEST_TOPIC');
|
||||
expect(onStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribe', () => {
|
||||
it('should increment subscriber count', () => {
|
||||
service.subscribe('TEST_TOPIC');
|
||||
expect(service.getSubscriberCount('TEST_TOPIC')).toBe(1);
|
||||
|
||||
service.subscribe('TEST_TOPIC');
|
||||
expect(service.getSubscriberCount('TEST_TOPIC')).toBe(2);
|
||||
|
||||
service.subscribe('TEST_TOPIC');
|
||||
expect(service.getSubscriberCount('TEST_TOPIC')).toBe(3);
|
||||
});
|
||||
|
||||
it('should call onStart handler only for first subscriber', () => {
|
||||
const onStart = vi.fn();
|
||||
const onStop = vi.fn();
|
||||
|
||||
service.registerTopic('TEST_TOPIC', onStart, onStop);
|
||||
|
||||
// First subscriber should trigger onStart
|
||||
service.subscribe('TEST_TOPIC');
|
||||
expect(onStart).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Additional subscribers should not trigger onStart
|
||||
service.subscribe('TEST_TOPIC');
|
||||
service.subscribe('TEST_TOPIC');
|
||||
expect(onStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should log subscription events', () => {
|
||||
service.subscribe('TEST_TOPIC');
|
||||
expect(loggerSpy).toHaveBeenCalledWith(
|
||||
"Subscription added for topic 'TEST_TOPIC': 1 active subscriber(s)"
|
||||
);
|
||||
|
||||
service.subscribe('TEST_TOPIC');
|
||||
expect(loggerSpy).toHaveBeenCalledWith(
|
||||
"Subscription added for topic 'TEST_TOPIC': 2 active subscriber(s)"
|
||||
);
|
||||
});
|
||||
|
||||
it('should log when starting a topic', () => {
|
||||
const onStart = vi.fn();
|
||||
const onStop = vi.fn();
|
||||
|
||||
service.registerTopic('TEST_TOPIC', onStart, onStop);
|
||||
service.subscribe('TEST_TOPIC');
|
||||
|
||||
expect(loggerSpy).toHaveBeenCalledWith("Starting topic 'TEST_TOPIC' (first subscriber)");
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsubscribe', () => {
|
||||
it('should decrement subscriber count', () => {
|
||||
service.subscribe('TEST_TOPIC');
|
||||
service.subscribe('TEST_TOPIC');
|
||||
service.subscribe('TEST_TOPIC');
|
||||
expect(service.getSubscriberCount('TEST_TOPIC')).toBe(3);
|
||||
|
||||
service.unsubscribe('TEST_TOPIC');
|
||||
expect(service.getSubscriberCount('TEST_TOPIC')).toBe(2);
|
||||
|
||||
service.unsubscribe('TEST_TOPIC');
|
||||
expect(service.getSubscriberCount('TEST_TOPIC')).toBe(1);
|
||||
|
||||
service.unsubscribe('TEST_TOPIC');
|
||||
expect(service.getSubscriberCount('TEST_TOPIC')).toBe(0);
|
||||
});
|
||||
|
||||
it('should call onStop handler only when last subscriber unsubscribes', () => {
|
||||
const onStart = vi.fn();
|
||||
const onStop = vi.fn();
|
||||
|
||||
service.registerTopic('TEST_TOPIC', onStart, onStop);
|
||||
|
||||
service.subscribe('TEST_TOPIC');
|
||||
service.subscribe('TEST_TOPIC');
|
||||
service.subscribe('TEST_TOPIC');
|
||||
|
||||
service.unsubscribe('TEST_TOPIC');
|
||||
expect(onStop).not.toHaveBeenCalled();
|
||||
|
||||
service.unsubscribe('TEST_TOPIC');
|
||||
expect(onStop).not.toHaveBeenCalled();
|
||||
|
||||
service.unsubscribe('TEST_TOPIC');
|
||||
expect(onStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should be idempotent when called with no subscribers', () => {
|
||||
const onStart = vi.fn();
|
||||
const onStop = vi.fn();
|
||||
|
||||
service.registerTopic('TEST_TOPIC', onStart, onStop);
|
||||
|
||||
// Unsubscribe without any subscribers
|
||||
service.unsubscribe('TEST_TOPIC');
|
||||
expect(onStop).not.toHaveBeenCalled();
|
||||
expect(service.getSubscriberCount('TEST_TOPIC')).toBe(0);
|
||||
|
||||
// Should log idempotent message
|
||||
expect(loggerSpy).toHaveBeenCalledWith(
|
||||
"Unsubscribe called for topic 'TEST_TOPIC' but no active subscribers (idempotent)"
|
||||
);
|
||||
});
|
||||
|
||||
it('should log unsubscription events', () => {
|
||||
service.subscribe('TEST_TOPIC');
|
||||
service.subscribe('TEST_TOPIC');
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
service.unsubscribe('TEST_TOPIC');
|
||||
expect(loggerSpy).toHaveBeenCalledWith(
|
||||
"Subscription removed for topic 'TEST_TOPIC': 1 active subscriber(s) remaining"
|
||||
);
|
||||
|
||||
service.unsubscribe('TEST_TOPIC');
|
||||
expect(loggerSpy).toHaveBeenCalledWith(
|
||||
"Subscription removed for topic 'TEST_TOPIC': 0 active subscriber(s) remaining"
|
||||
);
|
||||
});
|
||||
|
||||
it('should log when stopping a topic', () => {
|
||||
const onStart = vi.fn();
|
||||
const onStop = vi.fn();
|
||||
|
||||
service.registerTopic('TEST_TOPIC', onStart, onStop);
|
||||
service.subscribe('TEST_TOPIC');
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
service.unsubscribe('TEST_TOPIC');
|
||||
expect(loggerSpy).toHaveBeenCalledWith(
|
||||
"Stopping topic 'TEST_TOPIC' (last subscriber removed)"
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete topic entry when count reaches zero', () => {
|
||||
service.subscribe('TEST_TOPIC');
|
||||
expect(service.getSubscriberCount('TEST_TOPIC')).toBe(1);
|
||||
|
||||
service.unsubscribe('TEST_TOPIC');
|
||||
expect(service.getSubscriberCount('TEST_TOPIC')).toBe(0);
|
||||
|
||||
// Should return 0 for non-existent topics
|
||||
expect(service.getAllSubscriberCounts().has('TEST_TOPIC')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSubscriberCount', () => {
|
||||
it('should return correct count for active topic', () => {
|
||||
service.subscribe('TEST_TOPIC');
|
||||
service.subscribe('TEST_TOPIC');
|
||||
|
||||
expect(service.getSubscriberCount('TEST_TOPIC')).toBe(2);
|
||||
});
|
||||
|
||||
it('should return 0 for non-existent topic', () => {
|
||||
expect(service.getSubscriberCount('UNKNOWN_TOPIC')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllSubscriberCounts', () => {
|
||||
it('should return all active topics and counts', () => {
|
||||
service.subscribe('TOPIC_1');
|
||||
service.subscribe('TOPIC_1');
|
||||
service.subscribe('TOPIC_2');
|
||||
service.subscribe('TOPIC_3');
|
||||
service.subscribe('TOPIC_3');
|
||||
service.subscribe('TOPIC_3');
|
||||
|
||||
const counts = service.getAllSubscriberCounts();
|
||||
|
||||
expect(counts.get('TOPIC_1')).toBe(2);
|
||||
expect(counts.get('TOPIC_2')).toBe(1);
|
||||
expect(counts.get('TOPIC_3')).toBe(3);
|
||||
});
|
||||
|
||||
it('should return empty map when no subscribers', () => {
|
||||
const counts = service.getAllSubscriberCounts();
|
||||
expect(counts.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should return a copy of the internal map', () => {
|
||||
service.subscribe('TEST_TOPIC');
|
||||
|
||||
const counts1 = service.getAllSubscriberCounts();
|
||||
counts1.set('TEST_TOPIC', 999);
|
||||
|
||||
const counts2 = service.getAllSubscriberCounts();
|
||||
expect(counts2.get('TEST_TOPIC')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complex scenarios', () => {
|
||||
it('should handle multiple topics independently', () => {
|
||||
const onStart1 = vi.fn();
|
||||
const onStop1 = vi.fn();
|
||||
const onStart2 = vi.fn();
|
||||
const onStop2 = vi.fn();
|
||||
|
||||
service.registerTopic('TOPIC_1', onStart1, onStop1);
|
||||
service.registerTopic('TOPIC_2', onStart2, onStop2);
|
||||
|
||||
service.subscribe('TOPIC_1');
|
||||
expect(onStart1).toHaveBeenCalledTimes(1);
|
||||
expect(onStart2).not.toHaveBeenCalled();
|
||||
|
||||
service.subscribe('TOPIC_2');
|
||||
expect(onStart2).toHaveBeenCalledTimes(1);
|
||||
|
||||
service.unsubscribe('TOPIC_1');
|
||||
expect(onStop1).toHaveBeenCalledTimes(1);
|
||||
expect(onStop2).not.toHaveBeenCalled();
|
||||
|
||||
service.unsubscribe('TOPIC_2');
|
||||
expect(onStop2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle resubscription after all unsubscribed', () => {
|
||||
const onStart = vi.fn();
|
||||
const onStop = vi.fn();
|
||||
|
||||
service.registerTopic('TEST_TOPIC', onStart, onStop);
|
||||
|
||||
// First cycle
|
||||
service.subscribe('TEST_TOPIC');
|
||||
service.unsubscribe('TEST_TOPIC');
|
||||
|
||||
expect(onStart).toHaveBeenCalledTimes(1);
|
||||
expect(onStop).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second cycle - should call onStart again
|
||||
service.subscribe('TEST_TOPIC');
|
||||
expect(onStart).toHaveBeenCalledTimes(2);
|
||||
|
||||
service.unsubscribe('TEST_TOPIC');
|
||||
expect(onStop).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle missing handlers gracefully', () => {
|
||||
// Subscribe without registering handlers
|
||||
expect(() => service.subscribe('UNREGISTERED_TOPIC')).not.toThrow();
|
||||
expect(() => service.unsubscribe('UNREGISTERED_TOPIC')).not.toThrow();
|
||||
|
||||
expect(service.getSubscriberCount('UNREGISTERED_TOPIC')).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { SubscriptionPollingService } from '@app/unraid-api/graph/services/subscription-polling.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class SubscriptionTrackerService {
|
||||
private readonly logger = new Logger(SubscriptionTrackerService.name);
|
||||
private subscriberCounts = new Map<string, number>();
|
||||
private topicHandlers = new Map<string, { onStart: () => void; onStop: () => void }>();
|
||||
|
||||
constructor(private readonly pollingService: SubscriptionPollingService) {}
|
||||
|
||||
/**
|
||||
* Register a topic with optional polling support
|
||||
* @param topic The topic identifier
|
||||
* @param callbackOrOnStart The callback function to execute (can be async) OR onStart handler for legacy support
|
||||
* @param intervalMsOrOnStop Optional interval in ms for polling OR onStop handler for legacy support
|
||||
*/
|
||||
public registerTopic(
|
||||
topic: string,
|
||||
callbackOrOnStart: () => void | Promise<void>,
|
||||
intervalMsOrOnStop?: number | (() => void)
|
||||
): void {
|
||||
if (typeof intervalMsOrOnStop === 'number') {
|
||||
// New API: callback with polling interval
|
||||
const pollingConfig = {
|
||||
name: topic,
|
||||
intervalMs: intervalMsOrOnStop,
|
||||
callback: async () => callbackOrOnStart(),
|
||||
};
|
||||
this.topicHandlers.set(topic, {
|
||||
onStart: () => this.pollingService.startPolling(pollingConfig),
|
||||
onStop: () => this.pollingService.stopPolling(topic),
|
||||
});
|
||||
} else {
|
||||
// Legacy API: onStart and onStop handlers
|
||||
this.topicHandlers.set(topic, {
|
||||
onStart: callbackOrOnStart,
|
||||
onStop: intervalMsOrOnStop || (() => {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public subscribe(topic: string): void {
|
||||
const currentCount = this.subscriberCounts.get(topic) ?? 0;
|
||||
const newCount = currentCount + 1;
|
||||
this.subscriberCounts.set(topic, newCount);
|
||||
|
||||
this.logger.debug(`Subscription added for topic '${topic}': ${newCount} active subscriber(s)`);
|
||||
|
||||
if (currentCount === 0) {
|
||||
this.logger.debug(`Starting topic '${topic}' (first subscriber)`);
|
||||
const handlers = this.topicHandlers.get(topic);
|
||||
if (handlers?.onStart) {
|
||||
handlers.onStart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current subscriber count for a topic
|
||||
* @param topic The topic to check
|
||||
* @returns The number of active subscribers
|
||||
*/
|
||||
public getSubscriberCount(topic: string): number {
|
||||
return this.subscriberCounts.get(topic) ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active topics and their subscriber counts
|
||||
* @returns A map of topics to subscriber counts
|
||||
*/
|
||||
public getAllSubscriberCounts(): Map<string, number> {
|
||||
return new Map(this.subscriberCounts);
|
||||
}
|
||||
|
||||
public unsubscribe(topic: string): void {
|
||||
const currentCount = this.subscriberCounts.get(topic) ?? 0;
|
||||
|
||||
// Early return for idempotency - if already at 0, do nothing
|
||||
if (currentCount === 0) {
|
||||
this.logger.debug(
|
||||
`Unsubscribe called for topic '${topic}' but no active subscribers (idempotent)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const newCount = currentCount - 1;
|
||||
|
||||
this.logger.debug(
|
||||
`Subscription removed for topic '${topic}': ${newCount} active subscriber(s) remaining`
|
||||
);
|
||||
|
||||
if (newCount === 0) {
|
||||
// Delete the topic entry when reaching zero
|
||||
this.subscriberCounts.delete(topic);
|
||||
|
||||
this.logger.debug(`Stopping topic '${topic}' (last subscriber removed)`);
|
||||
|
||||
// Call onStop handler if it exists
|
||||
const handlers = this.topicHandlers.get(topic);
|
||||
if (handlers?.onStop) {
|
||||
handlers.onStop();
|
||||
}
|
||||
} else {
|
||||
// Only update the count if not zero
|
||||
this.subscriberCounts.set(topic, newCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@ export const GRAPHQL_PUBSUB_TOKEN = "GRAPHQL_PUBSUB";
|
||||
/** PUBSUB_CHANNELS enum for the GRAPHQL_PUB_SUB event bus */
|
||||
export enum GRAPHQL_PUBSUB_CHANNEL {
|
||||
ARRAY = "ARRAY",
|
||||
CPU_UTILIZATION = "CPU_UTILIZATION",
|
||||
DASHBOARD = "DASHBOARD",
|
||||
DISPLAY = "DISPLAY",
|
||||
INFO = "INFO",
|
||||
MEMORY_UTILIZATION = "MEMORY_UTILIZATION",
|
||||
NOTIFICATION = "NOTIFICATION",
|
||||
NOTIFICATION_ADDED = "NOTIFICATION_ADDED",
|
||||
NOTIFICATION_OVERVIEW = "NOTIFICATION_OVERVIEW",
|
||||
|
||||
@@ -399,16 +399,6 @@ export enum AuthorizationRuleMode {
|
||||
OR = 'OR'
|
||||
}
|
||||
|
||||
export type Baseboard = Node & {
|
||||
__typename?: 'Baseboard';
|
||||
assetTag?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
manufacturer: Scalars['String']['output'];
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
version?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Capacity = {
|
||||
__typename?: 'Capacity';
|
||||
/** Free capacity */
|
||||
@@ -419,15 +409,6 @@ export type Capacity = {
|
||||
used: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type Case = Node & {
|
||||
__typename?: 'Case';
|
||||
base64?: Maybe<Scalars['String']['output']>;
|
||||
error?: Maybe<Scalars['String']['output']>;
|
||||
icon?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
url?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Cloud = {
|
||||
__typename?: 'Cloud';
|
||||
allowedOrigins: Array<Scalars['String']['output']>;
|
||||
@@ -539,6 +520,32 @@ export enum ContainerState {
|
||||
RUNNING = 'RUNNING'
|
||||
}
|
||||
|
||||
/** CPU load for a single core */
|
||||
export type CpuLoad = {
|
||||
__typename?: 'CpuLoad';
|
||||
/** The percentage of time the CPU was idle. */
|
||||
percentIdle: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU spent servicing hardware interrupts. */
|
||||
percentIrq: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU spent on low-priority (niced) user space processes. */
|
||||
percentNice: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU spent in kernel space. */
|
||||
percentSystem: Scalars['Float']['output'];
|
||||
/** The total CPU load on a single core, in percent. */
|
||||
percentTotal: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU spent in user space. */
|
||||
percentUser: Scalars['Float']['output'];
|
||||
};
|
||||
|
||||
export type CpuUtilization = Node & {
|
||||
__typename?: 'CpuUtilization';
|
||||
/** CPU load for each core */
|
||||
cpus: Array<CpuLoad>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Total CPU load in percent */
|
||||
percentTotal: Scalars['Float']['output'];
|
||||
};
|
||||
|
||||
export type CreateApiKeyInput = {
|
||||
description?: InputMaybe<Scalars['String']['input']>;
|
||||
name: Scalars['String']['input'];
|
||||
@@ -569,14 +576,6 @@ export type DeleteRCloneRemoteInput = {
|
||||
name: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type Devices = Node & {
|
||||
__typename?: 'Devices';
|
||||
gpu: Array<Gpu>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
pci: Array<Pci>;
|
||||
usb: Array<Usb>;
|
||||
};
|
||||
|
||||
export type Disk = Node & {
|
||||
__typename?: 'Disk';
|
||||
/** The number of bytes per sector */
|
||||
@@ -653,31 +652,6 @@ export enum DiskSmartStatus {
|
||||
UNKNOWN = 'UNKNOWN'
|
||||
}
|
||||
|
||||
export type Display = Node & {
|
||||
__typename?: 'Display';
|
||||
banner?: Maybe<Scalars['String']['output']>;
|
||||
case?: Maybe<Case>;
|
||||
critical?: Maybe<Scalars['Int']['output']>;
|
||||
dashapps?: Maybe<Scalars['String']['output']>;
|
||||
date?: Maybe<Scalars['String']['output']>;
|
||||
hot?: Maybe<Scalars['Int']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
locale?: Maybe<Scalars['String']['output']>;
|
||||
max?: Maybe<Scalars['Int']['output']>;
|
||||
number?: Maybe<Scalars['String']['output']>;
|
||||
resize?: Maybe<Scalars['Boolean']['output']>;
|
||||
scale?: Maybe<Scalars['Boolean']['output']>;
|
||||
tabs?: Maybe<Scalars['Boolean']['output']>;
|
||||
text?: Maybe<Scalars['Boolean']['output']>;
|
||||
theme?: Maybe<ThemeName>;
|
||||
total?: Maybe<Scalars['Boolean']['output']>;
|
||||
unit?: Maybe<Temperature>;
|
||||
usage?: Maybe<Scalars['Boolean']['output']>;
|
||||
users?: Maybe<Scalars['String']['output']>;
|
||||
warning?: Maybe<Scalars['Int']['output']>;
|
||||
wwn?: Maybe<Scalars['Boolean']['output']>;
|
||||
};
|
||||
|
||||
export type Docker = Node & {
|
||||
__typename?: 'Docker';
|
||||
containers: Array<DockerContainer>;
|
||||
@@ -792,80 +766,340 @@ export type FlashBackupStatus = {
|
||||
status: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type Gpu = Node & {
|
||||
__typename?: 'Gpu';
|
||||
blacklisted: Scalars['Boolean']['output'];
|
||||
class: Scalars['String']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
productid: Scalars['String']['output'];
|
||||
type: Scalars['String']['output'];
|
||||
typeid: Scalars['String']['output'];
|
||||
vendorname: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type Info = Node & {
|
||||
__typename?: 'Info';
|
||||
/** Count of docker containers */
|
||||
apps: InfoApps;
|
||||
baseboard: Baseboard;
|
||||
/** Motherboard information */
|
||||
baseboard: InfoBaseboard;
|
||||
/** CPU information */
|
||||
cpu: InfoCpu;
|
||||
devices: Devices;
|
||||
display: Display;
|
||||
/** Device information */
|
||||
devices: InfoDevices;
|
||||
/** Display configuration */
|
||||
display: InfoDisplay;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Machine ID */
|
||||
machineId?: Maybe<Scalars['PrefixedID']['output']>;
|
||||
machineId?: Maybe<Scalars['ID']['output']>;
|
||||
/** Memory information */
|
||||
memory: InfoMemory;
|
||||
os: Os;
|
||||
system: System;
|
||||
/** Operating system information */
|
||||
os: InfoOs;
|
||||
/** System information */
|
||||
system: InfoSystem;
|
||||
/** Current server time */
|
||||
time: Scalars['DateTime']['output'];
|
||||
versions: Versions;
|
||||
/** Software versions */
|
||||
versions: InfoVersions;
|
||||
};
|
||||
|
||||
export type InfoApps = Node & {
|
||||
__typename?: 'InfoApps';
|
||||
export type InfoBaseboard = Node & {
|
||||
__typename?: 'InfoBaseboard';
|
||||
/** Motherboard asset tag */
|
||||
assetTag?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** How many docker containers are installed */
|
||||
installed: Scalars['Int']['output'];
|
||||
/** How many docker containers are running */
|
||||
started: Scalars['Int']['output'];
|
||||
/** Motherboard manufacturer */
|
||||
manufacturer?: Maybe<Scalars['String']['output']>;
|
||||
/** Maximum memory capacity in bytes */
|
||||
memMax?: Maybe<Scalars['Float']['output']>;
|
||||
/** Number of memory slots */
|
||||
memSlots?: Maybe<Scalars['Float']['output']>;
|
||||
/** Motherboard model */
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
/** Motherboard serial number */
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
/** Motherboard version */
|
||||
version?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type InfoCpu = Node & {
|
||||
__typename?: 'InfoCpu';
|
||||
brand: Scalars['String']['output'];
|
||||
cache: Scalars['JSON']['output'];
|
||||
cores: Scalars['Int']['output'];
|
||||
family: Scalars['String']['output'];
|
||||
flags: Array<Scalars['String']['output']>;
|
||||
/** CPU brand name */
|
||||
brand?: Maybe<Scalars['String']['output']>;
|
||||
/** CPU cache information */
|
||||
cache?: Maybe<Scalars['JSON']['output']>;
|
||||
/** Number of CPU cores */
|
||||
cores?: Maybe<Scalars['Int']['output']>;
|
||||
/** CPU family */
|
||||
family?: Maybe<Scalars['String']['output']>;
|
||||
/** CPU feature flags */
|
||||
flags?: Maybe<Array<Scalars['String']['output']>>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
manufacturer: Scalars['String']['output'];
|
||||
model: Scalars['String']['output'];
|
||||
processors: Scalars['Int']['output'];
|
||||
revision: Scalars['String']['output'];
|
||||
socket: Scalars['String']['output'];
|
||||
speed: Scalars['Float']['output'];
|
||||
speedmax: Scalars['Float']['output'];
|
||||
speedmin: Scalars['Float']['output'];
|
||||
stepping: Scalars['Int']['output'];
|
||||
threads: Scalars['Int']['output'];
|
||||
vendor: Scalars['String']['output'];
|
||||
/** CPU manufacturer */
|
||||
manufacturer?: Maybe<Scalars['String']['output']>;
|
||||
/** CPU model */
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
/** Number of physical processors */
|
||||
processors?: Maybe<Scalars['Int']['output']>;
|
||||
/** CPU revision */
|
||||
revision?: Maybe<Scalars['String']['output']>;
|
||||
/** CPU socket type */
|
||||
socket?: Maybe<Scalars['String']['output']>;
|
||||
/** Current CPU speed in GHz */
|
||||
speed?: Maybe<Scalars['Float']['output']>;
|
||||
/** Maximum CPU speed in GHz */
|
||||
speedmax?: Maybe<Scalars['Float']['output']>;
|
||||
/** Minimum CPU speed in GHz */
|
||||
speedmin?: Maybe<Scalars['Float']['output']>;
|
||||
/** CPU stepping */
|
||||
stepping?: Maybe<Scalars['Int']['output']>;
|
||||
/** Number of CPU threads */
|
||||
threads?: Maybe<Scalars['Int']['output']>;
|
||||
/** CPU vendor */
|
||||
vendor?: Maybe<Scalars['String']['output']>;
|
||||
/** CPU voltage */
|
||||
voltage?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type InfoDevices = Node & {
|
||||
__typename?: 'InfoDevices';
|
||||
/** List of GPU devices */
|
||||
gpu?: Maybe<Array<InfoGpu>>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** List of network interfaces */
|
||||
network?: Maybe<Array<InfoNetwork>>;
|
||||
/** List of PCI devices */
|
||||
pci?: Maybe<Array<InfoPci>>;
|
||||
/** List of USB devices */
|
||||
usb?: Maybe<Array<InfoUsb>>;
|
||||
};
|
||||
|
||||
export type InfoDisplay = Node & {
|
||||
__typename?: 'InfoDisplay';
|
||||
/** Case display configuration */
|
||||
case: InfoDisplayCase;
|
||||
/** Critical temperature threshold */
|
||||
critical: Scalars['Int']['output'];
|
||||
/** Hot temperature threshold */
|
||||
hot: Scalars['Int']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Locale setting */
|
||||
locale?: Maybe<Scalars['String']['output']>;
|
||||
/** Maximum temperature threshold */
|
||||
max?: Maybe<Scalars['Int']['output']>;
|
||||
/** Enable UI resize */
|
||||
resize: Scalars['Boolean']['output'];
|
||||
/** Enable UI scaling */
|
||||
scale: Scalars['Boolean']['output'];
|
||||
/** Show tabs in UI */
|
||||
tabs: Scalars['Boolean']['output'];
|
||||
/** Show text labels */
|
||||
text: Scalars['Boolean']['output'];
|
||||
/** UI theme name */
|
||||
theme: ThemeName;
|
||||
/** Show totals */
|
||||
total: Scalars['Boolean']['output'];
|
||||
/** Temperature unit (C or F) */
|
||||
unit: Temperature;
|
||||
/** Show usage statistics */
|
||||
usage: Scalars['Boolean']['output'];
|
||||
/** Warning temperature threshold */
|
||||
warning: Scalars['Int']['output'];
|
||||
/** Show WWN identifiers */
|
||||
wwn: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type InfoDisplayCase = Node & {
|
||||
__typename?: 'InfoDisplayCase';
|
||||
/** Base64 encoded case image */
|
||||
base64: Scalars['String']['output'];
|
||||
/** Error message if any */
|
||||
error: Scalars['String']['output'];
|
||||
/** Case icon identifier */
|
||||
icon: Scalars['String']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Case image URL */
|
||||
url: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type InfoGpu = Node & {
|
||||
__typename?: 'InfoGpu';
|
||||
/** Whether GPU is blacklisted */
|
||||
blacklisted: Scalars['Boolean']['output'];
|
||||
/** Device class */
|
||||
class: Scalars['String']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Product ID */
|
||||
productid: Scalars['String']['output'];
|
||||
/** GPU type/manufacturer */
|
||||
type: Scalars['String']['output'];
|
||||
/** GPU type identifier */
|
||||
typeid: Scalars['String']['output'];
|
||||
/** Vendor name */
|
||||
vendorname?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type InfoMemory = Node & {
|
||||
__typename?: 'InfoMemory';
|
||||
active: Scalars['BigInt']['output'];
|
||||
available: Scalars['BigInt']['output'];
|
||||
buffcache: Scalars['BigInt']['output'];
|
||||
free: Scalars['BigInt']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Physical memory layout */
|
||||
layout: Array<MemoryLayout>;
|
||||
max: Scalars['BigInt']['output'];
|
||||
swapfree: Scalars['BigInt']['output'];
|
||||
swaptotal: Scalars['BigInt']['output'];
|
||||
swapused: Scalars['BigInt']['output'];
|
||||
total: Scalars['BigInt']['output'];
|
||||
used: Scalars['BigInt']['output'];
|
||||
};
|
||||
|
||||
export type InfoNetwork = Node & {
|
||||
__typename?: 'InfoNetwork';
|
||||
/** DHCP enabled flag */
|
||||
dhcp?: Maybe<Scalars['Boolean']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Network interface name */
|
||||
iface: Scalars['String']['output'];
|
||||
/** MAC address */
|
||||
mac?: Maybe<Scalars['String']['output']>;
|
||||
/** Network interface model */
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
/** Network speed */
|
||||
speed?: Maybe<Scalars['String']['output']>;
|
||||
/** Network vendor */
|
||||
vendor?: Maybe<Scalars['String']['output']>;
|
||||
/** Virtual interface flag */
|
||||
virtual?: Maybe<Scalars['Boolean']['output']>;
|
||||
};
|
||||
|
||||
export type InfoOs = Node & {
|
||||
__typename?: 'InfoOs';
|
||||
/** OS architecture */
|
||||
arch?: Maybe<Scalars['String']['output']>;
|
||||
/** OS build identifier */
|
||||
build?: Maybe<Scalars['String']['output']>;
|
||||
/** OS codename */
|
||||
codename?: Maybe<Scalars['String']['output']>;
|
||||
/** Linux distribution name */
|
||||
distro?: Maybe<Scalars['String']['output']>;
|
||||
/** Fully qualified domain name */
|
||||
fqdn?: Maybe<Scalars['String']['output']>;
|
||||
/** Hostname */
|
||||
hostname?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Kernel version */
|
||||
kernel?: Maybe<Scalars['String']['output']>;
|
||||
/** OS logo name */
|
||||
logofile?: Maybe<Scalars['String']['output']>;
|
||||
/** Operating system platform */
|
||||
platform?: Maybe<Scalars['String']['output']>;
|
||||
/** OS release version */
|
||||
release?: Maybe<Scalars['String']['output']>;
|
||||
/** OS serial number */
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
/** Service pack version */
|
||||
servicepack?: Maybe<Scalars['String']['output']>;
|
||||
/** OS started via UEFI */
|
||||
uefi?: Maybe<Scalars['Boolean']['output']>;
|
||||
/** Boot time ISO string */
|
||||
uptime?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type InfoPci = Node & {
|
||||
__typename?: 'InfoPci';
|
||||
/** Blacklisted status */
|
||||
blacklisted: Scalars['String']['output'];
|
||||
/** Device class */
|
||||
class: Scalars['String']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Product ID */
|
||||
productid: Scalars['String']['output'];
|
||||
/** Product name */
|
||||
productname?: Maybe<Scalars['String']['output']>;
|
||||
/** Device type/manufacturer */
|
||||
type: Scalars['String']['output'];
|
||||
/** Type identifier */
|
||||
typeid: Scalars['String']['output'];
|
||||
/** Vendor ID */
|
||||
vendorid: Scalars['String']['output'];
|
||||
/** Vendor name */
|
||||
vendorname?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type InfoSystem = Node & {
|
||||
__typename?: 'InfoSystem';
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** System manufacturer */
|
||||
manufacturer?: Maybe<Scalars['String']['output']>;
|
||||
/** System model */
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
/** System serial number */
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
/** System SKU */
|
||||
sku?: Maybe<Scalars['String']['output']>;
|
||||
/** System UUID */
|
||||
uuid?: Maybe<Scalars['String']['output']>;
|
||||
/** System version */
|
||||
version?: Maybe<Scalars['String']['output']>;
|
||||
/** Virtual machine flag */
|
||||
virtual?: Maybe<Scalars['Boolean']['output']>;
|
||||
};
|
||||
|
||||
export type InfoUsb = Node & {
|
||||
__typename?: 'InfoUsb';
|
||||
/** USB bus number */
|
||||
bus?: Maybe<Scalars['String']['output']>;
|
||||
/** USB device number */
|
||||
device?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** USB device name */
|
||||
name: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type InfoVersions = Node & {
|
||||
__typename?: 'InfoVersions';
|
||||
/** Apache version */
|
||||
apache?: Maybe<Scalars['String']['output']>;
|
||||
/** Docker version */
|
||||
docker?: Maybe<Scalars['String']['output']>;
|
||||
/** gcc version */
|
||||
gcc?: Maybe<Scalars['String']['output']>;
|
||||
/** Git version */
|
||||
git?: Maybe<Scalars['String']['output']>;
|
||||
/** Grunt version */
|
||||
grunt?: Maybe<Scalars['String']['output']>;
|
||||
/** Gulp version */
|
||||
gulp?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Java version */
|
||||
java?: Maybe<Scalars['String']['output']>;
|
||||
/** Kernel version */
|
||||
kernel?: Maybe<Scalars['String']['output']>;
|
||||
/** MongoDB version */
|
||||
mongodb?: Maybe<Scalars['String']['output']>;
|
||||
/** MySQL version */
|
||||
mysql?: Maybe<Scalars['String']['output']>;
|
||||
/** nginx version */
|
||||
nginx?: Maybe<Scalars['String']['output']>;
|
||||
/** Node.js version */
|
||||
node?: Maybe<Scalars['String']['output']>;
|
||||
/** npm version */
|
||||
npm?: Maybe<Scalars['String']['output']>;
|
||||
/** OpenSSL version */
|
||||
openssl?: Maybe<Scalars['String']['output']>;
|
||||
/** Perl version */
|
||||
perl?: Maybe<Scalars['String']['output']>;
|
||||
/** PHP version */
|
||||
php?: Maybe<Scalars['String']['output']>;
|
||||
/** pip version */
|
||||
pip?: Maybe<Scalars['String']['output']>;
|
||||
/** pip3 version */
|
||||
pip3?: Maybe<Scalars['String']['output']>;
|
||||
/** pm2 version */
|
||||
pm2?: Maybe<Scalars['String']['output']>;
|
||||
/** Postfix version */
|
||||
postfix?: Maybe<Scalars['String']['output']>;
|
||||
/** PostgreSQL version */
|
||||
postgresql?: Maybe<Scalars['String']['output']>;
|
||||
/** Python version */
|
||||
python?: Maybe<Scalars['String']['output']>;
|
||||
/** Python3 version */
|
||||
python3?: Maybe<Scalars['String']['output']>;
|
||||
/** Redis version */
|
||||
redis?: Maybe<Scalars['String']['output']>;
|
||||
/** System OpenSSL version */
|
||||
systemOpenssl?: Maybe<Scalars['String']['output']>;
|
||||
/** tsc version */
|
||||
tsc?: Maybe<Scalars['String']['output']>;
|
||||
/** Unraid version */
|
||||
unraid?: Maybe<Scalars['String']['output']>;
|
||||
/** V8 engine version */
|
||||
v8?: Maybe<Scalars['String']['output']>;
|
||||
/** VirtualBox version */
|
||||
virtualbox?: Maybe<Scalars['String']['output']>;
|
||||
/** Yarn version */
|
||||
yarn?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type InitiateFlashBackupInput = {
|
||||
@@ -911,20 +1145,68 @@ export type LogFileContent = {
|
||||
|
||||
export type MemoryLayout = Node & {
|
||||
__typename?: 'MemoryLayout';
|
||||
/** Memory bank location (e.g., BANK 0) */
|
||||
bank?: Maybe<Scalars['String']['output']>;
|
||||
/** Memory clock speed in MHz */
|
||||
clockSpeed?: Maybe<Scalars['Int']['output']>;
|
||||
/** Form factor (e.g., DIMM, SODIMM) */
|
||||
formFactor?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Memory manufacturer */
|
||||
manufacturer?: Maybe<Scalars['String']['output']>;
|
||||
/** Part number of the memory module */
|
||||
partNum?: Maybe<Scalars['String']['output']>;
|
||||
/** Serial number of the memory module */
|
||||
serialNum?: Maybe<Scalars['String']['output']>;
|
||||
/** Memory module size in bytes */
|
||||
size: Scalars['BigInt']['output'];
|
||||
/** Memory type (e.g., DDR4, DDR5) */
|
||||
type?: Maybe<Scalars['String']['output']>;
|
||||
/** Configured voltage in millivolts */
|
||||
voltageConfigured?: Maybe<Scalars['Int']['output']>;
|
||||
/** Maximum voltage in millivolts */
|
||||
voltageMax?: Maybe<Scalars['Int']['output']>;
|
||||
/** Minimum voltage in millivolts */
|
||||
voltageMin?: Maybe<Scalars['Int']['output']>;
|
||||
};
|
||||
|
||||
export type MemoryUtilization = Node & {
|
||||
__typename?: 'MemoryUtilization';
|
||||
/** Active memory in bytes */
|
||||
active: Scalars['BigInt']['output'];
|
||||
/** Available memory in bytes */
|
||||
available: Scalars['BigInt']['output'];
|
||||
/** Buffer/cache memory in bytes */
|
||||
buffcache: Scalars['BigInt']['output'];
|
||||
/** Free memory in bytes */
|
||||
free: Scalars['BigInt']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Swap usage percentage */
|
||||
percentSwapTotal: Scalars['Float']['output'];
|
||||
/** Memory usage percentage */
|
||||
percentTotal: Scalars['Float']['output'];
|
||||
/** Free swap memory in bytes */
|
||||
swapFree: Scalars['BigInt']['output'];
|
||||
/** Total swap memory in bytes */
|
||||
swapTotal: Scalars['BigInt']['output'];
|
||||
/** Used swap memory in bytes */
|
||||
swapUsed: Scalars['BigInt']['output'];
|
||||
/** Total system memory in bytes */
|
||||
total: Scalars['BigInt']['output'];
|
||||
/** Used memory in bytes */
|
||||
used: Scalars['BigInt']['output'];
|
||||
};
|
||||
|
||||
/** System metrics including CPU and memory utilization */
|
||||
export type Metrics = Node & {
|
||||
__typename?: 'Metrics';
|
||||
/** Current CPU utilization metrics */
|
||||
cpu?: Maybe<CpuUtilization>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Current memory utilization metrics */
|
||||
memory?: Maybe<MemoryUtilization>;
|
||||
};
|
||||
|
||||
/** The status of the minigraph */
|
||||
export enum MinigraphStatus {
|
||||
CONNECTED = 'CONNECTED',
|
||||
@@ -1237,23 +1519,6 @@ export type OrganizerResource = {
|
||||
type: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type Os = Node & {
|
||||
__typename?: 'Os';
|
||||
arch?: Maybe<Scalars['String']['output']>;
|
||||
build?: Maybe<Scalars['String']['output']>;
|
||||
codename?: Maybe<Scalars['String']['output']>;
|
||||
codepage?: Maybe<Scalars['String']['output']>;
|
||||
distro?: Maybe<Scalars['String']['output']>;
|
||||
hostname?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
kernel?: Maybe<Scalars['String']['output']>;
|
||||
logofile?: Maybe<Scalars['String']['output']>;
|
||||
platform?: Maybe<Scalars['String']['output']>;
|
||||
release?: Maybe<Scalars['String']['output']>;
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
uptime?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Owner = {
|
||||
__typename?: 'Owner';
|
||||
avatar: Scalars['String']['output'];
|
||||
@@ -1302,19 +1567,6 @@ export type ParityCheckMutationsStartArgs = {
|
||||
correct: Scalars['Boolean']['input'];
|
||||
};
|
||||
|
||||
export type Pci = Node & {
|
||||
__typename?: 'Pci';
|
||||
blacklisted?: Maybe<Scalars['String']['output']>;
|
||||
class?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
productid?: Maybe<Scalars['String']['output']>;
|
||||
productname?: Maybe<Scalars['String']['output']>;
|
||||
type?: Maybe<Scalars['String']['output']>;
|
||||
typeid?: Maybe<Scalars['String']['output']>;
|
||||
vendorid?: Maybe<Scalars['String']['output']>;
|
||||
vendorname?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Permission = {
|
||||
__typename?: 'Permission';
|
||||
actions: Array<Scalars['String']['output']>;
|
||||
@@ -1385,7 +1637,6 @@ export type Query = {
|
||||
customization?: Maybe<Customization>;
|
||||
disk: Disk;
|
||||
disks: Array<Disk>;
|
||||
display: Display;
|
||||
docker: Docker;
|
||||
flash: Flash;
|
||||
info: Info;
|
||||
@@ -1394,6 +1645,7 @@ export type Query = {
|
||||
logFile: LogFileContent;
|
||||
logFiles: Array<LogFile>;
|
||||
me: UserAccount;
|
||||
metrics: Metrics;
|
||||
network: Network;
|
||||
/** Get all notifications */
|
||||
notifications: Notifications;
|
||||
@@ -1743,14 +1995,14 @@ export type SsoSettings = Node & {
|
||||
export type Subscription = {
|
||||
__typename?: 'Subscription';
|
||||
arraySubscription: UnraidArray;
|
||||
displaySubscription: Display;
|
||||
infoSubscription: Info;
|
||||
logFile: LogFileContent;
|
||||
notificationAdded: Notification;
|
||||
notificationsOverview: NotificationOverview;
|
||||
ownerSubscription: Owner;
|
||||
parityHistorySubscription: ParityCheck;
|
||||
serversSubscription: Server;
|
||||
systemMetricsCpu: CpuUtilization;
|
||||
systemMetricsMemory: MemoryUtilization;
|
||||
upsUpdates: UpsDevice;
|
||||
};
|
||||
|
||||
@@ -1759,21 +2011,10 @@ export type SubscriptionLogFileArgs = {
|
||||
path: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type System = Node & {
|
||||
__typename?: 'System';
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
manufacturer?: Maybe<Scalars['String']['output']>;
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
serial?: Maybe<Scalars['String']['output']>;
|
||||
sku?: Maybe<Scalars['String']['output']>;
|
||||
uuid?: Maybe<Scalars['String']['output']>;
|
||||
version?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
/** Temperature unit (Celsius or Fahrenheit) */
|
||||
/** Temperature unit */
|
||||
export enum Temperature {
|
||||
C = 'C',
|
||||
F = 'F'
|
||||
CELSIUS = 'CELSIUS',
|
||||
FAHRENHEIT = 'FAHRENHEIT'
|
||||
}
|
||||
|
||||
export type Theme = {
|
||||
@@ -1985,12 +2226,6 @@ export type Uptime = {
|
||||
timestamp?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Usb = Node & {
|
||||
__typename?: 'Usb';
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type UserAccount = Node & {
|
||||
__typename?: 'UserAccount';
|
||||
/** A description of the user */
|
||||
@@ -2168,37 +2403,6 @@ export type Vars = Node & {
|
||||
workgroup?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Versions = Node & {
|
||||
__typename?: 'Versions';
|
||||
apache?: Maybe<Scalars['String']['output']>;
|
||||
docker?: Maybe<Scalars['String']['output']>;
|
||||
gcc?: Maybe<Scalars['String']['output']>;
|
||||
git?: Maybe<Scalars['String']['output']>;
|
||||
grunt?: Maybe<Scalars['String']['output']>;
|
||||
gulp?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
kernel?: Maybe<Scalars['String']['output']>;
|
||||
mongodb?: Maybe<Scalars['String']['output']>;
|
||||
mysql?: Maybe<Scalars['String']['output']>;
|
||||
nginx?: Maybe<Scalars['String']['output']>;
|
||||
node?: Maybe<Scalars['String']['output']>;
|
||||
npm?: Maybe<Scalars['String']['output']>;
|
||||
openssl?: Maybe<Scalars['String']['output']>;
|
||||
perl?: Maybe<Scalars['String']['output']>;
|
||||
php?: Maybe<Scalars['String']['output']>;
|
||||
pm2?: Maybe<Scalars['String']['output']>;
|
||||
postfix?: Maybe<Scalars['String']['output']>;
|
||||
postgresql?: Maybe<Scalars['String']['output']>;
|
||||
python?: Maybe<Scalars['String']['output']>;
|
||||
redis?: Maybe<Scalars['String']['output']>;
|
||||
systemOpenssl?: Maybe<Scalars['String']['output']>;
|
||||
systemOpensslLib?: Maybe<Scalars['String']['output']>;
|
||||
tsc?: Maybe<Scalars['String']['output']>;
|
||||
unraid?: Maybe<Scalars['String']['output']>;
|
||||
v8?: Maybe<Scalars['String']['output']>;
|
||||
yarn?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type VmDomain = Node & {
|
||||
__typename?: 'VmDomain';
|
||||
/** The unique identifier for the vm (uuid) */
|
||||
@@ -2516,7 +2720,7 @@ export type PublicOidcProvidersQuery = { __typename?: 'Query', publicOidcProvide
|
||||
export type ServerInfoQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type ServerInfoQuery = { __typename?: 'Query', info: { __typename?: 'Info', os: { __typename?: 'Os', hostname?: string | null } }, vars: { __typename?: 'Vars', comment?: string | null } };
|
||||
export type ServerInfoQuery = { __typename?: 'Query', info: { __typename?: 'Info', os: { __typename?: 'InfoOs', hostname?: string | null } }, vars: { __typename?: 'Vars', comment?: string | null } };
|
||||
|
||||
export type ConnectSignInMutationVariables = Exact<{
|
||||
input: ConnectSignInInput;
|
||||
@@ -2548,7 +2752,7 @@ export type CloudStateQuery = { __typename?: 'Query', cloud: (
|
||||
export type ServerStateQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type ServerStateQuery = { __typename?: 'Query', config: { __typename?: 'Config', error?: string | null, valid?: boolean | null }, info: { __typename?: 'Info', os: { __typename?: 'Os', hostname?: string | null } }, owner: { __typename?: 'Owner', avatar: string, username: string }, registration?: { __typename?: 'Registration', state?: RegistrationState | null, expiration?: string | null, updateExpiration?: string | null, keyFile?: { __typename?: 'KeyFile', contents?: string | null } | null } | null, vars: { __typename?: 'Vars', regGen?: string | null, regState?: RegistrationState | null, configError?: ConfigErrorState | null, configValid?: boolean | null } };
|
||||
export type ServerStateQuery = { __typename?: 'Query', config: { __typename?: 'Config', error?: string | null, valid?: boolean | null }, info: { __typename?: 'Info', os: { __typename?: 'InfoOs', hostname?: string | null } }, owner: { __typename?: 'Owner', avatar: string, username: string }, registration?: { __typename?: 'Registration', state?: RegistrationState | null, expiration?: string | null, updateExpiration?: string | null, keyFile?: { __typename?: 'KeyFile', contents?: string | null } | null } | null, vars: { __typename?: 'Vars', regGen?: string | null, regState?: RegistrationState | null, configError?: ConfigErrorState | null, configValid?: boolean | null } };
|
||||
|
||||
export type GetThemeQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user