From f7e1d8f259fc02a886ef854103ed993ec652dc42 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 18 Aug 2025 18:22:24 +0000 Subject: [PATCH 01/18] feat(api): add cpu utilization query and subscription Adds a new query and subscription to the info resolver for CPU to get CPU utilization on Unraid. - Adds `cpuUtilization` query to get a one-time snapshot of CPU load. - Adds `cpuUtilizationSubscription` to get real-time updates on CPU load. - Adds a `utilization` field to the `InfoCpu` type. - Creates a generic `SubscriptionTrackerService` to manage polling for subscriptions, ensuring that polling only occurs when there are active subscribers. - Creates a request-scoped `CpuDataService` to cache CPU load data within a single GraphQL request to improve performance. - Updates tests to cover the new functionality. - Adds detailed documentation to the `CpuLoad` object type. --- .../graph/resolvers/info/cpu-data.service.ts | 14 +++ .../graph/resolvers/info/info.model.ts | 36 ++++++ .../resolvers/info/info.resolver.spec.ts | 23 ++++ .../graph/resolvers/info/info.resolver.ts | 103 +++++++++++++++++- .../graph/resolvers/info/info.service.ts | 21 +++- .../graph/resolvers/resolvers.module.ts | 6 + .../graph/services/services.module.ts | 8 ++ .../services/subscription-tracker.service.ts | 44 ++++++++ 8 files changed, 249 insertions(+), 6 deletions(-) create mode 100644 api/src/unraid-api/graph/resolvers/info/cpu-data.service.ts create mode 100644 api/src/unraid-api/graph/services/services.module.ts create mode 100644 api/src/unraid-api/graph/services/subscription-tracker.service.ts diff --git a/api/src/unraid-api/graph/resolvers/info/cpu-data.service.ts b/api/src/unraid-api/graph/resolvers/info/cpu-data.service.ts new file mode 100644 index 000000000..9d6e7f29e --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/cpu-data.service.ts @@ -0,0 +1,14 @@ +import { Injectable, Scope } from '@nestjs/common'; +import { currentLoad, Systeminformation } from 'systeminformation'; + +@Injectable({ scope: Scope.REQUEST }) +export class CpuDataService { + private cpuLoadData: Promise; + + public getCpuLoad(): Promise { + if (!this.cpuLoadData) { + this.cpuLoadData = currentLoad(); + } + return this.cpuLoadData; + } +} 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..90065db46 100644 --- a/api/src/unraid-api/graph/resolvers/info/info.model.ts +++ b/api/src/unraid-api/graph/resolvers/info/info.model.ts @@ -117,6 +117,42 @@ export class InfoCpu extends Node { @Field(() => [String]) flags!: string[]; + + @Field(() => Float, { + description: 'CPU utilization in percent', + nullable: true, + }) + utilization?: number; +} + +@ObjectType({ description: 'CPU load for a single core' }) +export class CpuLoad { + @Field(() => Float, { description: 'The total CPU load on a single core, in percent.' }) + load!: number; + + @Field(() => Float, { description: 'The percentage of time the CPU spent in user space.' }) + loadUser!: number; + + @Field(() => Float, { description: 'The percentage of time the CPU spent in kernel space.' }) + loadSystem!: number; + + @Field(() => Float, { description: 'The percentage of time the CPU spent on low-priority (niced) user space processes.' }) + loadNice!: number; + + @Field(() => Float, { description: 'The percentage of time the CPU was idle.' }) + loadIdle!: number; + + @Field(() => Float, { description: 'The percentage of time the CPU spent servicing hardware interrupts.' }) + loadIrq!: number; +} + +@ObjectType({ implements: () => Node }) +export class CpuUtilization extends Node { + @Field(() => Float) + load!: number; + + @Field(() => [CpuLoad]) + cpus!: CpuLoad[]; } @ObjectType({ implements: () => Node }) 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..73ef4d6e8 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 @@ -6,8 +6,10 @@ 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 { CpuDataService } from '@app/unraid-api/graph/resolvers/info/cpu-data.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 { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js'; // Mock necessary modules vi.mock('fs/promises', () => ({ @@ -187,6 +189,19 @@ describe('InfoResolver', () => { generateDisplay: vi.fn().mockResolvedValue(mockDisplayData), }; + const mockSubscriptionTrackerService = { + registerTopic: vi.fn(), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockCpuDataService = { + getCpuLoad: vi.fn().mockResolvedValue({ + currentLoad: 10, + cpus: [], + }), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -207,6 +222,14 @@ describe('InfoResolver', () => { provide: CACHE_MANAGER, useValue: mockCacheManager, }, + { + provide: SubscriptionTrackerService, + useValue: mockSubscriptionTrackerService, + }, + { + provide: CpuDataService, + useValue: mockCpuDataService, + }, ], }).compile(); 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..34dd16b29 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,12 @@ -import { Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql'; +import { OnModuleInit } from '@nestjs/common'; +import { + Parent, + Query, + Resolver, + Subscription, + ResolveField, +} from '@nestjs/graphql'; +import { PubSub } from 'graphql-subscriptions'; import { Resource } from '@unraid/shared/graphql.model.js'; import { @@ -6,10 +14,18 @@ import { AuthPossession, UsePermissions, } from '@unraid/shared/use-permissions.directive.js'; -import { baseboard as getBaseboard, system as getSystem } from 'systeminformation'; +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 { + createSubscription, + PUBSUB_CHANNEL, + pubsub, +} from '@app/core/pubsub.js'; + import { DisplayService } from '@app/unraid-api/graph/resolvers/display/display.service.js'; import { Baseboard, @@ -22,16 +38,40 @@ import { Os, System, Versions, + CpuUtilization, } from '@app/unraid-api/graph/resolvers/info/info.model.js'; +import { CpuDataService } from '@app/unraid-api/graph/resolvers/info/cpu-data.service.js'; import { InfoService } from '@app/unraid-api/graph/resolvers/info/info.service.js'; +import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js'; + +const CPU_UTILIZATION = 'CPU_UTILIZATION'; @Resolver(() => Info) -export class InfoResolver { +export class InfoResolver implements OnModuleInit { + private cpuPollingTimer: NodeJS.Timeout; + constructor( private readonly infoService: InfoService, - private readonly displayService: DisplayService + private readonly displayService: DisplayService, + private readonly subscriptionTracker: SubscriptionTrackerService, + private readonly cpuDataService: CpuDataService ) {} + onModuleInit() { + this.subscriptionTracker.registerTopic( + CPU_UTILIZATION, + () => { + this.cpuPollingTimer = setInterval(async () => { + const payload = await this.infoService.generateCpuLoad(); + pubsub.publish(CPU_UTILIZATION, { cpuUtilization: payload }); + }, 1000); + }, + () => { + clearInterval(this.cpuPollingTimer); + } + ); + } + @Query(() => Info) @UsePermissions({ action: AuthActionVerb.READ, @@ -116,4 +156,57 @@ export class InfoResolver { public async infoSubscription() { return createSubscription(PUBSUB_CHANNEL.INFO); } + + @Query(() => CpuUtilization) + @UsePermissions({ + action: AuthActionVerb.READ, + resource: Resource.INFO, + possession: AuthPossession.ANY, + }) + public async cpuUtilization(): Promise { + const { currentLoad: load, cpus } = await this.cpuDataService.getCpuLoad(); + return { + id: 'info/cpu-load', + load, + cpus, + }; + } + + @Subscription(() => CpuUtilization, { + name: 'cpuUtilization', + resolve: (value) => value.cpuUtilization, + }) + @UsePermissions({ + action: AuthActionVerb.READ, + resource: Resource.INFO, + possession: AuthPossession.ANY, + }) + public async cpuUtilizationSubscription() { + const iterator = createSubscription(CPU_UTILIZATION); + + return { + [Symbol.asyncIterator]: () => { + this.subscriptionTracker.subscribe(CPU_UTILIZATION); + return iterator[Symbol.asyncIterator](); + }, + return: () => { + this.subscriptionTracker.unsubscribe(CPU_UTILIZATION); + return iterator.return(); + }, + }; + } +} + +@Resolver(() => InfoCpu) +export class InfoCpuResolver { + constructor(private readonly cpuDataService: CpuDataService) {} + + @ResolveField(() => Number, { + description: 'CPU utilization in percent', + nullable: true, + }) + public async utilization(@Parent() cpu: InfoCpu): Promise { + const { currentLoad } = await this.cpuDataService.getCpuLoad(); + return currentLoad; + } } diff --git a/api/src/unraid-api/graph/resolvers/info/info.service.ts b/api/src/unraid-api/graph/resolvers/info/info.service.ts index b036dbb1f..194bb383d 100644 --- a/api/src/unraid-api/graph/resolvers/info/info.service.ts +++ b/api/src/unraid-api/graph/resolvers/info/info.service.ts @@ -1,6 +1,14 @@ import { Injectable } from '@nestjs/common'; -import { cpu, cpuFlags, mem, memLayout, osInfo, versions } from 'systeminformation'; +import { + cpu, + cpuFlags, + mem, + memLayout, + osInfo, + versions, + currentLoad, +} from 'systeminformation'; import { bootTimestamp } from '@app/common/dashboard/boot-timestamp.js'; import { getUnraidVersion } from '@app/common/dashboard/get-unraid-version.js'; @@ -15,6 +23,7 @@ import { Os as InfoOs, MemoryLayout, Versions, + CpuUtilization, } from '@app/unraid-api/graph/resolvers/info/info.model.js'; @Injectable() @@ -91,4 +100,14 @@ export class InfoService { // These fields will be resolved by DevicesResolver } as Devices; } + + async generateCpuLoad(): Promise { + const { currentLoad: load, cpus } = await currentLoad(); + + return { + id: 'info/cpu-load', + load, + cpus, + }; + } } diff --git a/api/src/unraid-api/graph/resolvers/resolvers.module.ts b/api/src/unraid-api/graph/resolvers/resolvers.module.ts index e29746ba5..58482f891 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -33,12 +33,16 @@ 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'; +import { InfoCpuResolver } from '@app/unraid-api/graph/resolvers/info/info.resolver.js'; +import { CpuDataService } from '@app/unraid-api/graph/resolvers/info/cpu-data.service.js'; @Module({ imports: [ + ServicesModule, ArrayModule, ApiKeyModule, AuthModule, @@ -76,6 +80,8 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js'; VmMutationsResolver, VmsResolver, VmsService, + InfoCpuResolver, + CpuDataService, ], exports: [ApiKeyModule], }) 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..63807bf42 --- /dev/null +++ b/api/src/unraid-api/graph/services/services.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { SubscriptionTrackerService } from './subscription-tracker.service'; + +@Module({ + providers: [SubscriptionTrackerService], + exports: [SubscriptionTrackerService], +}) +export class ServicesModule {} 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..b118354af --- /dev/null +++ b/api/src/unraid-api/graph/services/subscription-tracker.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class SubscriptionTrackerService { + private subscriberCounts = new Map(); + private topicHandlers = new Map< + string, + { onStart: () => void; onStop: () => void } + >(); + + public registerTopic( + topic: string, + onStart: () => void, + onStop: () => void + ): void { + this.topicHandlers.set(topic, { onStart, onStop }); + } + + public subscribe(topic: string): void { + const currentCount = this.subscriberCounts.get(topic) || 0; + this.subscriberCounts.set(topic, currentCount + 1); + + if (currentCount === 0) { + const handlers = this.topicHandlers.get(topic); + if (handlers) { + handlers.onStart(); + } + } + } + + public unsubscribe(topic: string): void { + const currentCount = this.subscriberCounts.get(topic) || 1; + const newCount = currentCount - 1; + + this.subscriberCounts.set(topic, newCount); + + if (newCount === 0) { + const handlers = this.topicHandlers.get(topic); + if (handlers) { + handlers.onStop(); + } + } + } +} From ed9820676966e637fb7dd28ffa063f3698984a19 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 18 Aug 2025 18:08:50 -0400 Subject: [PATCH 02/18] refactor(api): reorganize info module imports and enhance CPU data handling - Consolidated imports in the info resolver and service for better readability. - Added `CpuDataService` to manage CPU load data more efficiently. - Updated `InfoResolver` to include `InfoCpuResolver` for improved CPU data handling. - Enhanced documentation for CPU load fields in the model to improve clarity. - Streamlined the `SubscriptionTrackerService` for better topic management. --- .../graph/resolvers/info/cpu-data.service.ts | 1 + .../graph/resolvers/info/info.model.ts | 9 +++++-- .../graph/resolvers/info/info.resolver.ts | 26 +++++-------------- .../graph/resolvers/info/info.service.ts | 12 ++------- .../graph/resolvers/resolvers.module.ts | 5 ++-- .../graph/services/services.module.ts | 3 ++- .../services/subscription-tracker.service.ts | 11 ++------ 7 files changed, 22 insertions(+), 45 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/info/cpu-data.service.ts b/api/src/unraid-api/graph/resolvers/info/cpu-data.service.ts index 9d6e7f29e..5b9303dbd 100644 --- a/api/src/unraid-api/graph/resolvers/info/cpu-data.service.ts +++ b/api/src/unraid-api/graph/resolvers/info/cpu-data.service.ts @@ -1,4 +1,5 @@ import { Injectable, Scope } from '@nestjs/common'; + import { currentLoad, Systeminformation } from 'systeminformation'; @Injectable({ scope: Scope.REQUEST }) 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 90065db46..68ff41d7a 100644 --- a/api/src/unraid-api/graph/resolvers/info/info.model.ts +++ b/api/src/unraid-api/graph/resolvers/info/info.model.ts @@ -136,13 +136,18 @@ export class CpuLoad { @Field(() => Float, { description: 'The percentage of time the CPU spent in kernel space.' }) loadSystem!: number; - @Field(() => Float, { description: 'The percentage of time the CPU spent on low-priority (niced) user space processes.' }) + @Field(() => Float, { + description: + 'The percentage of time the CPU spent on low-priority (niced) user space processes.', + }) loadNice!: number; @Field(() => Float, { description: 'The percentage of time the CPU was idle.' }) loadIdle!: number; - @Field(() => Float, { description: 'The percentage of time the CPU spent servicing hardware interrupts.' }) + @Field(() => Float, { + description: 'The percentage of time the CPU spent servicing hardware interrupts.', + }) loadIrq!: number; } 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 34dd16b29..43428a2ae 100644 --- a/api/src/unraid-api/graph/resolvers/info/info.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/info/info.resolver.ts @@ -1,12 +1,5 @@ import { OnModuleInit } from '@nestjs/common'; -import { - Parent, - Query, - Resolver, - Subscription, - ResolveField, -} from '@nestjs/graphql'; -import { PubSub } from 'graphql-subscriptions'; +import { Parent, Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql'; import { Resource } from '@unraid/shared/graphql.model.js'; import { @@ -14,21 +7,16 @@ import { AuthPossession, UsePermissions, } from '@unraid/shared/use-permissions.directive.js'; -import { - baseboard as getBaseboard, - system as getSystem, -} from 'systeminformation'; +import { PubSub } from 'graphql-subscriptions'; +import { baseboard as getBaseboard, system as getSystem } from 'systeminformation'; +import { createSubscription, pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; import { getMachineId } from '@app/core/utils/misc/get-machine-id.js'; -import { - createSubscription, - PUBSUB_CHANNEL, - pubsub, -} from '@app/core/pubsub.js'; - import { DisplayService } from '@app/unraid-api/graph/resolvers/display/display.service.js'; +import { CpuDataService } from '@app/unraid-api/graph/resolvers/info/cpu-data.service.js'; import { Baseboard, + CpuUtilization, Devices, Display, Info, @@ -38,9 +26,7 @@ import { Os, System, Versions, - CpuUtilization, } from '@app/unraid-api/graph/resolvers/info/info.model.js'; -import { CpuDataService } from '@app/unraid-api/graph/resolvers/info/cpu-data.service.js'; import { InfoService } from '@app/unraid-api/graph/resolvers/info/info.service.js'; import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js'; diff --git a/api/src/unraid-api/graph/resolvers/info/info.service.ts b/api/src/unraid-api/graph/resolvers/info/info.service.ts index 194bb383d..8f5c155e6 100644 --- a/api/src/unraid-api/graph/resolvers/info/info.service.ts +++ b/api/src/unraid-api/graph/resolvers/info/info.service.ts @@ -1,14 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { - cpu, - cpuFlags, - mem, - memLayout, - osInfo, - versions, - currentLoad, -} from 'systeminformation'; +import { cpu, cpuFlags, currentLoad, 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'; @@ -16,6 +8,7 @@ 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 { + CpuUtilization, Devices, InfoApps, InfoCpu, @@ -23,7 +16,6 @@ import { Os as InfoOs, MemoryLayout, Versions, - CpuUtilization, } from '@app/unraid-api/graph/resolvers/info/info.model.js'; @Injectable() diff --git a/api/src/unraid-api/graph/resolvers/resolvers.module.ts b/api/src/unraid-api/graph/resolvers/resolvers.module.ts index 58482f891..394601779 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -12,9 +12,10 @@ import { DisplayService } from '@app/unraid-api/graph/resolvers/display/display. 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 { CpuDataService } from '@app/unraid-api/graph/resolvers/info/cpu-data.service.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 { InfoCpuResolver, InfoResolver } from '@app/unraid-api/graph/resolvers/info/info.resolver.js'; import { InfoService } from '@app/unraid-api/graph/resolvers/info/info.service.js'; import { LogsResolver } from '@app/unraid-api/graph/resolvers/logs/logs.resolver.js'; import { LogsService } from '@app/unraid-api/graph/resolvers/logs/logs.service.js'; @@ -37,8 +38,6 @@ import { ServicesModule } from '@app/unraid-api/graph/services/services.module.j 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'; -import { InfoCpuResolver } from '@app/unraid-api/graph/resolvers/info/info.resolver.js'; -import { CpuDataService } from '@app/unraid-api/graph/resolvers/info/cpu-data.service.js'; @Module({ imports: [ diff --git a/api/src/unraid-api/graph/services/services.module.ts b/api/src/unraid-api/graph/services/services.module.ts index 63807bf42..ecb022bb7 100644 --- a/api/src/unraid-api/graph/services/services.module.ts +++ b/api/src/unraid-api/graph/services/services.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; -import { SubscriptionTrackerService } from './subscription-tracker.service'; + +import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service'; @Module({ providers: [SubscriptionTrackerService], diff --git a/api/src/unraid-api/graph/services/subscription-tracker.service.ts b/api/src/unraid-api/graph/services/subscription-tracker.service.ts index b118354af..1120048d2 100644 --- a/api/src/unraid-api/graph/services/subscription-tracker.service.ts +++ b/api/src/unraid-api/graph/services/subscription-tracker.service.ts @@ -3,16 +3,9 @@ import { Injectable } from '@nestjs/common'; @Injectable() export class SubscriptionTrackerService { private subscriberCounts = new Map(); - private topicHandlers = new Map< - string, - { onStart: () => void; onStop: () => void } - >(); + private topicHandlers = new Map void; onStop: () => void }>(); - public registerTopic( - topic: string, - onStart: () => void, - onStop: () => void - ): void { + public registerTopic(topic: string, onStart: () => void, onStop: () => void): void { this.topicHandlers.set(topic, { onStart, onStop }); } From a08726aad7fad3659ecf92c496480c7ec8537f1b Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 18 Aug 2025 18:09:44 -0400 Subject: [PATCH 03/18] refactor(api): improve CPU load data caching in CpuDataService - Updated `CpuDataService` to use nullish coalescing for initializing `cpuLoadData`, enhancing readability and efficiency. - Ensured `cpuLoadData` can be undefined, allowing for more flexible handling of CPU load data retrieval. --- api/src/unraid-api/graph/resolvers/info/cpu-data.service.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/info/cpu-data.service.ts b/api/src/unraid-api/graph/resolvers/info/cpu-data.service.ts index 5b9303dbd..3fa3c8c7e 100644 --- a/api/src/unraid-api/graph/resolvers/info/cpu-data.service.ts +++ b/api/src/unraid-api/graph/resolvers/info/cpu-data.service.ts @@ -4,12 +4,10 @@ import { currentLoad, Systeminformation } from 'systeminformation'; @Injectable({ scope: Scope.REQUEST }) export class CpuDataService { - private cpuLoadData: Promise; + private cpuLoadData: Promise | undefined; public getCpuLoad(): Promise { - if (!this.cpuLoadData) { - this.cpuLoadData = currentLoad(); - } + this.cpuLoadData ??= currentLoad(); return this.cpuLoadData; } } From 476abd5f53b3671d63a2a53196fa0c3d3a20cc7c Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 18 Aug 2025 18:14:51 -0400 Subject: [PATCH 04/18] refactor(api): update CPU utilization handling in InfoResolver - Changed CPU utilization topic references to use the new PUBSUB_CHANNEL enum for consistency. - Updated InfoResolver to handle CPU polling timer as potentially undefined, improving type safety. - Adjusted import statement for SubscriptionTrackerService to include the correct file extension. --- .../graph/resolvers/info/info.resolver.ts | 14 ++++++-------- .../unraid-api/graph/services/services.module.ts | 2 +- .../unraid-shared/src/pubsub/graphql.pubsub.ts | 1 + 3 files changed, 8 insertions(+), 9 deletions(-) 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 43428a2ae..3c644d799 100644 --- a/api/src/unraid-api/graph/resolvers/info/info.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/info/info.resolver.ts @@ -30,11 +30,9 @@ import { import { InfoService } from '@app/unraid-api/graph/resolvers/info/info.service.js'; import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js'; -const CPU_UTILIZATION = 'CPU_UTILIZATION'; - @Resolver(() => Info) export class InfoResolver implements OnModuleInit { - private cpuPollingTimer: NodeJS.Timeout; + private cpuPollingTimer: NodeJS.Timeout | undefined; constructor( private readonly infoService: InfoService, @@ -45,11 +43,11 @@ export class InfoResolver implements OnModuleInit { onModuleInit() { this.subscriptionTracker.registerTopic( - CPU_UTILIZATION, + PUBSUB_CHANNEL.CPU_UTILIZATION, () => { this.cpuPollingTimer = setInterval(async () => { const payload = await this.infoService.generateCpuLoad(); - pubsub.publish(CPU_UTILIZATION, { cpuUtilization: payload }); + pubsub.publish(PUBSUB_CHANNEL.CPU_UTILIZATION, { cpuUtilization: payload }); }, 1000); }, () => { @@ -168,15 +166,15 @@ export class InfoResolver implements OnModuleInit { possession: AuthPossession.ANY, }) public async cpuUtilizationSubscription() { - const iterator = createSubscription(CPU_UTILIZATION); + const iterator = createSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); return { [Symbol.asyncIterator]: () => { - this.subscriptionTracker.subscribe(CPU_UTILIZATION); + this.subscriptionTracker.subscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); return iterator[Symbol.asyncIterator](); }, return: () => { - this.subscriptionTracker.unsubscribe(CPU_UTILIZATION); + this.subscriptionTracker.unsubscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); return iterator.return(); }, }; diff --git a/api/src/unraid-api/graph/services/services.module.ts b/api/src/unraid-api/graph/services/services.module.ts index ecb022bb7..4c59dc97f 100644 --- a/api/src/unraid-api/graph/services/services.module.ts +++ b/api/src/unraid-api/graph/services/services.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; -import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service'; +import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js'; @Module({ providers: [SubscriptionTrackerService], diff --git a/packages/unraid-shared/src/pubsub/graphql.pubsub.ts b/packages/unraid-shared/src/pubsub/graphql.pubsub.ts index a6529e633..9c16a778f 100644 --- a/packages/unraid-shared/src/pubsub/graphql.pubsub.ts +++ b/packages/unraid-shared/src/pubsub/graphql.pubsub.ts @@ -4,6 +4,7 @@ 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", From 3afc4219ee5723664c4ebe48bbfd67458d40821e Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 18 Aug 2025 18:25:08 -0400 Subject: [PATCH 05/18] refactor(api): streamline InfoResolver and SubscriptionTrackerService - Removed unnecessary imports and parameters in InfoResolver for cleaner code. - Updated CPU utilization method to directly use infoService for generating CPU load. - Enhanced SubscriptionTrackerService to improve subscriber count management and added early return for idempotency in unsubscribe method. --- .../graph/resolvers/info/info.resolver.ts | 15 ++++-------- .../services/subscription-tracker.service.ts | 23 ++++++++++++++----- 2 files changed, 21 insertions(+), 17 deletions(-) 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 3c644d799..1f96c2e0f 100644 --- a/api/src/unraid-api/graph/resolvers/info/info.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/info/info.resolver.ts @@ -1,5 +1,5 @@ import { OnModuleInit } from '@nestjs/common'; -import { Parent, Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql'; +import { Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql'; import { Resource } from '@unraid/shared/graphql.model.js'; import { @@ -7,7 +7,6 @@ import { AuthPossession, UsePermissions, } from '@unraid/shared/use-permissions.directive.js'; -import { PubSub } from 'graphql-subscriptions'; import { baseboard as getBaseboard, system as getSystem } from 'systeminformation'; import { createSubscription, pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; @@ -37,8 +36,7 @@ export class InfoResolver implements OnModuleInit { constructor( private readonly infoService: InfoService, private readonly displayService: DisplayService, - private readonly subscriptionTracker: SubscriptionTrackerService, - private readonly cpuDataService: CpuDataService + private readonly subscriptionTracker: SubscriptionTrackerService ) {} onModuleInit() { @@ -148,12 +146,7 @@ export class InfoResolver implements OnModuleInit { possession: AuthPossession.ANY, }) public async cpuUtilization(): Promise { - const { currentLoad: load, cpus } = await this.cpuDataService.getCpuLoad(); - return { - id: 'info/cpu-load', - load, - cpus, - }; + return this.infoService.generateCpuLoad(); } @Subscription(() => CpuUtilization, { @@ -189,7 +182,7 @@ export class InfoCpuResolver { description: 'CPU utilization in percent', nullable: true, }) - public async utilization(@Parent() cpu: InfoCpu): Promise { + public async utilization(): Promise { const { currentLoad } = await this.cpuDataService.getCpuLoad(); return currentLoad; } diff --git a/api/src/unraid-api/graph/services/subscription-tracker.service.ts b/api/src/unraid-api/graph/services/subscription-tracker.service.ts index 1120048d2..96fd425c3 100644 --- a/api/src/unraid-api/graph/services/subscription-tracker.service.ts +++ b/api/src/unraid-api/graph/services/subscription-tracker.service.ts @@ -10,28 +10,39 @@ export class SubscriptionTrackerService { } public subscribe(topic: string): void { - const currentCount = this.subscriberCounts.get(topic) || 0; + const currentCount = this.subscriberCounts.get(topic) ?? 0; this.subscriberCounts.set(topic, currentCount + 1); if (currentCount === 0) { const handlers = this.topicHandlers.get(topic); - if (handlers) { + if (handlers?.onStart) { handlers.onStart(); } } } public unsubscribe(topic: string): void { - const currentCount = this.subscriberCounts.get(topic) || 1; + const currentCount = this.subscriberCounts.get(topic) ?? 0; + + // Early return for idempotency - if already at 0, do nothing + if (currentCount === 0) { + return; + } + const newCount = currentCount - 1; - this.subscriberCounts.set(topic, newCount); - if (newCount === 0) { + // Delete the topic entry when reaching zero + this.subscriberCounts.delete(topic); + + // Call onStop handler if it exists const handlers = this.topicHandlers.get(topic); - if (handlers) { + if (handlers?.onStop) { handlers.onStop(); } + } else { + // Only update the count if not zero + this.subscriberCounts.set(topic, newCount); } } } From f8cfe38d6fcae1d294c6f13ad8a6bb59176d1a3a Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 18 Aug 2025 21:18:40 -0400 Subject: [PATCH 06/18] feat(api): introduce SubscriptionHelperService for improved subscription management - Added `SubscriptionHelperService` to streamline the creation of tracked GraphQL subscriptions with automatic cleanup. - Updated `InfoResolver` to utilize the new service for managing CPU utilization subscriptions, enhancing code clarity and maintainability. - Introduced unit tests for `SubscriptionHelperService` and `SubscriptionTrackerService` to ensure robust functionality and reliability. - Refactored `SubscriptionTrackerService` to include logging for subscription events, improving observability. - Removed deprecated types and methods related to previous subscription handling, simplifying the codebase. --- api/generated-schema.graphql | 257 ++------------- .../graph/resolvers/info/info.resolver.ts | 17 +- .../graph/services/services.module.ts | 5 +- .../subscription-helper.service.spec.ts | 302 ++++++++++++++++++ .../services/subscription-helper.service.ts | 57 ++++ .../subscription-tracker.service.spec.ts | 285 +++++++++++++++++ .../services/subscription-tracker.service.ts | 35 +- 7 files changed, 718 insertions(+), 240 deletions(-) create mode 100644 api/src/unraid-api/graph/services/subscription-helper.service.spec.ts create mode 100644 api/src/unraid-api/graph/services/subscription-helper.service.ts create mode 100644 api/src/unraid-api/graph/services/subscription-tracker.service.spec.ts diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 85c4b5dad..3b62cf825 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -978,6 +978,38 @@ type InfoCpu implements Node { socket: String! cache: JSON! flags: [String!]! + + """CPU utilization in percent""" + utilization: Float +} + +"""CPU load for a single core""" +type CpuLoad { + """The total CPU load on a single core, in percent.""" + load: Float! + + """The percentage of time the CPU spent in user space.""" + loadUser: Float! + + """The percentage of time the CPU spent in kernel space.""" + loadSystem: Float! + + """ + The percentage of time the CPU spent on low-priority (niced) user space processes. + """ + loadNice: Float! + + """The percentage of time the CPU was idle.""" + loadIdle: Float! + + """The percentage of time the CPU spent servicing hardware interrupts.""" + loadIrq: Float! +} + +type CpuUtilization implements Node { + id: PrefixedID! + load: Float! + cpus: [CpuLoad!]! } type Gpu implements Node { @@ -1750,160 +1782,6 @@ type Plugin { hasCliModule: Boolean } -type AccessUrl { - type: URL_TYPE! - name: String - ipv4: URL - ipv6: URL -} - -enum URL_TYPE { - LAN - WIREGUARD - WAN - MDNS - OTHER - DEFAULT -} - -""" -A field whose value conforms to the standard URL format as specified in RFC3986: https://www.ietf.org/rfc/rfc3986.txt. -""" -scalar URL - -type AccessUrlObject { - ipv4: String - ipv6: String - type: URL_TYPE! - name: String -} - -type ApiKeyResponse { - valid: Boolean! - error: String -} - -type MinigraphqlResponse { - status: MinigraphStatus! - timeout: Int - error: String -} - -"""The status of the minigraph""" -enum MinigraphStatus { - PRE_INIT - CONNECTING - CONNECTED - PING_FAILURE - ERROR_RETRYING -} - -type CloudResponse { - status: String! - ip: String - error: String -} - -type RelayResponse { - status: String! - timeout: String - error: String -} - -type Cloud { - error: String - apiKey: ApiKeyResponse! - relay: RelayResponse - minigraphql: MinigraphqlResponse! - cloud: CloudResponse! - allowedOrigins: [String!]! -} - -type RemoteAccess { - """The type of WAN access used for Remote Access""" - accessType: WAN_ACCESS_TYPE! - - """The type of port forwarding used for Remote Access""" - forwardType: WAN_FORWARD_TYPE - - """The port used for Remote Access""" - port: Int -} - -enum WAN_ACCESS_TYPE { - DYNAMIC - ALWAYS - DISABLED -} - -enum WAN_FORWARD_TYPE { - UPNP - STATIC -} - -type DynamicRemoteAccessStatus { - """The type of dynamic remote access that is enabled""" - enabledType: DynamicRemoteAccessType! - - """The type of dynamic remote access that is currently running""" - runningType: DynamicRemoteAccessType! - - """Any error message associated with the dynamic remote access""" - error: String -} - -enum DynamicRemoteAccessType { - STATIC - UPNP - DISABLED -} - -type ConnectSettingsValues { - """The type of WAN access used for Remote Access""" - accessType: WAN_ACCESS_TYPE! - - """The type of port forwarding used for Remote Access""" - forwardType: WAN_FORWARD_TYPE - - """The port used for Remote Access""" - port: Int -} - -type ConnectSettings implements Node { - id: PrefixedID! - - """The data schema for the Connect settings""" - dataSchema: JSON! - - """The UI schema for the Connect settings""" - uiSchema: JSON! - - """The values for the Connect settings""" - values: ConnectSettingsValues! -} - -type Connect implements Node { - id: PrefixedID! - - """The status of dynamic remote access""" - dynamicRemoteAccess: DynamicRemoteAccessStatus! - - """The settings for the Connect instance""" - settings: ConnectSettings! -} - -type Network implements Node { - id: PrefixedID! - accessUrls: [AccessUrl!] -} - -input AccessUrlObjectInput { - ipv4: String - ipv6: String - type: URL_TYPE! - name: String -} - "\n### Description:\n\nID scalar type that prefixes the underlying ID with the server identifier on output and strips it on input.\n\nWe use this scalar type to ensure that the ID is unique across all servers, allowing the same underlying resource ID to be used across different server instances.\n\n#### Input Behavior:\n\nWhen providing an ID as input (e.g., in arguments or input objects), the server identifier prefix (':') is optional.\n\n- If the prefix is present (e.g., '123:456'), it will be automatically stripped, and only the underlying ID ('456') will be used internally.\n- If the prefix is absent (e.g., '456'), the ID will be used as-is.\n\nThis makes it flexible for clients, as they don't strictly need to know or provide the server ID.\n\n#### Output Behavior:\n\nWhen an ID is returned in the response (output), it will *always* be prefixed with the current server's unique identifier (e.g., '123:456').\n\n#### Example:\n\nNote: The server identifier is '123' in this example.\n\n##### Input (Prefix Optional):\n```graphql\n# Both of these are valid inputs resolving to internal ID '456'\n{\n someQuery(id: \"123:456\") { ... }\n anotherQuery(id: \"456\") { ... }\n}\n```\n\n##### Output (Prefix Always Added):\n```graphql\n# Assuming internal ID is '456'\n{\n \"data\": {\n \"someResource\": {\n \"id\": \"123:456\" \n }\n }\n}\n```\n " scalar PrefixedID @@ -1920,6 +1798,7 @@ type Query { display: Display! flash: Flash! info: Info! + cpuUtilization: CpuUtilization! logFiles: [LogFile!]! logFile(path: String!, lines: Int, startLine: Int): LogFileContent! me: UserAccount! @@ -1967,10 +1846,6 @@ type Query { """List all installed plugins with their metadata""" plugins: [Plugin!]! - remoteAccess: RemoteAccess! - connect: Connect! - network: Network! - cloud: Cloud! } type Mutation { @@ -2018,11 +1893,6 @@ type Mutation { Remove one or more plugins from the API. Returns false if restart was triggered automatically, true if manual restart is required. """ removePlugin(input: PluginManagementInput!): Boolean! - updateApiSettings(input: ConnectSettingsInput!): ConnectSettingsValues! - connectSignIn(input: ConnectSignInInput!): Boolean! - connectSignOut: Boolean! - setupRemoteAccess(input: SetupRemoteAccessInput!): Boolean! - enableDynamicRemoteAccess(input: EnableDynamicRemoteAccessInput!): Boolean! } input NotificationData { @@ -2140,69 +2010,10 @@ input PluginManagementInput { restart: Boolean! = true } -input ConnectSettingsInput { - """The type of WAN access to use for Remote Access""" - accessType: WAN_ACCESS_TYPE - - """The type of port forwarding to use for Remote Access""" - forwardType: WAN_FORWARD_TYPE - - """ - The port to use for Remote Access. Not required for UPNP forwardType. Required for STATIC forwardType. Ignored if accessType is DISABLED or forwardType is UPNP. - """ - port: Int -} - -input ConnectSignInInput { - """The API key for authentication""" - apiKey: String! - - """User information for the sign-in""" - userInfo: ConnectUserInfoInput -} - -input ConnectUserInfoInput { - """The preferred username of the user""" - preferred_username: String! - - """The email address of the user""" - email: String! - - """The avatar URL of the user""" - avatar: String -} - -input SetupRemoteAccessInput { - """The type of WAN access to use for Remote Access""" - accessType: WAN_ACCESS_TYPE! - - """The type of port forwarding to use for Remote Access""" - forwardType: WAN_FORWARD_TYPE - - """ - The port to use for Remote Access. Not required for UPNP forwardType. Required for STATIC forwardType. Ignored if accessType is DISABLED or forwardType is UPNP. - """ - port: Int -} - -input EnableDynamicRemoteAccessInput { - """The AccessURL Input for dynamic remote access""" - url: AccessUrlInput! - - """Whether to enable or disable dynamic remote access""" - enabled: Boolean! -} - -input AccessUrlInput { - type: URL_TYPE! - name: String - ipv4: URL - ipv6: URL -} - type Subscription { displaySubscription: Display! infoSubscription: Info! + cpuUtilization: CpuUtilization! logFile(path: String!): LogFileContent! notificationAdded: Notification! notificationsOverview: NotificationOverview! 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 1f96c2e0f..cd94265d2 100644 --- a/api/src/unraid-api/graph/resolvers/info/info.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/info/info.resolver.ts @@ -27,6 +27,7 @@ import { Versions, } from '@app/unraid-api/graph/resolvers/info/info.model.js'; import { InfoService } from '@app/unraid-api/graph/resolvers/info/info.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'; @Resolver(() => Info) @@ -36,7 +37,8 @@ export class InfoResolver implements OnModuleInit { constructor( private readonly infoService: InfoService, private readonly displayService: DisplayService, - private readonly subscriptionTracker: SubscriptionTrackerService + private readonly subscriptionTracker: SubscriptionTrackerService, + private readonly subscriptionHelper: SubscriptionHelperService ) {} onModuleInit() { @@ -159,18 +161,7 @@ export class InfoResolver implements OnModuleInit { possession: AuthPossession.ANY, }) public async cpuUtilizationSubscription() { - const iterator = createSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); - - return { - [Symbol.asyncIterator]: () => { - this.subscriptionTracker.subscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); - return iterator[Symbol.asyncIterator](); - }, - return: () => { - this.subscriptionTracker.unsubscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); - return iterator.return(); - }, - }; + return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); } } diff --git a/api/src/unraid-api/graph/services/services.module.ts b/api/src/unraid-api/graph/services/services.module.ts index 4c59dc97f..ffc669076 100644 --- a/api/src/unraid-api/graph/services/services.module.ts +++ b/api/src/unraid-api/graph/services/services.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; +import { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js'; import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js'; @Module({ - providers: [SubscriptionTrackerService], - exports: [SubscriptionTrackerService], + providers: [SubscriptionTrackerService, SubscriptionHelperService], + exports: [SubscriptionTrackerService, SubscriptionHelperService], }) 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..ee1d52c6b --- /dev/null +++ b/api/src/unraid-api/graph/services/subscription-helper.service.spec.ts @@ -0,0 +1,302 @@ +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(() => { + trackerService = new SubscriptionTrackerService(); + 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..4fd12fb2b --- /dev/null +++ b/api/src/unraid-api/graph/services/subscription-helper.service.ts @@ -0,0 +1,57 @@ +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 iterator = createSubscription(topic) as AsyncIterable; + const innerIterator = iterator[Symbol.asyncIterator](); + + // 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-tracker.service.spec.ts b/api/src/unraid-api/graph/services/subscription-tracker.service.spec.ts new file mode 100644 index 000000000..65dd4ae33 --- /dev/null +++ b/api/src/unraid-api/graph/services/subscription-tracker.service.spec.ts @@ -0,0 +1,285 @@ +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(() => { + service = new SubscriptionTrackerService(); + // 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 index 96fd425c3..0186dec6c 100644 --- a/api/src/unraid-api/graph/services/subscription-tracker.service.ts +++ b/api/src/unraid-api/graph/services/subscription-tracker.service.ts @@ -1,7 +1,8 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; @Injectable() export class SubscriptionTrackerService { + private readonly logger = new Logger(SubscriptionTrackerService.name); private subscriberCounts = new Map(); private topicHandlers = new Map void; onStop: () => void }>(); @@ -11,9 +12,13 @@ export class SubscriptionTrackerService { public subscribe(topic: string): void { const currentCount = this.subscriberCounts.get(topic) ?? 0; - this.subscriberCounts.set(topic, currentCount + 1); + 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(); @@ -21,20 +26,46 @@ export class SubscriptionTrackerService { } } + /** + * 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) { From 573b04813dc5ed585edff4cdd25a991eb8a30dd0 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 18 Aug 2025 21:21:40 -0400 Subject: [PATCH 07/18] feat(api): expand GraphQL schema with new types and mutations for remote access and connection settings - Introduced new types including `AccessUrl`, `Cloud`, `RemoteAccess`, and `Connect` to enhance remote access capabilities. - Added enums for `URL_TYPE`, `WAN_ACCESS_TYPE`, and `WAN_FORWARD_TYPE` to standardize access configurations. - Implemented new input types for connection settings and remote access setup, improving API usability. - Updated the `Query` and `Mutation` types to include new fields for managing remote access and connection settings. - Bumped API version to 4.13.1 in configuration. --- api/dev/configs/api.json | 2 +- api/generated-schema.graphql | 223 +++++++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 1 deletion(-) 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 3b62cf825..7befb08e5 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -1782,6 +1782,160 @@ type Plugin { hasCliModule: Boolean } +type AccessUrl { + type: URL_TYPE! + name: String + ipv4: URL + ipv6: URL +} + +enum URL_TYPE { + LAN + WIREGUARD + WAN + MDNS + OTHER + DEFAULT +} + +""" +A field whose value conforms to the standard URL format as specified in RFC3986: https://www.ietf.org/rfc/rfc3986.txt. +""" +scalar URL + +type AccessUrlObject { + ipv4: String + ipv6: String + type: URL_TYPE! + name: String +} + +type ApiKeyResponse { + valid: Boolean! + error: String +} + +type MinigraphqlResponse { + status: MinigraphStatus! + timeout: Int + error: String +} + +"""The status of the minigraph""" +enum MinigraphStatus { + PRE_INIT + CONNECTING + CONNECTED + PING_FAILURE + ERROR_RETRYING +} + +type CloudResponse { + status: String! + ip: String + error: String +} + +type RelayResponse { + status: String! + timeout: String + error: String +} + +type Cloud { + error: String + apiKey: ApiKeyResponse! + relay: RelayResponse + minigraphql: MinigraphqlResponse! + cloud: CloudResponse! + allowedOrigins: [String!]! +} + +type RemoteAccess { + """The type of WAN access used for Remote Access""" + accessType: WAN_ACCESS_TYPE! + + """The type of port forwarding used for Remote Access""" + forwardType: WAN_FORWARD_TYPE + + """The port used for Remote Access""" + port: Int +} + +enum WAN_ACCESS_TYPE { + DYNAMIC + ALWAYS + DISABLED +} + +enum WAN_FORWARD_TYPE { + UPNP + STATIC +} + +type DynamicRemoteAccessStatus { + """The type of dynamic remote access that is enabled""" + enabledType: DynamicRemoteAccessType! + + """The type of dynamic remote access that is currently running""" + runningType: DynamicRemoteAccessType! + + """Any error message associated with the dynamic remote access""" + error: String +} + +enum DynamicRemoteAccessType { + STATIC + UPNP + DISABLED +} + +type ConnectSettingsValues { + """The type of WAN access used for Remote Access""" + accessType: WAN_ACCESS_TYPE! + + """The type of port forwarding used for Remote Access""" + forwardType: WAN_FORWARD_TYPE + + """The port used for Remote Access""" + port: Int +} + +type ConnectSettings implements Node { + id: PrefixedID! + + """The data schema for the Connect settings""" + dataSchema: JSON! + + """The UI schema for the Connect settings""" + uiSchema: JSON! + + """The values for the Connect settings""" + values: ConnectSettingsValues! +} + +type Connect implements Node { + id: PrefixedID! + + """The status of dynamic remote access""" + dynamicRemoteAccess: DynamicRemoteAccessStatus! + + """The settings for the Connect instance""" + settings: ConnectSettings! +} + +type Network implements Node { + id: PrefixedID! + accessUrls: [AccessUrl!] +} + +input AccessUrlObjectInput { + ipv4: String + ipv6: String + type: URL_TYPE! + name: String +} + "\n### Description:\n\nID scalar type that prefixes the underlying ID with the server identifier on output and strips it on input.\n\nWe use this scalar type to ensure that the ID is unique across all servers, allowing the same underlying resource ID to be used across different server instances.\n\n#### Input Behavior:\n\nWhen providing an ID as input (e.g., in arguments or input objects), the server identifier prefix (':') is optional.\n\n- If the prefix is present (e.g., '123:456'), it will be automatically stripped, and only the underlying ID ('456') will be used internally.\n- If the prefix is absent (e.g., '456'), the ID will be used as-is.\n\nThis makes it flexible for clients, as they don't strictly need to know or provide the server ID.\n\n#### Output Behavior:\n\nWhen an ID is returned in the response (output), it will *always* be prefixed with the current server's unique identifier (e.g., '123:456').\n\n#### Example:\n\nNote: The server identifier is '123' in this example.\n\n##### Input (Prefix Optional):\n```graphql\n# Both of these are valid inputs resolving to internal ID '456'\n{\n someQuery(id: \"123:456\") { ... }\n anotherQuery(id: \"456\") { ... }\n}\n```\n\n##### Output (Prefix Always Added):\n```graphql\n# Assuming internal ID is '456'\n{\n \"data\": {\n \"someResource\": {\n \"id\": \"123:456\" \n }\n }\n}\n```\n " scalar PrefixedID @@ -1846,6 +2000,10 @@ type Query { """List all installed plugins with their metadata""" plugins: [Plugin!]! + remoteAccess: RemoteAccess! + connect: Connect! + network: Network! + cloud: Cloud! } type Mutation { @@ -1893,6 +2051,11 @@ type Mutation { Remove one or more plugins from the API. Returns false if restart was triggered automatically, true if manual restart is required. """ removePlugin(input: PluginManagementInput!): Boolean! + updateApiSettings(input: ConnectSettingsInput!): ConnectSettingsValues! + connectSignIn(input: ConnectSignInInput!): Boolean! + connectSignOut: Boolean! + setupRemoteAccess(input: SetupRemoteAccessInput!): Boolean! + enableDynamicRemoteAccess(input: EnableDynamicRemoteAccessInput!): Boolean! } input NotificationData { @@ -2010,6 +2173,66 @@ input PluginManagementInput { restart: Boolean! = true } +input ConnectSettingsInput { + """The type of WAN access to use for Remote Access""" + accessType: WAN_ACCESS_TYPE + + """The type of port forwarding to use for Remote Access""" + forwardType: WAN_FORWARD_TYPE + + """ + The port to use for Remote Access. Not required for UPNP forwardType. Required for STATIC forwardType. Ignored if accessType is DISABLED or forwardType is UPNP. + """ + port: Int +} + +input ConnectSignInInput { + """The API key for authentication""" + apiKey: String! + + """User information for the sign-in""" + userInfo: ConnectUserInfoInput +} + +input ConnectUserInfoInput { + """The preferred username of the user""" + preferred_username: String! + + """The email address of the user""" + email: String! + + """The avatar URL of the user""" + avatar: String +} + +input SetupRemoteAccessInput { + """The type of WAN access to use for Remote Access""" + accessType: WAN_ACCESS_TYPE! + + """The type of port forwarding to use for Remote Access""" + forwardType: WAN_FORWARD_TYPE + + """ + The port to use for Remote Access. Not required for UPNP forwardType. Required for STATIC forwardType. Ignored if accessType is DISABLED or forwardType is UPNP. + """ + port: Int +} + +input EnableDynamicRemoteAccessInput { + """The AccessURL Input for dynamic remote access""" + url: AccessUrlInput! + + """Whether to enable or disable dynamic remote access""" + enabled: Boolean! +} + +input AccessUrlInput { + type: URL_TYPE! + name: String + ipv4: URL + ipv6: URL +} + type Subscription { displaySubscription: Display! infoSubscription: Info! From 0fadb6fbd9335a9cd1ac28be797f4abdd6e168e2 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 19 Aug 2025 12:26:36 -0400 Subject: [PATCH 08/18] feat: extract CPU and memory metrics from info resolver and move to metrics resolver (#1594) - also enables subscription resolution for CPU and memory usage --- api/generated-schema.graphql | 838 ++++++++++++------ api/src/unraid-api/cli/generated/graphql.ts | 582 ++++++++---- .../display/display.resolver.spec.ts | 2 +- .../resolvers/display/display.resolver.ts | 4 +- .../graph/resolvers/info/cpu-data.service.ts | 13 - .../graph/resolvers/info/cpu/cpu.model.ts | 93 ++ .../graph/resolvers/info/cpu/cpu.service.ts | 46 + .../graph/resolvers/info/devices.resolver.ts | 24 - .../resolvers/info/devices/devices.model.ts | 102 +++ .../{ => devices}/devices.resolver.spec.ts | 4 +- .../info/devices/devices.resolver.ts | 35 + .../{ => devices}/devices.service.spec.ts | 2 +- .../info/{ => devices}/devices.service.ts | 45 +- .../resolvers/info/display/display.model.ts | 82 ++ .../display/display.service.spec.ts | 13 +- .../{ => info}/display/display.service.ts | 33 +- .../graph/resolvers/info/info.model.ts | 619 +------------ .../graph/resolvers/info/info.module.ts | 34 + .../info/info.resolver.integration.spec.ts | 185 ++++ .../resolvers/info/info.resolver.spec.ts | 435 +++------ .../graph/resolvers/info/info.resolver.ts | 160 +--- .../graph/resolvers/info/info.service.spec.ts | 346 -------- .../graph/resolvers/info/info.service.ts | 105 --- .../resolvers/info/memory/memory.model.ts | 82 ++ .../resolvers/info/memory/memory.service.ts | 51 ++ .../graph/resolvers/info/os/os.model.ts | 48 + .../graph/resolvers/info/os/os.service.ts | 21 + .../resolvers/info/system/system.model.ts | 51 ++ .../resolvers/info/versions/versions.model.ts | 96 ++ .../info/versions/versions.service.ts | 22 + .../graph/resolvers/metrics/metrics.model.ts | 21 + .../graph/resolvers/metrics/metrics.module.ts | 13 + .../metrics.resolver.integration.spec.ts | 201 +++++ .../metrics/metrics.resolver.spec.ts | 186 ++++ .../resolvers/metrics/metrics.resolver.ts | 135 +++ .../graph/resolvers/resolvers.module.ts | 19 +- .../src/pubsub/graphql.pubsub.ts | 1 + web/composables/gql/graphql.ts | 584 ++++++++---- 38 files changed, 3166 insertions(+), 2167 deletions(-) delete mode 100644 api/src/unraid-api/graph/resolvers/info/cpu-data.service.ts create mode 100644 api/src/unraid-api/graph/resolvers/info/cpu/cpu.model.ts create mode 100644 api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.ts delete mode 100644 api/src/unraid-api/graph/resolvers/info/devices.resolver.ts create mode 100644 api/src/unraid-api/graph/resolvers/info/devices/devices.model.ts rename api/src/unraid-api/graph/resolvers/info/{ => devices}/devices.resolver.spec.ts (97%) create mode 100644 api/src/unraid-api/graph/resolvers/info/devices/devices.resolver.ts rename api/src/unraid-api/graph/resolvers/info/{ => devices}/devices.service.spec.ts (99%) rename api/src/unraid-api/graph/resolvers/info/{ => devices}/devices.service.ts (88%) create mode 100644 api/src/unraid-api/graph/resolvers/info/display/display.model.ts rename api/src/unraid-api/graph/resolvers/{ => info}/display/display.service.spec.ts (94%) rename api/src/unraid-api/graph/resolvers/{ => info}/display/display.service.ts (79%) create mode 100644 api/src/unraid-api/graph/resolvers/info/info.module.ts create mode 100644 api/src/unraid-api/graph/resolvers/info/info.resolver.integration.spec.ts delete mode 100644 api/src/unraid-api/graph/resolvers/info/info.service.spec.ts delete mode 100644 api/src/unraid-api/graph/resolvers/info/info.service.ts create mode 100644 api/src/unraid-api/graph/resolvers/info/memory/memory.model.ts create mode 100644 api/src/unraid-api/graph/resolvers/info/memory/memory.service.ts create mode 100644 api/src/unraid-api/graph/resolvers/info/os/os.model.ts create mode 100644 api/src/unraid-api/graph/resolvers/info/os/os.service.ts create mode 100644 api/src/unraid-api/graph/resolvers/info/system/system.model.ts create mode 100644 api/src/unraid-api/graph/resolvers/info/versions/versions.model.ts create mode 100644 api/src/unraid-api/graph/resolvers/info/versions/versions.service.ts create mode 100644 api/src/unraid-api/graph/resolvers/metrics/metrics.model.ts create mode 100644 api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts create mode 100644 api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts create mode 100644 api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.spec.ts create mode 100644 api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 7befb08e5..23fd9267f 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -940,255 +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!]! - - """CPU utilization in percent""" - utilization: Float -} - -"""CPU load for a single core""" -type CpuLoad { - """The total CPU load on a single core, in percent.""" - load: Float! - - """The percentage of time the CPU spent in user space.""" - loadUser: Float! - - """The percentage of time the CPU spent in kernel space.""" - loadSystem: Float! - - """ - The percentage of time the CPU spent on low-priority (niced) user space processes. - """ - loadNice: Float! - - """The percentage of time the CPU was idle.""" - loadIdle: Float! - - """The percentage of time the CPU spent servicing hardware interrupts.""" - loadIrq: Float! -} - -type CpuUtilization implements Node { - id: PrefixedID! - load: Float! - cpus: [CpuLoad!]! -} - -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 @@ -1310,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.""" + load: Float! + + """The percentage of time the CPU spent in user space.""" + loadUser: Float! + + """The percentage of time the CPU spent in kernel space.""" + loadSystem: Float! + + """ + The percentage of time the CPU spent on low-priority (niced) user space processes. + """ + loadNice: Float! + + """The percentage of time the CPU was idle.""" + loadIdle: Float! + + """The percentage of time the CPU spent servicing hardware interrupts.""" + loadIrq: Float! +} + +type CpuUtilization implements Node { + id: PrefixedID! + + """Total CPU load in percent""" + load: 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""" + usedPercent: 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""" + swapUsedPercent: 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! @@ -1338,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! @@ -1949,10 +2279,7 @@ type Query { """All possible permissions for API keys""" apiKeyPossiblePermissions: [Permission!]! config: Config! - display: Display! flash: Flash! - info: Info! - cpuUtilization: CpuUtilization! logFiles: [LogFile!]! logFile(path: String!, lines: Int, startLine: Int): LogFileContent! me: UserAccount! @@ -1980,6 +2307,7 @@ type Query { disks: [Disk!]! disk(id: PrefixedID!): Disk! rclone: RCloneBackupSettings! + info: Info! settings: Settings! isSSOEnabled: Boolean! @@ -1994,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! @@ -2234,9 +2563,6 @@ input AccessUrlInput { } type Subscription { - displaySubscription: Display! - infoSubscription: Info! - cpuUtilization: CpuUtilization! logFile(path: String!): LogFileContent! notificationAdded: Notification! notificationsOverview: NotificationOverview! @@ -2244,6 +2570,8 @@ type Subscription { serversSubscription: Server! parityHistorySubscription: ParityCheck! arraySubscription: UnraidArray! + systemMetricsCpu: CpuUtilization! + systemMetricsMemory: MemoryUtilization! upsUpdates: UPSDevice! } diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index 10f256da5..2fc6d59e2 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 total CPU load on a single core, in percent. */ + load: Scalars['Float']['output']; + /** The percentage of time the CPU was idle. */ + loadIdle: Scalars['Float']['output']; + /** The percentage of time the CPU spent servicing hardware interrupts. */ + loadIrq: Scalars['Float']['output']; + /** The percentage of time the CPU spent on low-priority (niced) user space processes. */ + loadNice: Scalars['Float']['output']; + /** The percentage of time the CPU spent in kernel space. */ + loadSystem: Scalars['Float']['output']; + /** The percentage of time the CPU spent in user space. */ + loadUser: 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 */ + load: 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']; + /** 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']; + /** Swap usage percentage */ + swapUsedPercent: Scalars['Float']['output']; + /** Total system memory in bytes */ + total: Scalars['BigInt']['output']; + /** Used memory in bytes */ + used: Scalars['BigInt']['output']; + /** Memory usage percentage */ + usedPercent: Scalars['Float']['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/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-data.service.ts b/api/src/unraid-api/graph/resolvers/info/cpu-data.service.ts deleted file mode 100644 index 3fa3c8c7e..000000000 --- a/api/src/unraid-api/graph/resolvers/info/cpu-data.service.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Injectable, Scope } from '@nestjs/common'; - -import { currentLoad, Systeminformation } from 'systeminformation'; - -@Injectable({ scope: Scope.REQUEST }) -export class CpuDataService { - private cpuLoadData: Promise | undefined; - - public getCpuLoad(): Promise { - this.cpuLoadData ??= currentLoad(); - return this.cpuLoadData; - } -} 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..60cd957e5 --- /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.' }) + load!: number; + + @Field(() => Float, { description: 'The percentage of time the CPU spent in user space.' }) + loadUser!: number; + + @Field(() => Float, { description: 'The percentage of time the CPU spent in kernel space.' }) + loadSystem!: number; + + @Field(() => Float, { + description: + 'The percentage of time the CPU spent on low-priority (niced) user space processes.', + }) + loadNice!: number; + + @Field(() => Float, { description: 'The percentage of time the CPU was idle.' }) + loadIdle!: number; + + @Field(() => Float, { + description: 'The percentage of time the CPU spent servicing hardware interrupts.', + }) + loadIrq!: number; +} + +@ObjectType({ implements: () => Node }) +export class CpuUtilization extends Node { + @Field(() => Float, { description: 'Total CPU load in percent' }) + load!: 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..c0e1fc579 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.ts @@ -0,0 +1,46 @@ +import { Injectable, Scope } from '@nestjs/common'; + +import { cpu, cpuFlags, currentLoad, Systeminformation } from 'systeminformation'; + +import { CpuUtilization, InfoCpu } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js'; + +@Injectable({ scope: Scope.REQUEST }) +export class CpuDataService { + private cpuLoadData: Promise | undefined; + + public getCpuLoad(): Promise { + this.cpuLoadData ??= currentLoad(); + return this.cpuLoadData; + } +} + +@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 { currentLoad: load, cpus } = await currentLoad(); + + return { + id: 'info/cpu-load', + load, + cpus, + }; + } +} 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 79% 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..6945377bc 100644 --- a/api/src/unraid-api/graph/resolvers/display/display.service.ts +++ b/api/src/unraid-api/graph/resolvers/info/display/display.service.ts @@ -8,17 +8,19 @@ import { fileExists } from '@app/core/utils/files/file-exists.js'; import { loadState } from '@app/core/utils/misc/load-state.js'; import { getters } from '@app/store/index.js'; import { ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js'; -import { Display, Temperature } from '@app/unraid-api/graph/resolvers/info/info.model.js'; +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 +29,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 +74,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 +124,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) => { 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 68ff41d7a..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,593 +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[]; - - @Field(() => Float, { - description: 'CPU utilization in percent', - nullable: true, - }) - utilization?: number; -} - -@ObjectType({ description: 'CPU load for a single core' }) -export class CpuLoad { - @Field(() => Float, { description: 'The total CPU load on a single core, in percent.' }) - load!: number; - - @Field(() => Float, { description: 'The percentage of time the CPU spent in user space.' }) - loadUser!: number; - - @Field(() => Float, { description: 'The percentage of time the CPU spent in kernel space.' }) - loadSystem!: number; - - @Field(() => Float, { - description: - 'The percentage of time the CPU spent on low-priority (niced) user space processes.', - }) - loadNice!: number; - - @Field(() => Float, { description: 'The percentage of time the CPU was idle.' }) - loadIdle!: number; - - @Field(() => Float, { - description: 'The percentage of time the CPU spent servicing hardware interrupts.', - }) - loadIrq!: number; -} - -@ObjectType({ implements: () => Node }) -export class CpuUtilization extends Node { - @Field(() => Float) - load!: number; - - @Field(() => [CpuLoad]) - cpus!: CpuLoad[]; -} - -@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..3e74e3520 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/info.module.ts @@ -0,0 +1,34 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; + +import { CpuDataService, 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, + CpuDataService, + 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..18800fcca --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/info.resolver.integration.spec.ts @@ -0,0 +1,185 @@ +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 { CpuDataService, 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, + CpuDataService, + MemoryService, + DevicesService, + OsService, + VersionsService, + DisplayService, + SubscriptionTrackerService, + SubscriptionHelperService, + { + 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 () => { + 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 73ef4d6e8..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,248 +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 { CpuDataService } from '@app/unraid-api/graph/resolvers/info/cpu-data.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 { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.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), - }; - - const mockSubscriptionTrackerService = { - registerTopic: vi.fn(), - subscribe: vi.fn(), - unsubscribe: vi.fn(), - }; - - const mockCpuDataService = { - getCpuLoad: vi.fn().mockResolvedValue({ - currentLoad: 10, - cpus: [], - }), - }; + 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: SubscriptionTrackerService, - useValue: mockSubscriptionTrackerService, - }, - { - provide: CpuDataService, - useValue: mockCpuDataService, + 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', }); @@ -251,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 cd94265d2..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,5 +1,4 @@ -import { OnModuleInit } from '@nestjs/common'; -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 { @@ -9,53 +8,31 @@ import { } from '@unraid/shared/use-permissions.directive.js'; import { baseboard as getBaseboard, system as getSystem } from 'systeminformation'; -import { createSubscription, pubsub, 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 { CpuDataService } from '@app/unraid-api/graph/resolvers/info/cpu-data.service.js'; -import { - Baseboard, - CpuUtilization, - 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 { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js'; -import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.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 implements OnModuleInit { - private cpuPollingTimer: NodeJS.Timeout | undefined; - +export class InfoResolver { constructor( - private readonly infoService: InfoService, + private readonly cpuService: CpuService, + private readonly memoryService: MemoryService, private readonly displayService: DisplayService, - private readonly subscriptionTracker: SubscriptionTrackerService, - private readonly subscriptionHelper: SubscriptionHelperService + private readonly osService: OsService, + private readonly versionsService: VersionsService ) {} - onModuleInit() { - this.subscriptionTracker.registerTopic( - PUBSUB_CHANNEL.CPU_UTILIZATION, - () => { - this.cpuPollingTimer = setInterval(async () => { - const payload = await this.infoService.generateCpuLoad(); - pubsub.publish(PUBSUB_CHANNEL.CPU_UTILIZATION, { cpuUtilization: payload }); - }, 1000); - }, - () => { - clearInterval(this.cpuPollingTimer); - } - ); - } - @Query(() => Info) @UsePermissions({ action: AuthActionVerb.READ, @@ -68,37 +45,30 @@ export class InfoResolver implements OnModuleInit { }; } - @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(); } @@ -109,72 +79,22 @@ export class InfoResolver implements OnModuleInit { @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); - } - - @Query(() => CpuUtilization) - @UsePermissions({ - action: AuthActionVerb.READ, - resource: Resource.INFO, - possession: AuthPossession.ANY, - }) - public async cpuUtilization(): Promise { - return this.infoService.generateCpuLoad(); - } - - @Subscription(() => CpuUtilization, { - name: 'cpuUtilization', - resolve: (value) => value.cpuUtilization, - }) - @UsePermissions({ - action: AuthActionVerb.READ, - resource: Resource.INFO, - possession: AuthPossession.ANY, - }) - public async cpuUtilizationSubscription() { - return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION); - } -} - -@Resolver(() => InfoCpu) -export class InfoCpuResolver { - constructor(private readonly cpuDataService: CpuDataService) {} - - @ResolveField(() => Number, { - description: 'CPU utilization in percent', - nullable: true, - }) - public async utilization(): Promise { - const { currentLoad } = await this.cpuDataService.getCpuLoad(); - return currentLoad; + @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 8f5c155e6..000000000 --- a/api/src/unraid-api/graph/resolvers/info/info.service.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { cpu, cpuFlags, currentLoad, 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 { - CpuUtilization, - 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; - } - - async generateCpuLoad(): Promise { - const { currentLoad: load, cpus } = await currentLoad(); - - return { - id: 'info/cpu-load', - load, - cpus, - }; - } -} 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..8d29c58d3 --- /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' }) + usedPercent!: 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' }) + swapUsedPercent!: 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..552e4d442 --- /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), + usedPercent: + 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), + swapUsedPercent: 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..8d1481113 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { CpuDataService, 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, CpuDataService, 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..4354009da --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts @@ -0,0 +1,201 @@ +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { CpuDataService, 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 Integration Tests', () => { + let metricsResolver: MetricsResolver; + let module: TestingModule; + + beforeEach(async () => { + module = await Test.createTestingModule({ + providers: [ + MetricsResolver, + CpuService, + CpuDataService, + MemoryService, + SubscriptionTrackerService, + SubscriptionHelperService, + ], + }).compile(); + + metricsResolver = module.get(MetricsResolver); + }); + + afterEach(async () => { + // Clean up any active timers + if (metricsResolver['cpuPollingTimer']) { + clearInterval(metricsResolver['cpuPollingTimer']); + } + if (metricsResolver['memoryPollingTimer']) { + clearInterval(metricsResolver['memoryPollingTimer']); + } + 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('load'); + expect(result).toHaveProperty('cpus'); + expect(result.cpus).toBeInstanceOf(Array); + expect(result.load).toBeGreaterThanOrEqual(0); + expect(result.load).toBeLessThanOrEqual(100); + + if (result.cpus.length > 0) { + const firstCpu = result.cpus[0]; + expect(firstCpu).toHaveProperty('load'); + expect(firstCpu).toHaveProperty('loadUser'); + expect(firstCpu).toHaveProperty('loadSystem'); + expect(firstCpu).toHaveProperty('loadIdle'); + } + }); + + 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('usedPercent'); + expect(result).toHaveProperty('swapTotal'); + expect(result).toHaveProperty('swapUsed'); + expect(result).toHaveProperty('swapFree'); + expect(result).toHaveProperty('swapUsedPercent'); + + expect(result.total).toBeGreaterThan(0); + expect(result.usedPercent).toBeGreaterThanOrEqual(0); + expect(result.usedPercent).toBeLessThanOrEqual(100); + }); + }); + + describe('Polling Mechanism', () => { + it('should prevent concurrent CPU polling executions', async () => { + // Start multiple polling attempts simultaneously + const promises = Array(5) + .fill(null) + .map(() => metricsResolver['pollCpuUtilization']()); + + await Promise.all(promises); + + // Only one execution should have occurred + expect(metricsResolver['isCpuPollingInProgress']).toBe(false); + }); + + it('should prevent concurrent memory polling executions', async () => { + // Start multiple polling attempts simultaneously + const promises = Array(5) + .fill(null) + .map(() => metricsResolver['pollMemoryUtilization']()); + + await Promise.all(promises); + + // Only one execution should have occurred + expect(metricsResolver['isMemoryPollingInProgress']).toBe(false); + }); + + it('should publish CPU metrics to pubsub', async () => { + const publishSpy = vi.spyOn(pubsub, 'publish'); + + await metricsResolver['pollCpuUtilization'](); + + expect(publishSpy).toHaveBeenCalledWith( + PUBSUB_CHANNEL.CPU_UTILIZATION, + expect.objectContaining({ + systemMetricsCpu: expect.objectContaining({ + id: 'info/cpu-load', + load: expect.any(Number), + cpus: expect.any(Array), + }), + }) + ); + + publishSpy.mockRestore(); + }); + + it('should publish memory metrics to pubsub', async () => { + const publishSpy = vi.spyOn(pubsub, 'publish'); + + await metricsResolver['pollMemoryUtilization'](); + + expect(publishSpy).toHaveBeenCalledWith( + PUBSUB_CHANNEL.MEMORY_UTILIZATION, + expect.objectContaining({ + systemMetricsMemory: expect.objectContaining({ + id: 'memory-utilization', + used: expect.any(Number), + free: expect.any(Number), + usedPercent: expect.any(Number), + }), + }) + ); + + publishSpy.mockRestore(); + }); + + it('should handle errors in CPU polling gracefully', async () => { + const service = module.get(CpuService); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(service, 'generateCpuLoad').mockRejectedValueOnce(new Error('CPU error')); + + await metricsResolver['pollCpuUtilization'](); + + expect(errorSpy).toHaveBeenCalledWith('Error polling CPU utilization:', expect.any(Error)); + expect(metricsResolver['isCpuPollingInProgress']).toBe(false); + + errorSpy.mockRestore(); + }); + + it('should handle errors in memory polling gracefully', async () => { + const service = module.get(MemoryService); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(service, 'generateMemoryLoad').mockRejectedValueOnce(new Error('Memory error')); + + await metricsResolver['pollMemoryUtilization'](); + + expect(errorSpy).toHaveBeenCalledWith( + 'Error polling memory utilization:', + expect.any(Error) + ); + expect(metricsResolver['isMemoryPollingInProgress']).toBe(false); + + errorSpy.mockRestore(); + }); + }); + + describe('Polling cleanup on module destroy', () => { + it('should clean up timers when module is destroyed', async () => { + // Force-start polling + await metricsResolver['pollCpuUtilization'](); + expect(metricsResolver['isCpuPollingInProgress']).toBe(false); + + await metricsResolver['pollMemoryUtilization'](); + expect(metricsResolver['isMemoryPollingInProgress']).toBe(false); + + // Clean up the module + await module.close(); + + // Timers should be cleaned up + expect(metricsResolver['cpuPollingTimer']).toBeUndefined(); + expect(metricsResolver['memoryPollingTimer']).toBeUndefined(); + }); + }); +}); 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..2df51a1c1 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.spec.ts @@ -0,0 +1,186 @@ +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CpuDataService, 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: CpuDataService, + useValue: {}, + }, + { + 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), + expect.any(Function) + ); + expect(subscriptionTracker.registerTopic).toHaveBeenCalledWith( + 'MEMORY_UTILIZATION', + expect.any(Function), + expect.any(Function) + ); + }); + }); +}); 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..7adc90e7b --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts @@ -0,0 +1,135 @@ +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 { + private cpuPollingTimer: NodeJS.Timeout | undefined; + private memoryPollingTimer: NodeJS.Timeout | undefined; + private isCpuPollingInProgress = false; + private isMemoryPollingInProgress = false; + + constructor( + private readonly cpuService: CpuService, + private readonly memoryService: MemoryService, + private readonly subscriptionTracker: SubscriptionTrackerService, + private readonly subscriptionHelper: SubscriptionHelperService + ) {} + + onModuleInit() { + this.subscriptionTracker.registerTopic( + PUBSUB_CHANNEL.CPU_UTILIZATION, + () => { + this.pollCpuUtilization(); + this.cpuPollingTimer = setInterval(() => this.pollCpuUtilization(), 1000); + }, + () => { + clearInterval(this.cpuPollingTimer); + this.isCpuPollingInProgress = false; + } + ); + + this.subscriptionTracker.registerTopic( + PUBSUB_CHANNEL.MEMORY_UTILIZATION, + () => { + this.pollMemoryUtilization(); + this.memoryPollingTimer = setInterval(() => this.pollMemoryUtilization(), 2000); + }, + () => { + clearInterval(this.memoryPollingTimer); + this.isMemoryPollingInProgress = false; + } + ); + } + + private async pollCpuUtilization(): Promise { + if (this.isCpuPollingInProgress) return; + + this.isCpuPollingInProgress = true; + try { + const payload = await this.cpuService.generateCpuLoad(); + pubsub.publish(PUBSUB_CHANNEL.CPU_UTILIZATION, { systemMetricsCpu: payload }); + } catch (error) { + console.error('Error polling CPU utilization:', error); + } finally { + this.isCpuPollingInProgress = false; + } + } + + private async pollMemoryUtilization(): Promise { + if (this.isMemoryPollingInProgress) return; + + this.isMemoryPollingInProgress = true; + try { + const payload = await this.memoryService.generateMemoryLoad(); + pubsub.publish(PUBSUB_CHANNEL.MEMORY_UTILIZATION, { systemMetricsMemory: payload }); + } catch (error) { + console.error('Error polling memory utilization:', error); + } finally { + this.isMemoryPollingInProgress = false; + } + } + + @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 394601779..8d739b0af 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -7,18 +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 { CpuDataService } from '@app/unraid-api/graph/resolvers/info/cpu-data.service.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 { InfoCpuResolver, 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'; @@ -49,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, @@ -79,8 +70,6 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js'; VmMutationsResolver, VmsResolver, VmsService, - InfoCpuResolver, - CpuDataService, ], exports: [ApiKeyModule], }) diff --git a/packages/unraid-shared/src/pubsub/graphql.pubsub.ts b/packages/unraid-shared/src/pubsub/graphql.pubsub.ts index 9c16a778f..602e57e89 100644 --- a/packages/unraid-shared/src/pubsub/graphql.pubsub.ts +++ b/packages/unraid-shared/src/pubsub/graphql.pubsub.ts @@ -8,6 +8,7 @@ export enum GRAPHQL_PUBSUB_CHANNEL { 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..c7ba55302 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 total CPU load on a single core, in percent. */ + load: Scalars['Float']['output']; + /** The percentage of time the CPU was idle. */ + loadIdle: Scalars['Float']['output']; + /** The percentage of time the CPU spent servicing hardware interrupts. */ + loadIrq: Scalars['Float']['output']; + /** The percentage of time the CPU spent on low-priority (niced) user space processes. */ + loadNice: Scalars['Float']['output']; + /** The percentage of time the CPU spent in kernel space. */ + loadSystem: Scalars['Float']['output']; + /** The percentage of time the CPU spent in user space. */ + loadUser: 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 */ + load: 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']; + /** 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']; + /** Swap usage percentage */ + swapUsedPercent: Scalars['Float']['output']; + /** Total system memory in bytes */ + total: Scalars['BigInt']['output']; + /** Used memory in bytes */ + used: Scalars['BigInt']['output']; + /** Memory usage percentage */ + usedPercent: Scalars['Float']['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; }>; From 99da8bf3099a5dd568775d4e772b0df2c1b16093 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 19 Aug 2025 12:32:02 -0400 Subject: [PATCH 09/18] fix(api): enhance type safety in pubsub subscription methods - Updated `createSubscription` and `createTrackedSubscription` methods to include generic type parameters, improving type safety and ensuring correct async iterable handling. --- api/src/core/pubsub.ts | 6 ++++-- .../graph/services/subscription-helper.service.ts | 3 +-- 2 files changed, 5 insertions(+), 4 deletions(-) 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/unraid-api/graph/services/subscription-helper.service.ts b/api/src/unraid-api/graph/services/subscription-helper.service.ts index 4fd12fb2b..2df982d12 100644 --- a/api/src/unraid-api/graph/services/subscription-helper.service.ts +++ b/api/src/unraid-api/graph/services/subscription-helper.service.ts @@ -16,8 +16,7 @@ export class SubscriptionHelperService { * @returns A proxy async iterator with automatic cleanup */ public createTrackedSubscription(topic: PUBSUB_CHANNEL): AsyncIterableIterator { - const iterator = createSubscription(topic) as AsyncIterable; - const innerIterator = iterator[Symbol.asyncIterator](); + const innerIterator = createSubscription(topic); // Subscribe when the subscription starts this.subscriptionTracker.subscribe(topic); From ca691b71aad9b3fd738c1f2a435bedd3fe24bd13 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 19 Aug 2025 12:50:17 -0400 Subject: [PATCH 10/18] refactor(api): enhance metrics polling mechanism and simplify subscription handling - Removed legacy polling methods from `MetricsResolver` and integrated polling logic into `SubscriptionPollingService` for better separation of concerns. - Updated `SubscriptionTrackerService` to support polling configuration directly, allowing for cleaner topic registration. - Adjusted unit tests to accommodate changes in the subscription handling and polling logic. - Introduced `SubscriptionPollingService` to manage polling intervals and ensure efficient execution of polling tasks. --- .../metrics/metrics.resolver.spec.ts | 4 +- .../resolvers/metrics/metrics.resolver.ts | 57 ++---------- .../graph/services/services.module.ts | 7 +- .../subscription-helper.service.spec.ts | 6 +- .../services/subscription-polling.service.ts | 91 +++++++++++++++++++ .../subscription-tracker.service.spec.ts | 6 +- .../services/subscription-tracker.service.ts | 35 ++++++- 7 files changed, 151 insertions(+), 55 deletions(-) create mode 100644 api/src/unraid-api/graph/services/subscription-polling.service.ts 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 index 2df51a1c1..4cdde37bf 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.spec.ts @@ -174,12 +174,12 @@ describe('MetricsResolver', () => { expect(subscriptionTracker.registerTopic).toHaveBeenCalledWith( 'CPU_UTILIZATION', expect.any(Function), - expect.any(Function) + 1000 ); expect(subscriptionTracker.registerTopic).toHaveBeenCalledWith( 'MEMORY_UTILIZATION', expect.any(Function), - 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 index 7adc90e7b..d8d11050d 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts @@ -19,11 +19,6 @@ import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subsc @Resolver(() => Metrics) export class MetricsResolver implements OnModuleInit { - private cpuPollingTimer: NodeJS.Timeout | undefined; - private memoryPollingTimer: NodeJS.Timeout | undefined; - private isCpuPollingInProgress = false; - private isMemoryPollingInProgress = false; - constructor( private readonly cpuService: CpuService, private readonly memoryService: MemoryService, @@ -32,59 +27,27 @@ export class MetricsResolver implements OnModuleInit { ) {} onModuleInit() { + // Register CPU polling with 1 second interval this.subscriptionTracker.registerTopic( PUBSUB_CHANNEL.CPU_UTILIZATION, - () => { - this.pollCpuUtilization(); - this.cpuPollingTimer = setInterval(() => this.pollCpuUtilization(), 1000); + async () => { + const payload = await this.cpuService.generateCpuLoad(); + pubsub.publish(PUBSUB_CHANNEL.CPU_UTILIZATION, { systemMetricsCpu: payload }); }, - () => { - clearInterval(this.cpuPollingTimer); - this.isCpuPollingInProgress = false; - } + 1000 ); + // Register memory polling with 2 second interval this.subscriptionTracker.registerTopic( PUBSUB_CHANNEL.MEMORY_UTILIZATION, - () => { - this.pollMemoryUtilization(); - this.memoryPollingTimer = setInterval(() => this.pollMemoryUtilization(), 2000); + async () => { + const payload = await this.memoryService.generateMemoryLoad(); + pubsub.publish(PUBSUB_CHANNEL.MEMORY_UTILIZATION, { systemMetricsMemory: payload }); }, - () => { - clearInterval(this.memoryPollingTimer); - this.isMemoryPollingInProgress = false; - } + 2000 ); } - private async pollCpuUtilization(): Promise { - if (this.isCpuPollingInProgress) return; - - this.isCpuPollingInProgress = true; - try { - const payload = await this.cpuService.generateCpuLoad(); - pubsub.publish(PUBSUB_CHANNEL.CPU_UTILIZATION, { systemMetricsCpu: payload }); - } catch (error) { - console.error('Error polling CPU utilization:', error); - } finally { - this.isCpuPollingInProgress = false; - } - } - - private async pollMemoryUtilization(): Promise { - if (this.isMemoryPollingInProgress) return; - - this.isMemoryPollingInProgress = true; - try { - const payload = await this.memoryService.generateMemoryLoad(); - pubsub.publish(PUBSUB_CHANNEL.MEMORY_UTILIZATION, { systemMetricsMemory: payload }); - } catch (error) { - console.error('Error polling memory utilization:', error); - } finally { - this.isMemoryPollingInProgress = false; - } - } - @Query(() => Metrics) @UsePermissions({ action: AuthActionVerb.READ, diff --git a/api/src/unraid-api/graph/services/services.module.ts b/api/src/unraid-api/graph/services/services.module.ts index ffc669076..7adb97e43 100644 --- a/api/src/unraid-api/graph/services/services.module.ts +++ b/api/src/unraid-api/graph/services/services.module.ts @@ -1,10 +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({ - providers: [SubscriptionTrackerService, SubscriptionHelperService], - exports: [SubscriptionTrackerService, SubscriptionHelperService], + imports: [ScheduleModule.forRoot()], + 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 index ee1d52c6b..42ec4815c 100644 --- a/api/src/unraid-api/graph/services/subscription-helper.service.spec.ts +++ b/api/src/unraid-api/graph/services/subscription-helper.service.spec.ts @@ -13,7 +13,11 @@ describe('SubscriptionHelperService', () => { let loggerSpy: any; beforeEach(() => { - trackerService = new SubscriptionTrackerService(); + 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(() => {}); }); 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 index 65dd4ae33..80103c10b 100644 --- a/api/src/unraid-api/graph/services/subscription-tracker.service.spec.ts +++ b/api/src/unraid-api/graph/services/subscription-tracker.service.spec.ts @@ -9,7 +9,11 @@ describe('SubscriptionTrackerService', () => { let loggerSpy: any; beforeEach(() => { - service = new SubscriptionTrackerService(); + 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(() => {}); }); diff --git a/api/src/unraid-api/graph/services/subscription-tracker.service.ts b/api/src/unraid-api/graph/services/subscription-tracker.service.ts index 0186dec6c..7876bab51 100644 --- a/api/src/unraid-api/graph/services/subscription-tracker.service.ts +++ b/api/src/unraid-api/graph/services/subscription-tracker.service.ts @@ -1,13 +1,44 @@ 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 }>(); - public registerTopic(topic: string, onStart: () => void, onStop: () => void): void { - this.topicHandlers.set(topic, { onStart, onStop }); + 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 { From b4a761c1683d173e441caef394d027ab759d74cd Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 19 Aug 2025 13:04:46 -0400 Subject: [PATCH 11/18] test(api): enhance integration tests for MetricsResolver with SubscriptionPollingService - Integrated `ScheduleModule` to manage polling intervals effectively. - Updated tests to utilize `SubscriptionPollingService` for CPU and memory polling, ensuring single execution during concurrent attempts. - Improved error handling in polling tests to verify graceful error logging. - Ensured proper cleanup of polling subscriptions and timers during module destruction. --- .../metrics.resolver.integration.spec.ts | 154 +++++++++++++----- 1 file changed, 113 insertions(+), 41 deletions(-) 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 index 4354009da..5ec0a60a0 100644 --- 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 @@ -1,4 +1,5 @@ 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'; @@ -8,6 +9,7 @@ import { CpuDataService, CpuService } from '@app/unraid-api/graph/resolvers/info 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', () => { @@ -16,6 +18,7 @@ describe('MetricsResolver Integration Tests', () => { beforeEach(async () => { module = await Test.createTestingModule({ + imports: [ScheduleModule.forRoot()], providers: [ MetricsResolver, CpuService, @@ -23,20 +26,19 @@ describe('MetricsResolver Integration Tests', () => { MemoryService, SubscriptionTrackerService, SubscriptionHelperService, + SubscriptionPollingService, ], }).compile(); metricsResolver = module.get(MetricsResolver); + // Initialize the module to register polling topics + metricsResolver.onModuleInit(); }); afterEach(async () => { - // Clean up any active timers - if (metricsResolver['cpuPollingTimer']) { - clearInterval(metricsResolver['cpuPollingTimer']); - } - if (metricsResolver['memoryPollingTimer']) { - clearInterval(metricsResolver['memoryPollingTimer']); - } + // Clean up polling service + const pollingService = module.get(SubscriptionPollingService); + pollingService.stopAll(); await module.close(); }); @@ -89,33 +91,73 @@ describe('MetricsResolver Integration Tests', () => { describe('Polling Mechanism', () => { it('should prevent concurrent CPU polling executions', async () => { - // Start multiple polling attempts simultaneously - const promises = Array(5) - .fill(null) - .map(() => metricsResolver['pollCpuUtilization']()); + const trackerService = module.get(SubscriptionTrackerService); + const cpuService = module.get(CpuService); + let executionCount = 0; - await Promise.all(promises); + vi.spyOn(cpuService, 'generateCpuLoad').mockImplementation(async () => { + executionCount++; + await new Promise((resolve) => setTimeout(resolve, 50)); // Simulate slow operation + return { + id: 'info/cpu-load', + load: 50, + cpus: [], + }; + }); - // Only one execution should have occurred - expect(metricsResolver['isCpuPollingInProgress']).toBe(false); + // 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 () => { - // Start multiple polling attempts simultaneously - const promises = Array(5) - .fill(null) - .map(() => metricsResolver['pollMemoryUtilization']()); + const trackerService = module.get(SubscriptionTrackerService); + const memoryService = module.get(MemoryService); + let executionCount = 0; - await Promise.all(promises); + 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, + usedPercent: 50, + swapTotal: 0, + swapUsed: 0, + swapFree: 0, + swapUsedPercent: 0, + } as any; + }); - // Only one execution should have occurred - expect(metricsResolver['isMemoryPollingInProgress']).toBe(false); + // 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); - await metricsResolver['pollCpuUtilization'](); + // 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, @@ -128,13 +170,19 @@ describe('MetricsResolver Integration Tests', () => { }) ); + 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); - await metricsResolver['pollMemoryUtilization'](); + // 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, @@ -148,54 +196,78 @@ describe('MetricsResolver Integration Tests', () => { }) ); + trackerService.unsubscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION); publishSpy.mockRestore(); }); it('should handle errors in CPU polling gracefully', async () => { const service = module.get(CpuService); - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + 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')); - await metricsResolver['pollCpuUtilization'](); + // Trigger polling + trackerService.subscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); - expect(errorSpy).toHaveBeenCalledWith('Error polling CPU utilization:', expect.any(Error)); - expect(metricsResolver['isCpuPollingInProgress']).toBe(false); + // Wait for polling interval to trigger and handle error (1000ms for CPU) + await new Promise((resolve) => setTimeout(resolve, 1100)); - errorSpy.mockRestore(); + 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 errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + 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')); - await metricsResolver['pollMemoryUtilization'](); + // Trigger polling + trackerService.subscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION); - expect(errorSpy).toHaveBeenCalledWith( - 'Error polling 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) ); - expect(metricsResolver['isMemoryPollingInProgress']).toBe(false); - errorSpy.mockRestore(); + trackerService.unsubscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION); + loggerSpy.mockRestore(); }); }); describe('Polling cleanup on module destroy', () => { it('should clean up timers when module is destroyed', async () => { - // Force-start polling - await metricsResolver['pollCpuUtilization'](); - expect(metricsResolver['isCpuPollingInProgress']).toBe(false); + const trackerService = module.get(SubscriptionTrackerService); + const pollingService = module.get(SubscriptionPollingService); - await metricsResolver['pollMemoryUtilization'](); - expect(metricsResolver['isMemoryPollingInProgress']).toBe(false); + // 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(metricsResolver['cpuPollingTimer']).toBeUndefined(); - expect(metricsResolver['memoryPollingTimer']).toBeUndefined(); + expect(pollingService.isPolling(PUBSUB_CHANNEL.CPU_UTILIZATION)).toBe(false); + expect(pollingService.isPolling(PUBSUB_CHANNEL.MEMORY_UTILIZATION)).toBe(false); }); }); }); From 6930bb0500837149c31b99281c54f37926faed43 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 19 Aug 2025 13:36:03 -0400 Subject: [PATCH 12/18] feat(api): integrate ScheduleModule for task scheduling - Added `ScheduleModule` to the main application module for managing scheduled tasks. - Removed redundant `ScheduleModule` imports from `CronModule` and `ServicesModule` to streamline module dependencies. --- api/src/unraid-api/app/app.module.ts | 2 ++ api/src/unraid-api/cron/cron.module.ts | 2 +- api/src/unraid-api/graph/services/services.module.ts | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) 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/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/services/services.module.ts b/api/src/unraid-api/graph/services/services.module.ts index 7adb97e43..6f5399a05 100644 --- a/api/src/unraid-api/graph/services/services.module.ts +++ b/api/src/unraid-api/graph/services/services.module.ts @@ -6,7 +6,7 @@ import { SubscriptionPollingService } from '@app/unraid-api/graph/services/subsc import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js'; @Module({ - imports: [ScheduleModule.forRoot()], + imports: [], providers: [SubscriptionTrackerService, SubscriptionHelperService, SubscriptionPollingService], exports: [SubscriptionTrackerService, SubscriptionHelperService, SubscriptionPollingService], }) From 25ff13b0bb53df0144c1949d8a22b60e1ecad8e2 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 19 Aug 2025 13:36:38 -0400 Subject: [PATCH 13/18] fix(api): ensure proper cleanup in InfoResolver integration tests - Added a null check for the `module` before calling `close()` in the `afterEach` hook to prevent potential errors during test teardown. --- .../graph/resolvers/info/info.resolver.integration.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index 18800fcca..60800a178 100644 --- 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 @@ -68,7 +68,9 @@ describe('InfoResolver Integration Tests', () => { }); afterEach(async () => { - await module.close(); + if (module) { + await module.close(); + } }); describe('InfoResolver ResolveFields', () => { From 9df941317a86cf7cf8eb69d304482e244ced47ca Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 19 Aug 2025 14:07:20 -0400 Subject: [PATCH 14/18] refactor(api): update CPU and memory metrics naming conventions - Renamed CPU and memory metric fields for consistency, changing `load` to `percentTotal`, `loadUser` to `percentUser`, and similar adjustments for other fields. - Updated integration tests to reflect the new naming conventions, ensuring accurate property checks for CPU and memory utilization. - Enhanced the `CpuService` and `MemoryService` to return the updated metric names, improving clarity in the API response. --- .../graph/resolvers/info/cpu/cpu.model.ts | 14 ++++---- .../graph/resolvers/info/cpu/cpu.service.ts | 13 ++++++-- .../info/info.resolver.integration.spec.ts | 12 +++++-- .../resolvers/info/memory/memory.model.ts | 4 +-- .../resolvers/info/memory/memory.service.ts | 4 +-- .../metrics.resolver.integration.spec.ts | 32 +++++++++---------- 6 files changed, 47 insertions(+), 32 deletions(-) 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 index 60cd957e5..6a03d1002 100644 --- a/api/src/unraid-api/graph/resolvers/info/cpu/cpu.model.ts +++ b/api/src/unraid-api/graph/resolvers/info/cpu/cpu.model.ts @@ -6,33 +6,33 @@ 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.' }) - load!: number; + percentTotal!: number; @Field(() => Float, { description: 'The percentage of time the CPU spent in user space.' }) - loadUser!: number; + percentUser!: number; @Field(() => Float, { description: 'The percentage of time the CPU spent in kernel space.' }) - loadSystem!: number; + percentSystem!: number; @Field(() => Float, { description: 'The percentage of time the CPU spent on low-priority (niced) user space processes.', }) - loadNice!: number; + percentNice!: number; @Field(() => Float, { description: 'The percentage of time the CPU was idle.' }) - loadIdle!: number; + percentIdle!: number; @Field(() => Float, { description: 'The percentage of time the CPU spent servicing hardware interrupts.', }) - loadIrq!: number; + percentIrq!: number; } @ObjectType({ implements: () => Node }) export class CpuUtilization extends Node { @Field(() => Float, { description: 'Total CPU load in percent' }) - load!: number; + percentTotal!: number; @Field(() => [CpuLoad], { description: 'CPU load for each core' }) cpus!: CpuLoad[]; 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 index c0e1fc579..573c7a4b6 100644 --- a/api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.ts +++ b/api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.ts @@ -35,12 +35,19 @@ export class CpuService { } async generateCpuLoad(): Promise { - const { currentLoad: load, cpus } = await currentLoad(); + const loadData = await currentLoad(); return { id: 'info/cpu-load', - load, - cpus, + 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/info.resolver.integration.spec.ts b/api/src/unraid-api/graph/resolvers/info/info.resolver.integration.spec.ts index 60800a178..75d875c6c 100644 --- 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 @@ -34,8 +34,16 @@ describe('InfoResolver Integration Tests', () => { OsService, VersionsService, DisplayService, - SubscriptionTrackerService, - SubscriptionHelperService, + { + provide: SubscriptionTrackerService, + useValue: { + trackActiveSubscriptions: vi.fn(), + }, + }, + { + provide: SubscriptionHelperService, + useValue: {}, + }, { provide: ConfigService, useValue: { 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 index 8d29c58d3..1dabfa242 100644 --- a/api/src/unraid-api/graph/resolvers/info/memory/memory.model.ts +++ b/api/src/unraid-api/graph/resolvers/info/memory/memory.model.ts @@ -60,7 +60,7 @@ export class MemoryUtilization extends Node { buffcache!: number; @Field(() => Float, { description: 'Memory usage percentage' }) - usedPercent!: number; + percentUsed!: number; @Field(() => GraphQLBigInt, { description: 'Total swap memory in bytes' }) swapTotal!: number; @@ -72,7 +72,7 @@ export class MemoryUtilization extends Node { swapFree!: number; @Field(() => Float, { description: 'Swap usage percentage' }) - swapUsedPercent!: number; + percentSwapUsed!: number; } @ObjectType({ implements: () => Node }) 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 index 552e4d442..29935f426 100644 --- a/api/src/unraid-api/graph/resolvers/info/memory/memory.service.ts +++ b/api/src/unraid-api/graph/resolvers/info/memory/memory.service.ts @@ -40,12 +40,12 @@ export class MemoryService { available: Math.floor(memInfo.available), active: Math.floor(memInfo.active), buffcache: Math.floor(memInfo.buffcache), - usedPercent: + percentUsed: 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), - swapUsedPercent: memInfo.swaptotal > 0 ? (memInfo.swapused / memInfo.swaptotal) * 100 : 0, + percentSwapUsed: memInfo.swaptotal > 0 ? (memInfo.swapused / memInfo.swaptotal) * 100 : 0, }; } } 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 index 5ec0a60a0..3e2c67a7b 100644 --- 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 @@ -54,18 +54,18 @@ describe('MetricsResolver Integration Tests', () => { const result = await metricsResolver.cpu(); expect(result).toHaveProperty('id', 'info/cpu-load'); - expect(result).toHaveProperty('load'); + expect(result).toHaveProperty('percentTotal'); expect(result).toHaveProperty('cpus'); expect(result.cpus).toBeInstanceOf(Array); - expect(result.load).toBeGreaterThanOrEqual(0); - expect(result.load).toBeLessThanOrEqual(100); + expect(result.percentTotal).toBeGreaterThanOrEqual(0); + expect(result.percentTotal).toBeLessThanOrEqual(100); if (result.cpus.length > 0) { const firstCpu = result.cpus[0]; - expect(firstCpu).toHaveProperty('load'); - expect(firstCpu).toHaveProperty('loadUser'); - expect(firstCpu).toHaveProperty('loadSystem'); - expect(firstCpu).toHaveProperty('loadIdle'); + expect(firstCpu).toHaveProperty('percentTotal'); + expect(firstCpu).toHaveProperty('percentUser'); + expect(firstCpu).toHaveProperty('percentSystem'); + expect(firstCpu).toHaveProperty('percentIdle'); } }); @@ -77,15 +77,15 @@ describe('MetricsResolver Integration Tests', () => { expect(result).toHaveProperty('used'); expect(result).toHaveProperty('free'); expect(result).toHaveProperty('available'); - expect(result).toHaveProperty('usedPercent'); + expect(result).toHaveProperty('percentUsed'); expect(result).toHaveProperty('swapTotal'); expect(result).toHaveProperty('swapUsed'); expect(result).toHaveProperty('swapFree'); - expect(result).toHaveProperty('swapUsedPercent'); + expect(result).toHaveProperty('percentSwapUsed'); expect(result.total).toBeGreaterThan(0); - expect(result.usedPercent).toBeGreaterThanOrEqual(0); - expect(result.usedPercent).toBeLessThanOrEqual(100); + expect(result.percentUsed).toBeGreaterThanOrEqual(0); + expect(result.percentUsed).toBeLessThanOrEqual(100); }); }); @@ -100,7 +100,7 @@ describe('MetricsResolver Integration Tests', () => { await new Promise((resolve) => setTimeout(resolve, 50)); // Simulate slow operation return { id: 'info/cpu-load', - load: 50, + percentTotal: 50, cpus: [], }; }); @@ -131,11 +131,11 @@ describe('MetricsResolver Integration Tests', () => { available: 8000000000, active: 4000000000, buffcache: 2000000000, - usedPercent: 50, + percentUsed: 50, swapTotal: 0, swapUsed: 0, swapFree: 0, - swapUsedPercent: 0, + percentSwapUsed: 0, } as any; }); @@ -164,7 +164,7 @@ describe('MetricsResolver Integration Tests', () => { expect.objectContaining({ systemMetricsCpu: expect.objectContaining({ id: 'info/cpu-load', - load: expect.any(Number), + percentTotal: expect.any(Number), cpus: expect.any(Array), }), }) @@ -191,7 +191,7 @@ describe('MetricsResolver Integration Tests', () => { id: 'memory-utilization', used: expect.any(Number), free: expect.any(Number), - usedPercent: expect.any(Number), + percentUsed: expect.any(Number), }), }) ); From 57ef525d8e954f5b4ba92276420e58380334a35f Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 19 Aug 2025 14:11:59 -0400 Subject: [PATCH 15/18] refactor(api): standardize CPU and memory metric field names - Renamed CPU and memory metric fields for consistency, changing `load` to `percentTotal`, `loadUser` to `percentUser`, and similar adjustments for other fields across the GraphQL schema and resolvers. - Updated integration tests to reflect the new naming conventions, ensuring accurate property checks for CPU and memory utilization. - Enhanced the `CpuService` and `MemoryService` to return the updated metric names, improving clarity in the API response. --- api/generated-schema.graphql | 18 +++++++------- api/src/unraid-api/cli/generated/graphql.ts | 24 +++++++++---------- .../resolvers/info/memory/memory.model.ts | 4 ++-- .../resolvers/info/memory/memory.service.ts | 4 ++-- .../metrics.resolver.integration.spec.ts | 14 +++++------ web/composables/gql/graphql.ts | 24 +++++++++---------- 6 files changed, 44 insertions(+), 44 deletions(-) diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 23fd9267f..25631cb05 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -1239,31 +1239,31 @@ enum Temperature { """CPU load for a single core""" type CpuLoad { """The total CPU load on a single core, in percent.""" - load: Float! + percentTotal: Float! """The percentage of time the CPU spent in user space.""" - loadUser: Float! + percentUser: Float! """The percentage of time the CPU spent in kernel space.""" - loadSystem: Float! + percentSystem: Float! """ The percentage of time the CPU spent on low-priority (niced) user space processes. """ - loadNice: Float! + percentNice: Float! """The percentage of time the CPU was idle.""" - loadIdle: Float! + percentIdle: Float! """The percentage of time the CPU spent servicing hardware interrupts.""" - loadIrq: Float! + percentIrq: Float! } type CpuUtilization implements Node { id: PrefixedID! """Total CPU load in percent""" - load: Float! + percentTotal: Float! """CPU load for each core""" cpus: [CpuLoad!]! @@ -1383,7 +1383,7 @@ type MemoryUtilization implements Node { buffcache: BigInt! """Memory usage percentage""" - usedPercent: Float! + percentTotal: Float! """Total swap memory in bytes""" swapTotal: BigInt! @@ -1395,7 +1395,7 @@ type MemoryUtilization implements Node { swapFree: BigInt! """Swap usage percentage""" - swapUsedPercent: Float! + percentSwapTotal: Float! } type InfoMemory implements Node { diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index 2fc6d59e2..2a09e7a91 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -523,18 +523,18 @@ export enum ContainerState { /** CPU load for a single core */ export type CpuLoad = { __typename?: 'CpuLoad'; - /** The total CPU load on a single core, in percent. */ - load: Scalars['Float']['output']; /** The percentage of time the CPU was idle. */ - loadIdle: Scalars['Float']['output']; + percentIdle: Scalars['Float']['output']; /** The percentage of time the CPU spent servicing hardware interrupts. */ - loadIrq: Scalars['Float']['output']; + percentIrq: Scalars['Float']['output']; /** The percentage of time the CPU spent on low-priority (niced) user space processes. */ - loadNice: Scalars['Float']['output']; + percentNice: Scalars['Float']['output']; /** The percentage of time the CPU spent in kernel space. */ - loadSystem: Scalars['Float']['output']; + 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. */ - loadUser: Scalars['Float']['output']; + percentUser: Scalars['Float']['output']; }; export type CpuUtilization = Node & { @@ -543,7 +543,7 @@ export type CpuUtilization = Node & { cpus: Array; id: Scalars['PrefixedID']['output']; /** Total CPU load in percent */ - load: Scalars['Float']['output']; + percentTotal: Scalars['Float']['output']; }; export type CreateApiKeyInput = { @@ -1181,20 +1181,20 @@ export type MemoryUtilization = Node & { /** Free memory in bytes */ free: Scalars['BigInt']['output']; id: Scalars['PrefixedID']['output']; + /** Swap usage percentage */ + percentSwapUsed: Scalars['Float']['output']; + /** Memory usage percentage */ + percentUsed: 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']; - /** Swap usage percentage */ - swapUsedPercent: Scalars['Float']['output']; /** Total system memory in bytes */ total: Scalars['BigInt']['output']; /** Used memory in bytes */ used: Scalars['BigInt']['output']; - /** Memory usage percentage */ - usedPercent: Scalars['Float']['output']; }; /** System metrics including CPU and memory utilization */ 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 index 1dabfa242..4e2aa46b3 100644 --- a/api/src/unraid-api/graph/resolvers/info/memory/memory.model.ts +++ b/api/src/unraid-api/graph/resolvers/info/memory/memory.model.ts @@ -60,7 +60,7 @@ export class MemoryUtilization extends Node { buffcache!: number; @Field(() => Float, { description: 'Memory usage percentage' }) - percentUsed!: number; + percentTotal!: number; @Field(() => GraphQLBigInt, { description: 'Total swap memory in bytes' }) swapTotal!: number; @@ -72,7 +72,7 @@ export class MemoryUtilization extends Node { swapFree!: number; @Field(() => Float, { description: 'Swap usage percentage' }) - percentSwapUsed!: number; + percentSwapTotal!: number; } @ObjectType({ implements: () => Node }) 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 index 29935f426..6d82cbc5e 100644 --- a/api/src/unraid-api/graph/resolvers/info/memory/memory.service.ts +++ b/api/src/unraid-api/graph/resolvers/info/memory/memory.service.ts @@ -40,12 +40,12 @@ export class MemoryService { available: Math.floor(memInfo.available), active: Math.floor(memInfo.active), buffcache: Math.floor(memInfo.buffcache), - percentUsed: + 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), - percentSwapUsed: memInfo.swaptotal > 0 ? (memInfo.swapused / memInfo.swaptotal) * 100 : 0, + percentSwapTotal: memInfo.swaptotal > 0 ? (memInfo.swapused / memInfo.swaptotal) * 100 : 0, }; } } 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 index 3e2c67a7b..2ec88f765 100644 --- 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 @@ -77,15 +77,15 @@ describe('MetricsResolver Integration Tests', () => { expect(result).toHaveProperty('used'); expect(result).toHaveProperty('free'); expect(result).toHaveProperty('available'); - expect(result).toHaveProperty('percentUsed'); + expect(result).toHaveProperty('percentTotal'); expect(result).toHaveProperty('swapTotal'); expect(result).toHaveProperty('swapUsed'); expect(result).toHaveProperty('swapFree'); - expect(result).toHaveProperty('percentSwapUsed'); + expect(result).toHaveProperty('percentSwapTotal'); expect(result.total).toBeGreaterThan(0); - expect(result.percentUsed).toBeGreaterThanOrEqual(0); - expect(result.percentUsed).toBeLessThanOrEqual(100); + expect(result.percentTotal).toBeGreaterThanOrEqual(0); + expect(result.percentTotal).toBeLessThanOrEqual(100); }); }); @@ -131,11 +131,11 @@ describe('MetricsResolver Integration Tests', () => { available: 8000000000, active: 4000000000, buffcache: 2000000000, - percentUsed: 50, + percentTotal: 50, swapTotal: 0, swapUsed: 0, swapFree: 0, - percentSwapUsed: 0, + percentSwapTotal: 0, } as any; }); @@ -191,7 +191,7 @@ describe('MetricsResolver Integration Tests', () => { id: 'memory-utilization', used: expect.any(Number), free: expect.any(Number), - percentUsed: expect.any(Number), + percentTotal: expect.any(Number), }), }) ); diff --git a/web/composables/gql/graphql.ts b/web/composables/gql/graphql.ts index c7ba55302..32c5ffa43 100644 --- a/web/composables/gql/graphql.ts +++ b/web/composables/gql/graphql.ts @@ -523,18 +523,18 @@ export enum ContainerState { /** CPU load for a single core */ export type CpuLoad = { __typename?: 'CpuLoad'; - /** The total CPU load on a single core, in percent. */ - load: Scalars['Float']['output']; /** The percentage of time the CPU was idle. */ - loadIdle: Scalars['Float']['output']; + percentIdle: Scalars['Float']['output']; /** The percentage of time the CPU spent servicing hardware interrupts. */ - loadIrq: Scalars['Float']['output']; + percentIrq: Scalars['Float']['output']; /** The percentage of time the CPU spent on low-priority (niced) user space processes. */ - loadNice: Scalars['Float']['output']; + percentNice: Scalars['Float']['output']; /** The percentage of time the CPU spent in kernel space. */ - loadSystem: Scalars['Float']['output']; + 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. */ - loadUser: Scalars['Float']['output']; + percentUser: Scalars['Float']['output']; }; export type CpuUtilization = Node & { @@ -543,7 +543,7 @@ export type CpuUtilization = Node & { cpus: Array; id: Scalars['PrefixedID']['output']; /** Total CPU load in percent */ - load: Scalars['Float']['output']; + percentTotal: Scalars['Float']['output']; }; export type CreateApiKeyInput = { @@ -1181,20 +1181,20 @@ export type MemoryUtilization = Node & { /** 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']; - /** Swap usage percentage */ - swapUsedPercent: Scalars['Float']['output']; /** Total system memory in bytes */ total: Scalars['BigInt']['output']; /** Used memory in bytes */ used: Scalars['BigInt']['output']; - /** Memory usage percentage */ - usedPercent: Scalars['Float']['output']; }; /** System metrics including CPU and memory utilization */ From 4d74a5e241ba0701e3b41460ef6b822602723bcc Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 19 Aug 2025 14:12:33 -0400 Subject: [PATCH 16/18] refactor(api): update memory metric field names for consistency - Renamed `percentSwapUsed` to `percentSwapTotal` and `percentUsed` to `percentTotal` in the GraphQL schema to align with recent naming conventions. - Adjusted export statements in the index file for consistency in quotation style. --- api/src/unraid-api/cli/generated/graphql.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index 2a09e7a91..d0b08eeb9 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -1182,9 +1182,9 @@ export type MemoryUtilization = Node & { free: Scalars['BigInt']['output']; id: Scalars['PrefixedID']['output']; /** Swap usage percentage */ - percentSwapUsed: Scalars['Float']['output']; + percentSwapTotal: Scalars['Float']['output']; /** Memory usage percentage */ - percentUsed: Scalars['Float']['output']; + percentTotal: Scalars['Float']['output']; /** Free swap memory in bytes */ swapFree: Scalars['BigInt']['output']; /** Total swap memory in bytes */ From 8e258d30ed50c464ad21182b4112de67fd59232f Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 19 Aug 2025 14:20:30 -0400 Subject: [PATCH 17/18] feat(api): add enum validation utility for improved type safety - Introduced `isValidEnumValue` and `validateEnumValue` functions to validate enum values, enhancing type safety in the application. - Updated `DisplayService` to utilize `validateEnumValue` for theme and unit properties, ensuring only valid enum values are assigned. --- api/src/core/utils/validation/enum-validator.ts | 17 +++++++++++++++++ .../resolvers/info/display/display.service.ts | 10 ++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) create mode 100644 api/src/core/utils/validation/enum-validator.ts 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/graph/resolvers/info/display/display.service.ts b/api/src/unraid-api/graph/resolvers/info/display/display.service.ts index 6945377bc..9668b55ac 100644 --- a/api/src/unraid-api/graph/resolvers/info/display/display.service.ts +++ b/api/src/unraid-api/graph/resolvers/info/display/display.service.ts @@ -6,6 +6,7 @@ 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/display/display.model.js'; @@ -77,8 +78,8 @@ export class DisplayService { const display: Display = { id: 'info/display', case: caseInfo, - theme: config.theme || ThemeName.white, - unit: config.unit || Temperature.CELSIUS, + theme: config.theme ?? ThemeName.white, + unit: config.unit ?? Temperature.CELSIUS, scale: config.scale ?? false, tabs: config.tabs ?? true, resize: config.resize ?? true, @@ -145,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), From ee65e804355e0d3bdb8b3974d3fefb171fc78f30 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 19 Aug 2025 14:30:10 -0400 Subject: [PATCH 18/18] refactor(api): remove CpuDataService from info and metrics modules - Eliminated the `CpuDataService` from the `info.module.ts`, `metrics.module.ts`, and related integration tests, streamlining the CPU service implementation. - Updated imports and provider lists to reflect the removal, ensuring the application remains functional without the redundant service. --- .../graph/resolvers/info/cpu/cpu.service.ts | 14 ++------------ .../unraid-api/graph/resolvers/info/info.module.ts | 3 +-- .../info/info.resolver.integration.spec.ts | 3 +-- .../graph/resolvers/metrics/metrics.module.ts | 4 ++-- .../metrics/metrics.resolver.integration.spec.ts | 3 +-- .../resolvers/metrics/metrics.resolver.spec.ts | 6 +----- 6 files changed, 8 insertions(+), 25 deletions(-) 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 index 573c7a4b6..0ae43debe 100644 --- a/api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.ts +++ b/api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.ts @@ -1,19 +1,9 @@ -import { Injectable, Scope } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; -import { cpu, cpuFlags, currentLoad, Systeminformation } from 'systeminformation'; +import { cpu, cpuFlags, currentLoad } from 'systeminformation'; import { CpuUtilization, InfoCpu } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js'; -@Injectable({ scope: Scope.REQUEST }) -export class CpuDataService { - private cpuLoadData: Promise | undefined; - - public getCpuLoad(): Promise { - this.cpuLoadData ??= currentLoad(); - return this.cpuLoadData; - } -} - @Injectable() export class CpuService { async generateCpu(): Promise { diff --git a/api/src/unraid-api/graph/resolvers/info/info.module.ts b/api/src/unraid-api/graph/resolvers/info/info.module.ts index 3e74e3520..a28a472b5 100644 --- a/api/src/unraid-api/graph/resolvers/info/info.module.ts +++ b/api/src/unraid-api/graph/resolvers/info/info.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { CpuDataService, CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.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'; @@ -22,7 +22,6 @@ import { ServicesModule } from '@app/unraid-api/graph/services/services.module.j // Services CpuService, - CpuDataService, MemoryService, DevicesService, OsService, 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 index 75d875c6c..2745cfae8 100644 --- 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 @@ -6,7 +6,7 @@ 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 { CpuDataService, CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.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'; @@ -28,7 +28,6 @@ describe('InfoResolver Integration Tests', () => { InfoResolver, DevicesResolver, CpuService, - CpuDataService, MemoryService, DevicesService, OsService, diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts index 8d1481113..93dbb7ded 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts @@ -1,13 +1,13 @@ import { Module } from '@nestjs/common'; -import { CpuDataService, CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.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 { ServicesModule } from '@app/unraid-api/graph/services/services.module.js'; @Module({ imports: [ServicesModule], - providers: [MetricsResolver, CpuService, CpuDataService, MemoryService], + 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 index 2ec88f765..dc0bed698 100644 --- 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 @@ -5,7 +5,7 @@ import { Test } from '@nestjs/testing'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; -import { CpuDataService, CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.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'; @@ -22,7 +22,6 @@ describe('MetricsResolver Integration Tests', () => { providers: [ MetricsResolver, CpuService, - CpuDataService, MemoryService, SubscriptionTrackerService, SubscriptionHelperService, 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 index 4cdde37bf..af674c24e 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.spec.ts @@ -3,7 +3,7 @@ import { Test } from '@nestjs/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { CpuDataService, CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.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'; @@ -45,10 +45,6 @@ describe('MetricsResolver', () => { }), }, }, - { - provide: CpuDataService, - useValue: {}, - }, { provide: MemoryService, useValue: {