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