This commit is contained in:
Eli Bosley
2025-08-19 14:30:25 -04:00
committed by GitHub
48 changed files with 4098 additions and 1987 deletions

View File

@@ -1,5 +1,5 @@
{
"version": "4.12.0",
"version": "4.13.1",
"extraOrigins": [],
"sandbox": true,
"ssoSubIds": [],

View File

@@ -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!
}

View File

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

View 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;
}

View File

@@ -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,

View File

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

View File

@@ -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 {}

View File

@@ -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', () => ({

View File

@@ -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 {

View 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[];
}

View 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,
})),
};
}
}

View File

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

View 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[];
}

View File

@@ -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;

View File

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

View File

@@ -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', () => ({

View File

@@ -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) => ({

View File

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

View File

@@ -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: '',

View File

@@ -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),

View File

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

View 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 {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
};
}
}

View 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;
}

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

View File

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

View File

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

View File

@@ -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,
};
}
}

View 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;
}

View 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 {}

View File

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

View File

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

View File

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

View File

@@ -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,

View 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 {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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