diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index 51fda8706..df4db6b51 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -1,5 +1,5 @@ { - "version": "4.12.0", + "version": "4.13.1", "extraOrigins": [], "sandbox": true, "ssoSubIds": [], diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 85c4b5dad..25631cb05 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -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! } diff --git a/api/src/core/pubsub.ts b/api/src/core/pubsub.ts index 6d0840137..efba5da3b 100644 --- a/api/src/core/pubsub.ts +++ b/api/src/core/pubsub.ts @@ -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 = ( + channel: GRAPHQL_PUBSUB_CHANNEL +): AsyncIterableIterator => { + return pubsub.asyncIterableIterator(channel); }; diff --git a/api/src/core/utils/validation/enum-validator.ts b/api/src/core/utils/validation/enum-validator.ts new file mode 100644 index 000000000..0b870bb79 --- /dev/null +++ b/api/src/core/utils/validation/enum-validator.ts @@ -0,0 +1,17 @@ +export function isValidEnumValue>( + 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>( + value: unknown, + enumObject: T +): T[keyof T] | undefined { + return isValidEnumValue(value, enumObject) ? (value as T[keyof T]) : undefined; +} diff --git a/api/src/unraid-api/app/app.module.ts b/api/src/unraid-api/app/app.module.ts index d472c78f0..abc51acc0 100644 --- a/api/src/unraid-api/app/app.module.ts +++ b/api/src/unraid-api/app/app.module.ts @@ -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, diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index 10f256da5..d0b08eeb9 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -399,16 +399,6 @@ export enum AuthorizationRuleMode { OR = 'OR' } -export type Baseboard = Node & { - __typename?: 'Baseboard'; - assetTag?: Maybe; - id: Scalars['PrefixedID']['output']; - manufacturer: Scalars['String']['output']; - model?: Maybe; - serial?: Maybe; - version?: Maybe; -}; - 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; - error?: Maybe; - icon?: Maybe; - id: Scalars['PrefixedID']['output']; - url?: Maybe; -}; - export type Cloud = { __typename?: 'Cloud'; allowedOrigins: Array; @@ -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; + id: Scalars['PrefixedID']['output']; + /** Total CPU load in percent */ + percentTotal: Scalars['Float']['output']; +}; + export type CreateApiKeyInput = { description?: InputMaybe; name: Scalars['String']['input']; @@ -569,14 +576,6 @@ export type DeleteRCloneRemoteInput = { name: Scalars['String']['input']; }; -export type Devices = Node & { - __typename?: 'Devices'; - gpu: Array; - id: Scalars['PrefixedID']['output']; - pci: Array; - usb: Array; -}; - 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; - case?: Maybe; - critical?: Maybe; - dashapps?: Maybe; - date?: Maybe; - hot?: Maybe; - id: Scalars['PrefixedID']['output']; - locale?: Maybe; - max?: Maybe; - number?: Maybe; - resize?: Maybe; - scale?: Maybe; - tabs?: Maybe; - text?: Maybe; - theme?: Maybe; - total?: Maybe; - unit?: Maybe; - usage?: Maybe; - users?: Maybe; - warning?: Maybe; - wwn?: Maybe; -}; - export type Docker = Node & { __typename?: 'Docker'; containers: Array; @@ -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; + machineId?: Maybe; + /** 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; 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; + /** Maximum memory capacity in bytes */ + memMax?: Maybe; + /** Number of memory slots */ + memSlots?: Maybe; + /** Motherboard model */ + model?: Maybe; + /** Motherboard serial number */ + serial?: Maybe; + /** Motherboard version */ + version?: Maybe; }; export type InfoCpu = Node & { __typename?: 'InfoCpu'; - brand: Scalars['String']['output']; - cache: Scalars['JSON']['output']; - cores: Scalars['Int']['output']; - family: Scalars['String']['output']; - flags: Array; + /** CPU brand name */ + brand?: Maybe; + /** CPU cache information */ + cache?: Maybe; + /** Number of CPU cores */ + cores?: Maybe; + /** CPU family */ + family?: Maybe; + /** CPU feature flags */ + flags?: Maybe>; 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; + /** CPU model */ + model?: Maybe; + /** Number of physical processors */ + processors?: Maybe; + /** CPU revision */ + revision?: Maybe; + /** CPU socket type */ + socket?: Maybe; + /** Current CPU speed in GHz */ + speed?: Maybe; + /** Maximum CPU speed in GHz */ + speedmax?: Maybe; + /** Minimum CPU speed in GHz */ + speedmin?: Maybe; + /** CPU stepping */ + stepping?: Maybe; + /** Number of CPU threads */ + threads?: Maybe; + /** CPU vendor */ + vendor?: Maybe; + /** CPU voltage */ voltage?: Maybe; }; +export type InfoDevices = Node & { + __typename?: 'InfoDevices'; + /** List of GPU devices */ + gpu?: Maybe>; + id: Scalars['PrefixedID']['output']; + /** List of network interfaces */ + network?: Maybe>; + /** List of PCI devices */ + pci?: Maybe>; + /** List of USB devices */ + usb?: Maybe>; +}; + +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; + /** Maximum temperature threshold */ + max?: Maybe; + /** 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; +}; + 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; - 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; + id: Scalars['PrefixedID']['output']; + /** Network interface name */ + iface: Scalars['String']['output']; + /** MAC address */ + mac?: Maybe; + /** Network interface model */ + model?: Maybe; + /** Network speed */ + speed?: Maybe; + /** Network vendor */ + vendor?: Maybe; + /** Virtual interface flag */ + virtual?: Maybe; +}; + +export type InfoOs = Node & { + __typename?: 'InfoOs'; + /** OS architecture */ + arch?: Maybe; + /** OS build identifier */ + build?: Maybe; + /** OS codename */ + codename?: Maybe; + /** Linux distribution name */ + distro?: Maybe; + /** Fully qualified domain name */ + fqdn?: Maybe; + /** Hostname */ + hostname?: Maybe; + id: Scalars['PrefixedID']['output']; + /** Kernel version */ + kernel?: Maybe; + /** OS logo name */ + logofile?: Maybe; + /** Operating system platform */ + platform?: Maybe; + /** OS release version */ + release?: Maybe; + /** OS serial number */ + serial?: Maybe; + /** Service pack version */ + servicepack?: Maybe; + /** OS started via UEFI */ + uefi?: Maybe; + /** Boot time ISO string */ + uptime?: Maybe; +}; + +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; + /** Device type/manufacturer */ + type: Scalars['String']['output']; + /** Type identifier */ + typeid: Scalars['String']['output']; + /** Vendor ID */ + vendorid: Scalars['String']['output']; + /** Vendor name */ + vendorname?: Maybe; +}; + +export type InfoSystem = Node & { + __typename?: 'InfoSystem'; + id: Scalars['PrefixedID']['output']; + /** System manufacturer */ + manufacturer?: Maybe; + /** System model */ + model?: Maybe; + /** System serial number */ + serial?: Maybe; + /** System SKU */ + sku?: Maybe; + /** System UUID */ + uuid?: Maybe; + /** System version */ + version?: Maybe; + /** Virtual machine flag */ + virtual?: Maybe; +}; + +export type InfoUsb = Node & { + __typename?: 'InfoUsb'; + /** USB bus number */ + bus?: Maybe; + /** USB device number */ + device?: Maybe; + id: Scalars['PrefixedID']['output']; + /** USB device name */ + name: Scalars['String']['output']; +}; + +export type InfoVersions = Node & { + __typename?: 'InfoVersions'; + /** Apache version */ + apache?: Maybe; + /** Docker version */ + docker?: Maybe; + /** gcc version */ + gcc?: Maybe; + /** Git version */ + git?: Maybe; + /** Grunt version */ + grunt?: Maybe; + /** Gulp version */ + gulp?: Maybe; + id: Scalars['PrefixedID']['output']; + /** Java version */ + java?: Maybe; + /** Kernel version */ + kernel?: Maybe; + /** MongoDB version */ + mongodb?: Maybe; + /** MySQL version */ + mysql?: Maybe; + /** nginx version */ + nginx?: Maybe; + /** Node.js version */ + node?: Maybe; + /** npm version */ + npm?: Maybe; + /** OpenSSL version */ + openssl?: Maybe; + /** Perl version */ + perl?: Maybe; + /** PHP version */ + php?: Maybe; + /** pip version */ + pip?: Maybe; + /** pip3 version */ + pip3?: Maybe; + /** pm2 version */ + pm2?: Maybe; + /** Postfix version */ + postfix?: Maybe; + /** PostgreSQL version */ + postgresql?: Maybe; + /** Python version */ + python?: Maybe; + /** Python3 version */ + python3?: Maybe; + /** Redis version */ + redis?: Maybe; + /** System OpenSSL version */ + systemOpenssl?: Maybe; + /** tsc version */ + tsc?: Maybe; + /** Unraid version */ + unraid?: Maybe; + /** V8 engine version */ + v8?: Maybe; + /** VirtualBox version */ + virtualbox?: Maybe; + /** Yarn version */ + yarn?: Maybe; }; 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; + /** Memory clock speed in MHz */ clockSpeed?: Maybe; + /** Form factor (e.g., DIMM, SODIMM) */ formFactor?: Maybe; id: Scalars['PrefixedID']['output']; + /** Memory manufacturer */ manufacturer?: Maybe; + /** Part number of the memory module */ partNum?: Maybe; + /** Serial number of the memory module */ serialNum?: Maybe; + /** Memory module size in bytes */ size: Scalars['BigInt']['output']; + /** Memory type (e.g., DDR4, DDR5) */ type?: Maybe; + /** Configured voltage in millivolts */ voltageConfigured?: Maybe; + /** Maximum voltage in millivolts */ voltageMax?: Maybe; + /** Minimum voltage in millivolts */ voltageMin?: Maybe; }; +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; + id: Scalars['PrefixedID']['output']; + /** Current memory utilization metrics */ + memory?: Maybe; +}; + /** 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; - build?: Maybe; - codename?: Maybe; - codepage?: Maybe; - distro?: Maybe; - hostname?: Maybe; - id: Scalars['PrefixedID']['output']; - kernel?: Maybe; - logofile?: Maybe; - platform?: Maybe; - release?: Maybe; - serial?: Maybe; - uptime?: Maybe; -}; - 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; - class?: Maybe; - id: Scalars['PrefixedID']['output']; - productid?: Maybe; - productname?: Maybe; - type?: Maybe; - typeid?: Maybe; - vendorid?: Maybe; - vendorname?: Maybe; -}; - export type Permission = { __typename?: 'Permission'; actions: Array; @@ -1385,7 +1637,6 @@ export type Query = { customization?: Maybe; disk: Disk; disks: Array; - display: Display; docker: Docker; flash: Flash; info: Info; @@ -1394,6 +1645,7 @@ export type Query = { logFile: LogFileContent; logFiles: Array; 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; - model?: Maybe; - serial?: Maybe; - sku?: Maybe; - uuid?: Maybe; - version?: Maybe; -}; - -/** 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; }; -export type Usb = Node & { - __typename?: 'Usb'; - id: Scalars['PrefixedID']['output']; - name?: Maybe; -}; - export type UserAccount = Node & { __typename?: 'UserAccount'; /** A description of the user */ @@ -2168,37 +2403,6 @@ export type Vars = Node & { workgroup?: Maybe; }; -export type Versions = Node & { - __typename?: 'Versions'; - apache?: Maybe; - docker?: Maybe; - gcc?: Maybe; - git?: Maybe; - grunt?: Maybe; - gulp?: Maybe; - id: Scalars['PrefixedID']['output']; - kernel?: Maybe; - mongodb?: Maybe; - mysql?: Maybe; - nginx?: Maybe; - node?: Maybe; - npm?: Maybe; - openssl?: Maybe; - perl?: Maybe; - php?: Maybe; - pm2?: Maybe; - postfix?: Maybe; - postgresql?: Maybe; - python?: Maybe; - redis?: Maybe; - systemOpenssl?: Maybe; - systemOpensslLib?: Maybe; - tsc?: Maybe; - unraid?: Maybe; - v8?: Maybe; - yarn?: Maybe; -}; - 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; }>; diff --git a/api/src/unraid-api/cron/cron.module.ts b/api/src/unraid-api/cron/cron.module.ts index bc108f3db..86b0b625f 100644 --- a/api/src/unraid-api/cron/cron.module.ts +++ b/api/src/unraid-api/cron/cron.module.ts @@ -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 {} diff --git a/api/src/unraid-api/graph/resolvers/display/display.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/display/display.resolver.spec.ts index af9b51516..884805059 100644 --- a/api/src/unraid-api/graph/resolvers/display/display.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/display/display.resolver.spec.ts @@ -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', () => ({ diff --git a/api/src/unraid-api/graph/resolvers/display/display.resolver.ts b/api/src/unraid-api/graph/resolvers/display/display.resolver.ts index 5fce8d0ea..50a9b37f3 100644 --- a/api/src/unraid-api/graph/resolvers/display/display.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/display/display.resolver.ts @@ -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 { diff --git a/api/src/unraid-api/graph/resolvers/info/cpu/cpu.model.ts b/api/src/unraid-api/graph/resolvers/info/cpu/cpu.model.ts new file mode 100644 index 000000000..6a03d1002 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/cpu/cpu.model.ts @@ -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; + + @Field(() => [String], { nullable: true, description: 'CPU feature flags' }) + flags?: string[]; +} diff --git a/api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.ts b/api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.ts new file mode 100644 index 000000000..0ae43debe --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.ts @@ -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 { + 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 { + 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, + })), + }; + } +} diff --git a/api/src/unraid-api/graph/resolvers/info/devices.resolver.ts b/api/src/unraid-api/graph/resolvers/info/devices.resolver.ts deleted file mode 100644 index 4f7bcf647..000000000 --- a/api/src/unraid-api/graph/resolvers/info/devices.resolver.ts +++ /dev/null @@ -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 { - return this.devicesService.generateGpu(); - } - - @ResolveField(() => [Pci]) - public async pci(): Promise { - return this.devicesService.generatePci(); - } - - @ResolveField(() => [Usb]) - public async usb(): Promise { - return this.devicesService.generateUsb(); - } -} diff --git a/api/src/unraid-api/graph/resolvers/info/devices/devices.model.ts b/api/src/unraid-api/graph/resolvers/info/devices/devices.model.ts new file mode 100644 index 000000000..2b22415fb --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/devices/devices.model.ts @@ -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[]; +} diff --git a/api/src/unraid-api/graph/resolvers/info/devices.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/info/devices/devices.resolver.spec.ts similarity index 97% rename from api/src/unraid-api/graph/resolvers/info/devices.resolver.spec.ts rename to api/src/unraid-api/graph/resolvers/info/devices/devices.resolver.spec.ts index 9d5becb69..0fb82a111 100644 --- a/api/src/unraid-api/graph/resolvers/info/devices.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/info/devices/devices.resolver.spec.ts @@ -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; diff --git a/api/src/unraid-api/graph/resolvers/info/devices/devices.resolver.ts b/api/src/unraid-api/graph/resolvers/info/devices/devices.resolver.ts new file mode 100644 index 000000000..427125f23 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/devices/devices.resolver.ts @@ -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 { + return this.devicesService.generateGpu(); + } + + @ResolveField(() => [InfoNetwork]) + public async network(): Promise { + return this.devicesService.generateNetwork(); + } + + @ResolveField(() => [InfoPci]) + public async pci(): Promise { + return this.devicesService.generatePci(); + } + + @ResolveField(() => [InfoUsb]) + public async usb(): Promise { + return this.devicesService.generateUsb(); + } +} diff --git a/api/src/unraid-api/graph/resolvers/info/devices.service.spec.ts b/api/src/unraid-api/graph/resolvers/info/devices/devices.service.spec.ts similarity index 99% rename from api/src/unraid-api/graph/resolvers/info/devices.service.spec.ts rename to api/src/unraid-api/graph/resolvers/info/devices/devices.service.spec.ts index 85cd8c7df..46e1ee899 100644 --- a/api/src/unraid-api/graph/resolvers/info/devices.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/info/devices/devices.service.spec.ts @@ -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', () => ({ diff --git a/api/src/unraid-api/graph/resolvers/info/devices.service.ts b/api/src/unraid-api/graph/resolvers/info/devices/devices.service.ts similarity index 88% rename from api/src/unraid-api/graph/resolvers/info/devices.service.ts rename to api/src/unraid-api/graph/resolvers/info/devices/devices.service.ts index 7d90ccf5f..e2bf747cd 100644 --- a/api/src/unraid-api/graph/resolvers/info/devices.service.ts +++ b/api/src/unraid-api/graph/resolvers/info/devices/devices.service.ts @@ -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 { + async generateGpu(): Promise { 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 { + async generatePci(): Promise { try { const devices = await this.getSystemPciDevices(); return devices.map((device) => ({ @@ -73,7 +84,21 @@ export class DevicesService { } } - async generateUsb(): Promise { + async generateNetwork(): Promise { + 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 { try { const usbDevices = await this.getSystemUSBDevices(); return usbDevices.map((device) => ({ diff --git a/api/src/unraid-api/graph/resolvers/info/display/display.model.ts b/api/src/unraid-api/graph/resolvers/info/display/display.model.ts new file mode 100644 index 000000000..f75c6ed2e --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/display/display.model.ts @@ -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 }; diff --git a/api/src/unraid-api/graph/resolvers/display/display.service.spec.ts b/api/src/unraid-api/graph/resolvers/info/display/display.service.spec.ts similarity index 94% rename from api/src/unraid-api/graph/resolvers/display/display.service.spec.ts rename to api/src/unraid-api/graph/resolvers/info/display/display.service.spec.ts index cf4cda338..80a0e0c2c 100644 --- a/api/src/unraid-api/graph/resolvers/display/display.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/info/display/display.service.spec.ts @@ -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: '', diff --git a/api/src/unraid-api/graph/resolvers/display/display.service.ts b/api/src/unraid-api/graph/resolvers/info/display/display.service.ts similarity index 76% rename from api/src/unraid-api/graph/resolvers/display/display.service.ts rename to api/src/unraid-api/graph/resolvers/info/display/display.service.ts index b3d4edbd2..9668b55ac 100644 --- a/api/src/unraid-api/graph/resolvers/display/display.service.ts +++ b/api/src/unraid-api/graph/resolvers/info/display/display.service.ts @@ -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>> { const filePaths = getters.paths()['dynamix-config']; const state = filePaths.reduce>((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), diff --git a/api/src/unraid-api/graph/resolvers/info/info.model.ts b/api/src/unraid-api/graph/resolvers/info/info.model.ts index d4808a316..9550df21f 100644 --- a/api/src/unraid-api/graph/resolvers/info/info.model.ts +++ b/api/src/unraid-api/graph/resolvers/info/info.model.ts @@ -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; - - @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; } diff --git a/api/src/unraid-api/graph/resolvers/info/info.module.ts b/api/src/unraid-api/graph/resolvers/info/info.module.ts new file mode 100644 index 000000000..a28a472b5 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/info.module.ts @@ -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 {} diff --git a/api/src/unraid-api/graph/resolvers/info/info.resolver.integration.spec.ts b/api/src/unraid-api/graph/resolvers/info/info.resolver.integration.spec.ts new file mode 100644 index 000000000..2745cfae8 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/info.resolver.integration.spec.ts @@ -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); + devicesResolver = module.get(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); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/info/info.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/info/info.resolver.spec.ts index a2d4d2417..eccae5435 100644 --- a/api/src/unraid-api/graph/resolvers/info/info.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/info/info.resolver.spec.ts @@ -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); - - // Reset mocks before each test - vi.clearAllMocks(); + cpuService = module.get(CpuService); + memoryService = module.get(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); 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); 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); 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', + }); }); }); }); diff --git a/api/src/unraid-api/graph/resolvers/info/info.resolver.ts b/api/src/unraid-api/graph/resolvers/info/info.resolver.ts index a461154e8..c02180008 100644 --- a/api/src/unraid-api/graph/resolvers/info/info.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/info/info.resolver.ts @@ -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 { return new Date(); } - @ResolveField(() => InfoApps) - public async apps(): Promise { - return this.infoService.generateApps(); - } - - @ResolveField(() => Baseboard) - public async baseboard(): Promise { + @ResolveField(() => InfoBaseboard) + public async baseboard(): Promise { const baseboard = await getBaseboard(); - return { - id: 'baseboard', - ...baseboard, - }; + return { id: 'info/baseboard', ...baseboard } as InfoBaseboard; } @ResolveField(() => InfoCpu) public async cpu(): Promise { - return this.infoService.generateCpu(); + return this.cpuService.generateCpu(); } - @ResolveField(() => Devices) - public async devices(): Promise { - return this.infoService.generateDevices(); + @ResolveField(() => InfoDevices) + public devices(): Partial { + // Return minimal stub, let InfoDevicesResolver handle all fields + return { id: 'info/devices' }; } - @ResolveField(() => Display) - public async display(): Promise { + @ResolveField(() => InfoDisplay) + public async display(): Promise { return this.displayService.generateDisplay(); } @@ -85,35 +79,22 @@ export class InfoResolver { @ResolveField(() => InfoMemory) public async memory(): Promise { - return this.infoService.generateMemory(); + return this.memoryService.generateMemory(); } - @ResolveField(() => Os) - public async os(): Promise { - return this.infoService.generateOs(); + @ResolveField(() => InfoOs) + public async os(): Promise { + return this.osService.generateOs(); } - @ResolveField(() => System) - public async system(): Promise { + @ResolveField(() => InfoSystem) + public async system(): Promise { const system = await getSystem(); - return { - id: 'system', - ...system, - }; + return { id: 'info/system', ...system } as InfoSystem; } - @ResolveField(() => Versions) - public async versions(): Promise { - 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 { + return this.versionsService.generateVersions(); } } diff --git a/api/src/unraid-api/graph/resolvers/info/info.service.spec.ts b/api/src/unraid-api/graph/resolvers/info/info.service.spec.ts deleted file mode 100644 index ab9bafa7b..000000000 --- a/api/src/unraid-api/graph/resolvers/info/info.service.spec.ts +++ /dev/null @@ -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); - dockerService = module.get(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', - }); - }); - }); -}); diff --git a/api/src/unraid-api/graph/resolvers/info/info.service.ts b/api/src/unraid-api/graph/resolvers/info/info.service.ts deleted file mode 100644 index b036dbb1f..000000000 --- a/api/src/unraid-api/graph/resolvers/info/info.service.ts +++ /dev/null @@ -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 { - 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 { - const os = await osInfo(); - - return { - id: 'info/os', - ...os, - hostname: getters.emhttp().var.name, - uptime: bootTimestamp.toISOString(), - }; - } - - async generateCpu(): Promise { - 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 { - const unraid = await getUnraidVersion(); - const softwareVersions = await versions(); - - return { - id: 'info/versions', - unraid, - ...softwareVersions, - }; - } - - async generateMemory(): Promise { - 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 { - return { - id: 'info/devices', - // These fields will be resolved by DevicesResolver - } as Devices; - } -} diff --git a/api/src/unraid-api/graph/resolvers/info/memory/memory.model.ts b/api/src/unraid-api/graph/resolvers/info/memory/memory.model.ts new file mode 100644 index 000000000..4e2aa46b3 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/memory/memory.model.ts @@ -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[]; +} diff --git a/api/src/unraid-api/graph/resolvers/info/memory/memory.service.ts b/api/src/unraid-api/graph/resolvers/info/memory/memory.service.ts new file mode 100644 index 000000000..6d82cbc5e --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/memory/memory.service.ts @@ -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 { + 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 { + 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, + }; + } +} diff --git a/api/src/unraid-api/graph/resolvers/info/os/os.model.ts b/api/src/unraid-api/graph/resolvers/info/os/os.model.ts new file mode 100644 index 000000000..823e9ec3a --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/os/os.model.ts @@ -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; +} diff --git a/api/src/unraid-api/graph/resolvers/info/os/os.service.ts b/api/src/unraid-api/graph/resolvers/info/os/os.service.ts new file mode 100644 index 000000000..e0fa288b6 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/os/os.service.ts @@ -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 { + const os = await osInfo(); + + return { + id: 'info/os', + ...os, + hostname: getters.emhttp().var.name, + uptime: bootTimestamp.toISOString(), + }; + } +} diff --git a/api/src/unraid-api/graph/resolvers/info/system/system.model.ts b/api/src/unraid-api/graph/resolvers/info/system/system.model.ts new file mode 100644 index 000000000..6c9b6a150 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/system/system.model.ts @@ -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; +} diff --git a/api/src/unraid-api/graph/resolvers/info/versions/versions.model.ts b/api/src/unraid-api/graph/resolvers/info/versions/versions.model.ts new file mode 100644 index 000000000..37ad2003a --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/versions/versions.model.ts @@ -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; +} diff --git a/api/src/unraid-api/graph/resolvers/info/versions/versions.service.ts b/api/src/unraid-api/graph/resolvers/info/versions/versions.service.ts new file mode 100644 index 000000000..42c399c19 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/versions/versions.service.ts @@ -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 { + const unraid = this.configService.get('store.emhttp.var.version') || 'unknown'; + const softwareVersions = await versions(); + + return { + id: 'info/versions', + unraid, + ...softwareVersions, + }; + } +} diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.model.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.model.ts new file mode 100644 index 000000000..0e7652888 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.model.ts @@ -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; +} diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts new file mode 100644 index 000000000..93dbb7ded --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts @@ -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 {} diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts new file mode 100644 index 000000000..dc0bed698 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts @@ -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); + // Initialize the module to register polling topics + metricsResolver.onModuleInit(); + }); + + afterEach(async () => { + // Clean up polling service + const pollingService = module.get(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); + const cpuService = module.get(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); + const memoryService = module.get(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); + + // 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); + + // 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); + const trackerService = module.get(SubscriptionTrackerService); + const pollingService = module.get(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); + const trackerService = module.get(SubscriptionTrackerService); + const pollingService = module.get(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); + const pollingService = module.get(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); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.spec.ts new file mode 100644 index 000000000..af674c24e --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.spec.ts @@ -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); + cpuService = module.get(CpuService); + memoryService = module.get(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 + ); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts new file mode 100644 index 000000000..d8d11050d --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts @@ -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> { + return { + id: 'metrics', + }; + } + + @ResolveField(() => CpuUtilization, { nullable: true }) + public async cpu(): Promise { + return this.cpuService.generateCpuLoad(); + } + + @ResolveField(() => MemoryUtilization, { nullable: true }) + public async memory(): Promise { + 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); + } +} diff --git a/api/src/unraid-api/graph/resolvers/resolvers.module.ts b/api/src/unraid-api/graph/resolvers/resolvers.module.ts index e29746ba5..8d739b0af 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -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, diff --git a/api/src/unraid-api/graph/services/services.module.ts b/api/src/unraid-api/graph/services/services.module.ts new file mode 100644 index 000000000..6f5399a05 --- /dev/null +++ b/api/src/unraid-api/graph/services/services.module.ts @@ -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 {} diff --git a/api/src/unraid-api/graph/services/subscription-helper.service.spec.ts b/api/src/unraid-api/graph/services/subscription-helper.service.spec.ts new file mode 100644 index 000000000..42ec4815c --- /dev/null +++ b/api/src/unraid-api/graph/services/subscription-helper.service.spec.ts @@ -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); + }); + }); +}); diff --git a/api/src/unraid-api/graph/services/subscription-helper.service.ts b/api/src/unraid-api/graph/services/subscription-helper.service.ts new file mode 100644 index 000000000..2df982d12 --- /dev/null +++ b/api/src/unraid-api/graph/services/subscription-helper.service.ts @@ -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(topic: PUBSUB_CHANNEL): AsyncIterableIterator { + const innerIterator = createSubscription(topic); + + // Subscribe when the subscription starts + this.subscriptionTracker.subscribe(topic); + + // Return a proxy async iterator that properly handles cleanup + const proxyIterator: AsyncIterableIterator = { + 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; + } +} diff --git a/api/src/unraid-api/graph/services/subscription-polling.service.ts b/api/src/unraid-api/graph/services/subscription-polling.service.ts new file mode 100644 index 000000000..f806b13df --- /dev/null +++ b/api/src/unraid-api/graph/services/subscription-polling.service.ts @@ -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; +} + +@Injectable() +export class SubscriptionPollingService implements OnModuleDestroy { + private readonly logger = new Logger(SubscriptionPollingService.name); + private readonly activePollers = new Map(); + + 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); + } +} diff --git a/api/src/unraid-api/graph/services/subscription-tracker.service.spec.ts b/api/src/unraid-api/graph/services/subscription-tracker.service.spec.ts new file mode 100644 index 000000000..80103c10b --- /dev/null +++ b/api/src/unraid-api/graph/services/subscription-tracker.service.spec.ts @@ -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); + }); + }); +}); diff --git a/api/src/unraid-api/graph/services/subscription-tracker.service.ts b/api/src/unraid-api/graph/services/subscription-tracker.service.ts new file mode 100644 index 000000000..7876bab51 --- /dev/null +++ b/api/src/unraid-api/graph/services/subscription-tracker.service.ts @@ -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(); + private topicHandlers = new Map 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, + 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 { + 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); + } + } +} diff --git a/packages/unraid-shared/src/pubsub/graphql.pubsub.ts b/packages/unraid-shared/src/pubsub/graphql.pubsub.ts index a6529e633..602e57e89 100644 --- a/packages/unraid-shared/src/pubsub/graphql.pubsub.ts +++ b/packages/unraid-shared/src/pubsub/graphql.pubsub.ts @@ -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", diff --git a/web/composables/gql/graphql.ts b/web/composables/gql/graphql.ts index 24c259650..32c5ffa43 100644 --- a/web/composables/gql/graphql.ts +++ b/web/composables/gql/graphql.ts @@ -399,16 +399,6 @@ export enum AuthorizationRuleMode { OR = 'OR' } -export type Baseboard = Node & { - __typename?: 'Baseboard'; - assetTag?: Maybe; - id: Scalars['PrefixedID']['output']; - manufacturer: Scalars['String']['output']; - model?: Maybe; - serial?: Maybe; - version?: Maybe; -}; - 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; - error?: Maybe; - icon?: Maybe; - id: Scalars['PrefixedID']['output']; - url?: Maybe; -}; - export type Cloud = { __typename?: 'Cloud'; allowedOrigins: Array; @@ -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; + id: Scalars['PrefixedID']['output']; + /** Total CPU load in percent */ + percentTotal: Scalars['Float']['output']; +}; + export type CreateApiKeyInput = { description?: InputMaybe; name: Scalars['String']['input']; @@ -569,14 +576,6 @@ export type DeleteRCloneRemoteInput = { name: Scalars['String']['input']; }; -export type Devices = Node & { - __typename?: 'Devices'; - gpu: Array; - id: Scalars['PrefixedID']['output']; - pci: Array; - usb: Array; -}; - 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; - case?: Maybe; - critical?: Maybe; - dashapps?: Maybe; - date?: Maybe; - hot?: Maybe; - id: Scalars['PrefixedID']['output']; - locale?: Maybe; - max?: Maybe; - number?: Maybe; - resize?: Maybe; - scale?: Maybe; - tabs?: Maybe; - text?: Maybe; - theme?: Maybe; - total?: Maybe; - unit?: Maybe; - usage?: Maybe; - users?: Maybe; - warning?: Maybe; - wwn?: Maybe; -}; - export type Docker = Node & { __typename?: 'Docker'; containers: Array; @@ -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; + machineId?: Maybe; + /** 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; 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; + /** Maximum memory capacity in bytes */ + memMax?: Maybe; + /** Number of memory slots */ + memSlots?: Maybe; + /** Motherboard model */ + model?: Maybe; + /** Motherboard serial number */ + serial?: Maybe; + /** Motherboard version */ + version?: Maybe; }; export type InfoCpu = Node & { __typename?: 'InfoCpu'; - brand: Scalars['String']['output']; - cache: Scalars['JSON']['output']; - cores: Scalars['Int']['output']; - family: Scalars['String']['output']; - flags: Array; + /** CPU brand name */ + brand?: Maybe; + /** CPU cache information */ + cache?: Maybe; + /** Number of CPU cores */ + cores?: Maybe; + /** CPU family */ + family?: Maybe; + /** CPU feature flags */ + flags?: Maybe>; 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; + /** CPU model */ + model?: Maybe; + /** Number of physical processors */ + processors?: Maybe; + /** CPU revision */ + revision?: Maybe; + /** CPU socket type */ + socket?: Maybe; + /** Current CPU speed in GHz */ + speed?: Maybe; + /** Maximum CPU speed in GHz */ + speedmax?: Maybe; + /** Minimum CPU speed in GHz */ + speedmin?: Maybe; + /** CPU stepping */ + stepping?: Maybe; + /** Number of CPU threads */ + threads?: Maybe; + /** CPU vendor */ + vendor?: Maybe; + /** CPU voltage */ voltage?: Maybe; }; +export type InfoDevices = Node & { + __typename?: 'InfoDevices'; + /** List of GPU devices */ + gpu?: Maybe>; + id: Scalars['PrefixedID']['output']; + /** List of network interfaces */ + network?: Maybe>; + /** List of PCI devices */ + pci?: Maybe>; + /** List of USB devices */ + usb?: Maybe>; +}; + +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; + /** Maximum temperature threshold */ + max?: Maybe; + /** 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; +}; + 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; - 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; + id: Scalars['PrefixedID']['output']; + /** Network interface name */ + iface: Scalars['String']['output']; + /** MAC address */ + mac?: Maybe; + /** Network interface model */ + model?: Maybe; + /** Network speed */ + speed?: Maybe; + /** Network vendor */ + vendor?: Maybe; + /** Virtual interface flag */ + virtual?: Maybe; +}; + +export type InfoOs = Node & { + __typename?: 'InfoOs'; + /** OS architecture */ + arch?: Maybe; + /** OS build identifier */ + build?: Maybe; + /** OS codename */ + codename?: Maybe; + /** Linux distribution name */ + distro?: Maybe; + /** Fully qualified domain name */ + fqdn?: Maybe; + /** Hostname */ + hostname?: Maybe; + id: Scalars['PrefixedID']['output']; + /** Kernel version */ + kernel?: Maybe; + /** OS logo name */ + logofile?: Maybe; + /** Operating system platform */ + platform?: Maybe; + /** OS release version */ + release?: Maybe; + /** OS serial number */ + serial?: Maybe; + /** Service pack version */ + servicepack?: Maybe; + /** OS started via UEFI */ + uefi?: Maybe; + /** Boot time ISO string */ + uptime?: Maybe; +}; + +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; + /** Device type/manufacturer */ + type: Scalars['String']['output']; + /** Type identifier */ + typeid: Scalars['String']['output']; + /** Vendor ID */ + vendorid: Scalars['String']['output']; + /** Vendor name */ + vendorname?: Maybe; +}; + +export type InfoSystem = Node & { + __typename?: 'InfoSystem'; + id: Scalars['PrefixedID']['output']; + /** System manufacturer */ + manufacturer?: Maybe; + /** System model */ + model?: Maybe; + /** System serial number */ + serial?: Maybe; + /** System SKU */ + sku?: Maybe; + /** System UUID */ + uuid?: Maybe; + /** System version */ + version?: Maybe; + /** Virtual machine flag */ + virtual?: Maybe; +}; + +export type InfoUsb = Node & { + __typename?: 'InfoUsb'; + /** USB bus number */ + bus?: Maybe; + /** USB device number */ + device?: Maybe; + id: Scalars['PrefixedID']['output']; + /** USB device name */ + name: Scalars['String']['output']; +}; + +export type InfoVersions = Node & { + __typename?: 'InfoVersions'; + /** Apache version */ + apache?: Maybe; + /** Docker version */ + docker?: Maybe; + /** gcc version */ + gcc?: Maybe; + /** Git version */ + git?: Maybe; + /** Grunt version */ + grunt?: Maybe; + /** Gulp version */ + gulp?: Maybe; + id: Scalars['PrefixedID']['output']; + /** Java version */ + java?: Maybe; + /** Kernel version */ + kernel?: Maybe; + /** MongoDB version */ + mongodb?: Maybe; + /** MySQL version */ + mysql?: Maybe; + /** nginx version */ + nginx?: Maybe; + /** Node.js version */ + node?: Maybe; + /** npm version */ + npm?: Maybe; + /** OpenSSL version */ + openssl?: Maybe; + /** Perl version */ + perl?: Maybe; + /** PHP version */ + php?: Maybe; + /** pip version */ + pip?: Maybe; + /** pip3 version */ + pip3?: Maybe; + /** pm2 version */ + pm2?: Maybe; + /** Postfix version */ + postfix?: Maybe; + /** PostgreSQL version */ + postgresql?: Maybe; + /** Python version */ + python?: Maybe; + /** Python3 version */ + python3?: Maybe; + /** Redis version */ + redis?: Maybe; + /** System OpenSSL version */ + systemOpenssl?: Maybe; + /** tsc version */ + tsc?: Maybe; + /** Unraid version */ + unraid?: Maybe; + /** V8 engine version */ + v8?: Maybe; + /** VirtualBox version */ + virtualbox?: Maybe; + /** Yarn version */ + yarn?: Maybe; }; 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; + /** Memory clock speed in MHz */ clockSpeed?: Maybe; + /** Form factor (e.g., DIMM, SODIMM) */ formFactor?: Maybe; id: Scalars['PrefixedID']['output']; + /** Memory manufacturer */ manufacturer?: Maybe; + /** Part number of the memory module */ partNum?: Maybe; + /** Serial number of the memory module */ serialNum?: Maybe; + /** Memory module size in bytes */ size: Scalars['BigInt']['output']; + /** Memory type (e.g., DDR4, DDR5) */ type?: Maybe; + /** Configured voltage in millivolts */ voltageConfigured?: Maybe; + /** Maximum voltage in millivolts */ voltageMax?: Maybe; + /** Minimum voltage in millivolts */ voltageMin?: Maybe; }; +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; + id: Scalars['PrefixedID']['output']; + /** Current memory utilization metrics */ + memory?: Maybe; +}; + /** 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; - build?: Maybe; - codename?: Maybe; - codepage?: Maybe; - distro?: Maybe; - hostname?: Maybe; - id: Scalars['PrefixedID']['output']; - kernel?: Maybe; - logofile?: Maybe; - platform?: Maybe; - release?: Maybe; - serial?: Maybe; - uptime?: Maybe; -}; - 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; - class?: Maybe; - id: Scalars['PrefixedID']['output']; - productid?: Maybe; - productname?: Maybe; - type?: Maybe; - typeid?: Maybe; - vendorid?: Maybe; - vendorname?: Maybe; -}; - export type Permission = { __typename?: 'Permission'; actions: Array; @@ -1385,7 +1637,6 @@ export type Query = { customization?: Maybe; disk: Disk; disks: Array; - display: Display; docker: Docker; flash: Flash; info: Info; @@ -1394,6 +1645,7 @@ export type Query = { logFile: LogFileContent; logFiles: Array; 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; - model?: Maybe; - serial?: Maybe; - sku?: Maybe; - uuid?: Maybe; - version?: Maybe; -}; - -/** 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; }; -export type Usb = Node & { - __typename?: 'Usb'; - id: Scalars['PrefixedID']['output']; - name?: Maybe; -}; - export type UserAccount = Node & { __typename?: 'UserAccount'; /** A description of the user */ @@ -2168,37 +2403,6 @@ export type Vars = Node & { workgroup?: Maybe; }; -export type Versions = Node & { - __typename?: 'Versions'; - apache?: Maybe; - docker?: Maybe; - gcc?: Maybe; - git?: Maybe; - grunt?: Maybe; - gulp?: Maybe; - id: Scalars['PrefixedID']['output']; - kernel?: Maybe; - mongodb?: Maybe; - mysql?: Maybe; - nginx?: Maybe; - node?: Maybe; - npm?: Maybe; - openssl?: Maybe; - perl?: Maybe; - php?: Maybe; - pm2?: Maybe; - postfix?: Maybe; - postgresql?: Maybe; - python?: Maybe; - redis?: Maybe; - systemOpenssl?: Maybe; - systemOpensslLib?: Maybe; - tsc?: Maybe; - unraid?: Maybe; - v8?: Maybe; - yarn?: Maybe; -}; - 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; }>;