mirror of
https://github.com/unraid/api.git
synced 2026-01-06 08:39:54 -06:00
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.
This commit is contained in:
committed by
Eli Bosley
parent
effdbcf0f5
commit
f7e1d8f259
14
api/src/unraid-api/graph/resolvers/info/cpu-data.service.ts
Normal file
14
api/src/unraid-api/graph/resolvers/info/cpu-data.service.ts
Normal file
@@ -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<Systeminformation.CurrentLoadData>;
|
||||
|
||||
public getCpuLoad(): Promise<Systeminformation.CurrentLoadData> {
|
||||
if (!this.cpuLoadData) {
|
||||
this.cpuLoadData = currentLoad();
|
||||
}
|
||||
return this.cpuLoadData;
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<CpuUtilization> {
|
||||
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<number> {
|
||||
const { currentLoad } = await this.cpuDataService.getCpuLoad();
|
||||
return currentLoad;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CpuUtilization> {
|
||||
const { currentLoad: load, cpus } = await currentLoad();
|
||||
|
||||
return {
|
||||
id: 'info/cpu-load',
|
||||
load,
|
||||
cpus,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
8
api/src/unraid-api/graph/services/services.module.ts
Normal file
8
api/src/unraid-api/graph/services/services.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SubscriptionTrackerService } from './subscription-tracker.service';
|
||||
|
||||
@Module({
|
||||
providers: [SubscriptionTrackerService],
|
||||
exports: [SubscriptionTrackerService],
|
||||
})
|
||||
export class ServicesModule {}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class SubscriptionTrackerService {
|
||||
private subscriberCounts = new Map<string, number>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user