Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions api/generated-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -1401,6 +1401,17 @@ type CpuUtilization implements Node {
cpus: [CpuLoad!]!
}

type CpuPackages implements Node {
"""Total CPU package power draw (W)"""
totalPower: Float!

"""Power draw per package (W)"""
power: [Float!]

"""description: 'Temperature per package (°C)"""
temp: [Float!]
}
Comment on lines +1404 to +1413
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix malformed description and verify Node implementation.

Line 1411 has a malformed description: """description: 'Temperature per package (°C)""" should be """Temperature per package (°C)""".

Additionally, CpuPackages implements Node (line 1404), which requires an id: PrefixedID! field, but this field is not present in the TypeScript model (cpu.model.ts lines 42-52). This will cause runtime errors.

To fix the TypeScript model, add the id field:

@ObjectType({ description: 'CPU package telemetry data', implements: () => Node })
export class CpuPackages extends Node {
    @Field(() => Float, { description: 'Total CPU package power draw (W)', nullable: true })
    totalPower?: number;

    @Field(() => [Float], { description: 'Power draw per package (W)', nullable: true })
    power?: number[];

    @Field(() => [Float], { description: 'Temperature per package (°C)', nullable: true })
    temp?: number[];
}

Or if CpuPackages shouldn't implement Node, remove it from the GraphQL ObjectType decorator in the TypeScript file and regenerate the schema.

🤖 Prompt for AI Agents
In api/generated-schema.graphql around lines 1404 to 1413, the GraphQL
description for temp is malformed and the type declares CpuPackages implements
Node but the corresponding TypeScript model (cpu.model.ts lines 42-52) lacks the
required id: PrefixedID! field; fix by correcting the triple-quoted description
to """Temperature per package (°C)""" and then either add the missing id field
to the CpuPackages TypeScript class (extend/implement Node and declare id:
PrefixedID with appropriate @Field() decorator) or remove the Node
implementation from the TypeScript @ObjectType decorator and regenerate the
schema so the GraphQL and TypeScript models stay consistent.


