-
Notifications
You must be signed in to change notification settings - Fork 19
[testing, don't merge] feat: add cpu power query & subscription #1763
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ebe3910
a149440
d512d5e
82bc900
13f1065
8c3dd18
dd7882f
f2df4bd
42a32f2
0eecfb8
1c749e2
ea5c7f3
db52e01
cd25e42
2db3bf5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Remove unused topologyCache field. The 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||
|
|
||||||||||
| // ----------------------------------------------------------------- | ||||||||||
| // 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); | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use logger instance instead of console.warn. Line 51 uses Apply this diff: } catch (err) {
- console.warn('Topology read error for', dir, err);
+ this.logger.warn('Topology read error for', dir, err);
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| } | ||||||||||
| } | ||||||||||
| // 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; | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid type casting; use proper typing instead. The 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 |
||||||||||
| } | ||||||||||
|
|
||||||||||
| 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; | ||||||||||
| } | ||||||||||
| } | ||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add description to ObjectType and verify nullability. The 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| @ObjectType({ implements: () => Node }) | ||||||||||||||||||||||||||||||||||||||||||||||
| export class CpuUtilization extends Node { | ||||||||||||||||||||||||||||||||||||||||||||||
| @Field(() => Float, { description: 'Total CPU load in percent' }) | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| 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 {} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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,
CpuPackagesimplementsNode(line 1404), which requires anid: 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:
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