Skip to content

Commit 9a35b38

Browse files
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.
1 parent ce63d5d commit 9a35b38

File tree

8 files changed

+249
-6
lines changed

8 files changed

+249
-6
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Injectable, Scope } from '@nestjs/common';
2+
import { currentLoad, Systeminformation } from 'systeminformation';
3+
4+
@Injectable({ scope: Scope.REQUEST })
5+
export class CpuDataService {
6+
private cpuLoadData: Promise<Systeminformation.CurrentLoadData>;
7+
8+
public getCpuLoad(): Promise<Systeminformation.CurrentLoadData> {
9+
if (!this.cpuLoadData) {
10+
this.cpuLoadData = currentLoad();
11+
}
12+
return this.cpuLoadData;
13+
}
14+
}

api/src/unraid-api/graph/resolvers/info/info.model.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,42 @@ export class InfoCpu extends Node {
117117

118118
@Field(() => [String])
119119
flags!: string[];
120+
121+
@Field(() => Float, {
122+
description: 'CPU utilization in percent',
123+
nullable: true,
124+
})
125+
utilization?: number;
126+
}
127+
128+
@ObjectType({ description: 'CPU load for a single core' })
129+
export class CpuLoad {
130+
@Field(() => Float, { description: 'The total CPU load on a single core, in percent.' })
131+
load!: number;
132+
133+
@Field(() => Float, { description: 'The percentage of time the CPU spent in user space.' })
134+
loadUser!: number;
135+
136+
@Field(() => Float, { description: 'The percentage of time the CPU spent in kernel space.' })
137+
loadSystem!: number;
138+
139+
@Field(() => Float, { description: 'The percentage of time the CPU spent on low-priority (niced) user space processes.' })
140+
loadNice!: number;
141+
142+
@Field(() => Float, { description: 'The percentage of time the CPU was idle.' })
143+
loadIdle!: number;
144+
145+
@Field(() => Float, { description: 'The percentage of time the CPU spent servicing hardware interrupts.' })
146+
loadIrq!: number;
147+
}
148+
149+
@ObjectType({ implements: () => Node })
150+
export class CpuUtilization extends Node {
151+
@Field(() => Float)
152+
load!: number;
153+
154+
@Field(() => [CpuLoad])
155+
cpus!: CpuLoad[];
120156
}
121157

122158
@ObjectType({ implements: () => Node })

api/src/unraid-api/graph/resolvers/info/info.resolver.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
66

77
import { DisplayService } from '@app/unraid-api/graph/resolvers/display/display.service.js';
88
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
9+
import { CpuDataService } from '@app/unraid-api/graph/resolvers/info/cpu-data.service.js';
910
import { InfoResolver } from '@app/unraid-api/graph/resolvers/info/info.resolver.js';
1011
import { InfoService } from '@app/unraid-api/graph/resolvers/info/info.service.js';
12+
import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js';
1113