type InfoCpu implements Node {
id: PrefixedID!

Expand Down Expand Up @@ -1446,6 +1457,9 @@ type InfoCpu implements Node {
"""Number of physical processors"""
processors: Int

"""CPU packages information"""
packages: CpuPackages

"""CPU socket type"""
socket: String

Expand Down Expand Up @@ -2642,6 +2656,7 @@ type Subscription {
arraySubscription: UnraidArray!
logFile(path: String!): LogFileContent!
systemMetricsCpu: CpuUtilization!
systemMetricsCpuTelemetry: CpuPackages!
systemMetricsMemory: MemoryUtilization!
upsUpdates: UPSDevice!
}
211 changes: 211 additions & 0 deletions api/src/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { Injectable, Logger } from '@nestjs/common';
import { constants as fsConstants } from 'node:fs';
import { access, readdir, readFile } from 'node:fs/promises';
import { join } from 'path';

@Injectable()
export class CpuTopologyService {
private readonly logger = new Logger(CpuTopologyService.name);

private topologyCache: { id: number; cores: number[][] }[] | null = null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Remove unused topologyCache field.

The topologyCache field is declared but never read or written. Either implement caching or remove this dead code.

Apply this diff if caching is not needed:

     private readonly logger = new Logger(CpuTopologyService.name);
-
-    private topologyCache: { id: number; cores: number[][] }[] | null = null;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private topologyCache: { id: number; cores: number[][] }[] | null = null;
private readonly logger = new Logger(CpuTopologyService.name);
🤖 Prompt for AI Agents
In api/src/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.ts around
line 10, remove the unused topologyCache field declaration (private
topologyCache: { id: number; cores: number[][] }[] | null = null;) since it is
never read or written; simply delete that line and run the TypeScript
compiler/linter to ensure no references remain and commit the change.


// -----------------------------------------------------------------
// Read static CPU topology, per-package core thread pairs
// -----------------------------------------------------------------
async generateTopology(): Promise<number[][][]> {
const packages: Record<number, number[][]> = {};
const cpuDirs = await readdir('/sys/devices/system/cpu');

for (const dir of cpuDirs) {
if (!/^cpu\d+$/.test(dir)) continue;

const basePath = join('/sys/devices/system/cpu', dir, 'topology');
const pkgFile = join(basePath, 'physical_package_id');
const siblingsFile = join(basePath, 'thread_siblings_list');

try {
const [pkgIdStr, siblingsStrRaw] = await Promise.all([
readFile(pkgFile, 'utf8'),
readFile(siblingsFile, 'utf8'),
]);

const pkgId = parseInt(pkgIdStr.trim(), 10);

// expand ranges
const siblings = siblingsStrRaw
.trim()
.replace(/(\d+)-(\d+)/g, (_, start, end) =>
Array.from(
{ length: parseInt(end) - parseInt(start) + 1 },
(_, i) => parseInt(start) + i
).join(',')
)
.split(',')
.map((n) => parseInt(n, 10));

if (!packages[pkgId]) packages[pkgId] = [];
if (!packages[pkgId].some((arr) => arr.join(',') === siblings.join(','))) {
packages[pkgId].push(siblings);
}
} catch (err) {
console.warn('Topology read error for', dir, err);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use logger instance instead of console.warn.

Line 51 uses console.warn while lines 90, 97, 101, and 170 correctly use this.logger.warn. This inconsistency should be resolved.

Apply this diff:

             } catch (err) {
-                console.warn('Topology read error for', dir, err);
+                this.logger.warn('Topology read error for', dir, err);
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
console.warn('Topology read error for', dir, err);
} catch (err) {
this.logger.warn('Topology read error for', dir, err);
}
🤖 Prompt for AI Agents
In api/src/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.ts around
line 51, replace the console.warn call with this.logger.warn to match the other
warning usages; ensure the method uses the class logger (this.logger.warn) and
pass the same message and variables (dir and err) to the logger so it stays
consistent with lines 90, 97, 101, and 170.

}
}
// Sort cores within each package, and packages by their lowest core index
const result = Object.entries(packages)
.sort((a, b) => a[1][0][0] - b[1][0][0]) // sort packages by first CPU ID
.map(
([pkgId, cores]) => cores.sort((a, b) => a[0] - b[0]) // sort cores within package
);

return result;
}

// -----------------------------------------------------------------
// Dynamic telemetry (power + temperature)
// -----------------------------------------------------------------
private async getPackageTemps(): Promise<number[]> {
const temps: number[] = [];
try {
const hwmons = await readdir('/sys/class/hwmon');
for (const hwmon of hwmons) {
const path = join('/sys/class/hwmon', hwmon);
try {
const label = (await readFile(join(path, 'name'), 'utf8')).trim();
if (/coretemp|k10temp|zenpower/i.test(label)) {
const files = await readdir(path);
for (const f of files) {
if (f.startsWith('temp') && f.endsWith('_label')) {
const lbl = (await readFile(join(path, f), 'utf8')).trim().toLowerCase();
if (
lbl.includes('package id') ||
lbl.includes('tctl') ||
lbl.includes('tdie')
) {
const inputFile = join(path, f.replace('_label', '_input'));
try {
const raw = await readFile(inputFile, 'utf8');
temps.push(parseInt(raw.trim(), 10) / 1000);
} catch (err) {
this.logger.warn('Failed to read file', err);
}
}
}
}
}
} catch (err) {
this.logger.warn('Failed to read file', err);
}
}
} catch (err) {
this.logger.warn('Failed to read file', err);
}
return temps;
}

private async getPackagePower(): Promise<Record<number, Record<string, number>>> {
const basePath = '/sys/class/powercap';
const prefixes = ['intel-rapl', 'intel-rapl-mmio', 'amd-rapl'];
const raplPaths: string[] = [];

try {
const entries = await readdir(basePath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isSymbolicLink() && prefixes.some((p) => entry.name.startsWith(p))) {
if (/:\d+:\d+/.test(entry.name)) continue;
raplPaths.push(join(basePath, entry.name));
}
}
} catch {
return {};
}

if (!raplPaths.length) return {};

const readEnergy = async (p: string): Promise<number | null> => {
try {
await access(join(p, 'energy_uj'), fsConstants.R_OK);
const raw = await readFile(join(p, 'energy_uj'), 'utf8');
return parseInt(raw.trim(), 10);
} catch {
return null;
}
};

const prevE = new Map<string, number>();
const prevT = new Map<string, bigint>();

for (const p of raplPaths) {
const val = await readEnergy(p);
if (val !== null) {
prevE.set(p, val);
prevT.set(p, process.hrtime.bigint());
}
}

await new Promise((res) => setTimeout(res, 100));

const results: Record<number, Record<string, number>> = {};

for (const p of raplPaths) {
const now = await readEnergy(p);
if (now === null) continue;

const prevVal = prevE.get(p);
const prevTime = prevT.get(p);
if (prevVal === undefined || prevTime === undefined) continue;

const diffE = now - prevVal;
const diffT = Number(process.hrtime.bigint() - prevTime);
if (diffT <= 0 || diffE < 0) continue;

const watts = (diffE * 1e-6) / (diffT * 1e-9);
const powerW = Math.round(watts * 100) / 100;

const nameFile = join(p, 'name');
let label = 'package';
try {
label = (await readFile(nameFile, 'utf8')).trim();
} catch (err) {
this.logger.warn('Failed to read file', err);
}

const pkgMatch = label.match(/package-(\d+)/i);
const pkgId = pkgMatch ? Number(pkgMatch[1]) : 0;

if (!results[pkgId]) results[pkgId] = {};
results[pkgId][label] = powerW;
}

for (const domains of Object.values(results)) {
const total = Object.values(domains).reduce((a, b) => a + b, 0);
(domains as any)['total'] = Math.round(total * 100) / 100;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid type casting; use proper typing instead.

The (domains as any)['total'] cast violates the coding guideline to never use the any type and to avoid type casting.

As per coding guidelines.

Define a proper type for the domains object:

type DomainPower = Record<string, number>;

Then update the function signature and usage:

-    private async getPackagePower(): Promise<Record<number, Record<string, number>>> {
+    private async getPackagePower(): Promise<Record<number, DomainPower>> {
         // ... existing code ...
         
         for (const domains of Object.values(results)) {
             const total = Object.values(domains).reduce((a, b) => a + b, 0);
-            (domains as any)['total'] = Math.round(total * 100) / 100;
+            domains['total'] = Math.round(total * 100) / 100;
         }
🤖 Prompt for AI Agents
In api/src/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.ts around
line 182, avoid the (domains as any)['total'] cast: define a proper type (e.g.
type DomainPower = Record<string, number>) and use it for the domains
variable/function signature so domains is typed as DomainPower; then assign
domains['total'] = Math.round(total * 100) / 100 without any casting and update
any callers to pass/expect DomainPower.

}

return results;
}

async generateTelemetry(): Promise<{ id: number; power: number; temp: number }[]> {
const temps = await this.getPackageTemps();
const powerData = await this.getPackagePower();

const maxPkg = Math.max(temps.length - 1, ...Object.keys(powerData).map(Number), 0);

const result: {
id: number;
power: number;
temp: number;
}[] = [];

for (let pkgId = 0; pkgId <= maxPkg; pkgId++) {
const entry = powerData[pkgId] ?? {};
result.push({
id: pkgId,
power: entry.total ?? -1,
temp: temps[pkgId] ?? -1,
});
}

return result;
}
}
20 changes: 20 additions & 0 deletions api/src/unraid-api/graph/resolvers/info/cpu/cpu.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ export class CpuLoad {
percentSteal!: number;
}

@ObjectType()
export class CpuPackages {
@Field(() => Float, { description: 'Total CPU package power draw (W)' })
totalPower?: number;

@Field(() => [Float], { description: 'Power draw per package (W)' })
power?: number[];

@Field(() => [Float], { description: 'Temperature per package (°C)' })
temp?: number[];
}
Comment on lines +42 to +52
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add description to ObjectType and verify nullability.

The CpuPackages type is missing a description in the @ObjectType decorator. Additionally, verify the nullability semantics: the fields are optional (?) in TypeScript but appear as required in parts of the generated GraphQL schema (line 1404-1413 in generated-schema.graphql), creating an inconsistency.

Apply this diff to add a description:

-@ObjectType()
+@ObjectType({ description: 'CPU package telemetry data' })
 export class CpuPackages {

Additionally, confirm whether these fields should be optional or required. If telemetry collection can fail, consider keeping them optional and ensuring the GraphQL schema reflects this.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@ObjectType()
export class CpuPackages {
@Field(() => Float, { description: 'Total CPU package power draw (W)' })
totalPower?: number;
@Field(() => [Float], { description: 'Power draw per package (W)' })
power?: number[];
@Field(() => [Float], { description: 'Temperature per package (°C)' })
temp?: number[];
}
@ObjectType({ description: 'CPU package telemetry data' })
export class CpuPackages {
@Field(() => Float, { description: 'Total CPU package power draw (W)' })
totalPower?: number;
@Field(() => [Float], { description: 'Power draw per package (W)' })
power?: number[];
@Field(() => [Float], { description: 'Temperature per package (°C)' })
temp?: number[];
}
🤖 Prompt for AI Agents
In api/src/unraid-api/graph/resolvers/info/cpu/cpu.model.ts around lines 42 to
52, add a description to the @ObjectType decorator (e.g., "Per-package CPU
telemetry: power and temperature") and make the GraphQL nullability match the
TypeScript optional fields by updating each @Field to include nullable: true
(keeping the TypeScript ? on totalPower, power, temp). This ensures the
generated schema shows these fields as optional when telemetry can fail.


@ObjectType({ implements: () => Node })
export class CpuUtilization extends Node {
@Field(() => Float, { description: 'Total CPU load in percent' })
Expand Down Expand Up @@ -100,4 +112,12 @@ export class InfoCpu extends Node {

@Field(() => [String], { nullable: true, description: 'CPU feature flags' })
flags?: string[];

@Field(() => [[[Int]]], {
description: 'Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]]',
})
topology!: number[][][];

@Field(() => CpuPackages)
packages!: CpuPackages;
}
10 changes: 10 additions & 0 deletions api/src/unraid-api/graph/resolvers/info/cpu/cpu.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';

import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js';
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';

@Module({
providers: [CpuService, CpuTopologyService],
exports: [CpuService, CpuTopologyService],
})
export class CpuModule {}
36 changes: 35 additions & 1 deletion api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js';
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';

vi.mock('systeminformation', () => ({
Expand Down Expand Up @@ -88,9 +89,27 @@ vi.mock('systeminformation', () => ({

describe('CpuService', () => {
let service: CpuService;
let cpuTopologyService: CpuTopologyService;

beforeEach(() => {
service = new CpuService();
cpuTopologyService = {
generateTopology: vi.fn().mockResolvedValue([
[
[0, 1],
[2, 3],
],
[
[4, 5],
[6, 7],
],
]),
generateTelemetry: vi.fn().mockResolvedValue([
{ power: 32.5, temp: 45.0 },
{ power: 33.0, temp: 46.0 },
]),
} as any;

service = new CpuService(cpuTopologyService);
});

describe('generateCpu', () => {
Expand Down Expand Up @@ -121,6 +140,21 @@ describe('CpuService', () => {
l3: 12582912,
},
flags: ['fpu', 'vme', 'de', 'pse', 'tsc', 'msr', 'pae', 'mce', 'cx8'],
packages: {
totalPower: 65.5,
power: [32.5, 33.0],
temp: [45.0, 46.0],
},
topology: [
[
[0, 1],
[2, 3],
],
[
[4, 5],
[6, 7],
],
],
});
});

Expand Down
Loading
Loading