1214
// Mock necessary modules
1315
vi.mock('fs/promises', () => ({
@@ -187,6 +189,19 @@ describe('InfoResolver', () => {
187189
generateDisplay: vi.fn().mockResolvedValue(mockDisplayData),
188190
};
189191

192+
const mockSubscriptionTrackerService = {
193+
registerTopic: vi.fn(),
194+
subscribe: vi.fn(),
195+
unsubscribe: vi.fn(),
196+
};
197+
198+
const mockCpuDataService = {
199+
getCpuLoad: vi.fn().mockResolvedValue({
200+
currentLoad: 10,
201+
cpus: [],
202+
}),
203+
};
204+
190205
beforeEach(async () => {
191206
const module: TestingModule = await Test.createTestingModule({
192207
providers: [
@@ -207,6 +222,14 @@ describe('InfoResolver', () => {
207222
provide: CACHE_MANAGER,
208223
useValue: mockCacheManager,
209224
},
225+
{
226+
provide: SubscriptionTrackerService,
227+
useValue: mockSubscriptionTrackerService,
228+
},
229+
{
230+
provide: CpuDataService,
231+
useValue: mockCpuDataService,
232+
},
210233
],
211234
}).compile();
212235

api/src/unraid-api/graph/resolvers/info/info.resolver.ts

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,31 @@
1-
import { Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql';
1+
import { OnModuleInit } from '@nestjs/common';
2+
import {
3+
Parent,
4+
Query,
5+
Resolver,
6+
Subscription,
7+
ResolveField,
8+
} from '@nestjs/graphql';
9+
import { PubSub } from 'graphql-subscriptions';
210

311
import { Resource } from '@unraid/shared/graphql.model.js';
412
import {
513
AuthActionVerb,
614
AuthPossession,
715
UsePermissions,
816
} from '@unraid/shared/use-permissions.directive.js';
9-
import { baseboard as getBaseboard, system as getSystem } from 'systeminformation';
17+
import {
18+
baseboard as getBaseboard,
19+
system as getSystem,
20+
} from 'systeminformation';
1021

11-
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
1222
import { getMachineId } from '@app/core/utils/misc/get-machine-id.js';
23+
import {
24+
createSubscription,
25+
PUBSUB_CHANNEL,
26+
pubsub,
27+
} from '@app/core/pubsub.js';
28+
1329
import { DisplayService } from '@app/unraid-api/graph/resolvers/display/display.service.js';
1430
import {
1531
Baseboard,
@@ -22,16 +38,40 @@ import {
2238
Os,
2339
System,
2440
Versions,
41+
CpuUtilization,
2542
} from '@app/unraid-api/graph/resolvers/info/info.model.js';
43+
import { CpuDataService } from '@app/unraid-api/graph/resolvers/info/cpu-data.service.js';
2644
import { InfoService } from '@app/unraid-api/graph/resolvers/info/info.service.js';
45+
import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js';
46+
47+
const CPU_UTILIZATION = 'CPU_UTILIZATION';
2748

2849
@Resolver(() => Info)
29-
export class InfoResolver {
50+
export class InfoResolver implements OnModuleInit {
51+
private cpuPollingTimer: NodeJS.Timeout;
52+
3053
constructor(
3154
private readonly infoService: InfoService,
32-
private readonly displayService: DisplayService
55+
private readonly displayService: DisplayService,
56+
private readonly subscriptionTracker: SubscriptionTrackerService,
57+
private readonly cpuDataService: CpuDataService
3358
) {}
3459

60+
onModuleInit() {
61+
this.subscriptionTracker.registerTopic(
62+
CPU_UTILIZATION,
63+
() => {
64+
this.cpuPollingTimer = setInterval(async () => {
65+
const payload = await this.infoService.generateCpuLoad();
66+
pubsub.publish(CPU_UTILIZATION, { cpuUtilization: payload });
67+
}, 1000);
68+
},
69+
() => {
70+
clearInterval(this.cpuPollingTimer);
71+
}
72+
);
73+
}
74+
3575
@Query(() => Info)
3676
@UsePermissions({
3777
action: AuthActionVerb.READ,
@@ -116,4 +156,57 @@ export class InfoResolver {
116156
public async infoSubscription() {
117157
return createSubscription(PUBSUB_CHANNEL.INFO);
118158
}
159+
160+
@Query(() => CpuUtilization)
161+
@UsePermissions({
162+
action: AuthActionVerb.READ,
163+
resource: Resource.INFO,
164+
possession: AuthPossession.ANY,
165+
})
166+
public async cpuUtilization(): Promise<CpuUtilization> {
167+
const { currentLoad: load, cpus } = await this.cpuDataService.getCpuLoad();
168+
return {
169+
id: 'info/cpu-load',
170+
load,
171+
cpus,
172+
};
173+
}
174+
175+
@Subscription(() => CpuUtilization, {
176+
name: 'cpuUtilization',
177+
resolve: (value) => value.cpuUtilization,
178+
})
179+
@UsePermissions({
180+
action: AuthActionVerb.READ,
181+
resource: Resource.INFO,
182+
possession: AuthPossession.ANY,
183+
})
184+
public async cpuUtilizationSubscription() {
185+
const iterator = createSubscription(CPU_UTILIZATION);
186+
187+
return {
188+
[Symbol.asyncIterator]: () => {
189+
this.subscriptionTracker.subscribe(CPU_UTILIZATION);
190+
return iterator[Symbol.asyncIterator]();
191+
},
192+
return: () => {
193+
this.subscriptionTracker.unsubscribe(CPU_UTILIZATION);
194+
return iterator.return();
195+
},
196+
};
197+
}
198+
}
199+
200+
@Resolver(() => InfoCpu)
201+
export class InfoCpuResolver {
202+
constructor(private readonly cpuDataService: CpuDataService) {}
203+
204+
@ResolveField(() => Number, {
205+
description: 'CPU utilization in percent',
206+
nullable: true,
207+
})
208+
public async utilization(@Parent() cpu: InfoCpu): Promise<number> {
209+
const { currentLoad } = await this.cpuDataService.getCpuLoad();
210+
return currentLoad;
211+
}
119212
}

api/src/unraid-api/graph/resolvers/info/info.service.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { Injectable } from '@nestjs/common';
22

3-
import { cpu, cpuFlags, mem, memLayout, osInfo, versions } from 'systeminformation';
3+
import {
4+
cpu,
5+
cpuFlags,
6+
mem,
7+
memLayout,
8+
osInfo,
9+
versions,
10+
currentLoad,
11+
} from 'systeminformation';
412

513
import { bootTimestamp } from '@app/common/dashboard/boot-timestamp.js';
614
import { getUnraidVersion } from '@app/common/dashboard/get-unraid-version.js';
@@ -15,6 +23,7 @@ import {
1523
Os as InfoOs,
1624
MemoryLayout,
1725
Versions,
26+
CpuUtilization,
1827
} from '@app/unraid-api/graph/resolvers/info/info.model.js';
1928

2029
@Injectable()
@@ -91,4 +100,14 @@ export class InfoService {
91100
// These fields will be resolved by DevicesResolver
92101
} as Devices;
93102
}
103+
104+
async generateCpuLoad(): Promise<CpuUtilization> {
105+
const { currentLoad: load, cpus } = await currentLoad();
106+
107+
return {
108+
id: 'info/cpu-load',
109+
load,
110+
cpus,
111+
};
112+
}
94113
}

api/src/unraid-api/graph/resolvers/resolvers.module.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,16 @@ import { VarsResolver } from '@app/unraid-api/graph/resolvers/vars/vars.resolver
3333
import { VmMutationsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.mutations.resolver.js';
3434
import { VmsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.resolver.js';
3535
import { VmsService } from '@app/unraid-api/graph/resolvers/vms/vms.service.js';
36+
import { ServicesModule } from '@app/unraid-api/graph/services/services.module.js';
3637
import { ServicesResolver } from '@app/unraid-api/graph/services/services.resolver.js';
3738
import { SharesResolver } from '@app/unraid-api/graph/shares/shares.resolver.js';
3839
import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js';
40+
import { InfoCpuResolver } from '@app/unraid-api/graph/resolvers/info/info.resolver.js';
41+
import { CpuDataService } from '@app/unraid-api/graph/resolvers/info/cpu-data.service.js';
3942

4043
@Module({
4144
imports: [
45+
ServicesModule,
4246
ArrayModule,
4347
ApiKeyModule,
4448
AuthModule,
@@ -76,6 +80,8 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js';
7680
VmMutationsResolver,
7781
VmsResolver,
7882
VmsService,
83+
InfoCpuResolver,
84+
CpuDataService,
7985
],
8086
exports: [ApiKeyModule],
8187
})
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Module } from '@nestjs/common';
2+
import { SubscriptionTrackerService } from './subscription-tracker.service';
3+
4+
@Module({
5+
providers: [SubscriptionTrackerService],
6+
exports: [SubscriptionTrackerService],
7+
})
8+
export class ServicesModule {}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Injectable } from '@nestjs/common';
2+
3+
@Injectable()
4+
export class SubscriptionTrackerService {
5+
private subscriberCounts = new Map<string, number>();
6+
private topicHandlers = new Map<
7+
string,
8+
{ onStart: () => void; onStop: () => void }
9+
>();
10+
11+
public registerTopic(
12+
topic: string,
13+
onStart: () => void,
14+
onStop: () => void
15+
): void {
16+
this.topicHandlers.set(topic, { onStart, onStop });
17+
}
18+
19+
public subscribe(topic: string): void {
20+
const currentCount = this.subscriberCounts.get(topic) || 0;
21+
this.subscriberCounts.set(topic, currentCount + 1);
22+
23+
if (currentCount === 0) {
24+
const handlers = this.topicHandlers.get(topic);
25+
if (handlers) {
26+
handlers.onStart();
27+
}
28+
}
29+
}
30+
31+
public unsubscribe(topic: string): void {
32+
const currentCount = this.subscriberCounts.get(topic) || 1;
33+
const newCount = currentCount - 1;
34+
35+
this.subscriberCounts.set(topic, newCount);
36+
37+
if (newCount === 0) {
38+
const handlers = this.topicHandlers.get(topic);
39+
if (handlers) {
40+
handlers.onStop();
41+
}
42+
}
43+
}
44+
}

0 commit comments

Comments
 (0)