diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 03df961cd..85ed9bc2f 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -1411,6 +1411,17 @@ type CpuPower implements Node { coresPower: [Float!] } +type CpuPackages implements Node { + """Total CPU package power draw (W)""" + totalpower: number + + """Power draw per package (W)""" + power: [Float!] + + """description: 'Temperature per package (°C)""" + temp: [Float!] +} + type InfoCpu implements Node { id: PrefixedID! @@ -2656,6 +2667,7 @@ type Subscription { logFile(path: String!): LogFileContent! systemMetricsCpu: CpuUtilization! systemMetricsCpuPower: CpuPower! + systemMetricsCpuTelemetry: CpuPackages! systemMetricsMemory: MemoryUtilization! upsUpdates: UPSDevice! } \ No newline at end of file diff --git a/api/src/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.ts b/api/src/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.ts new file mode 100644 index 000000000..4b71fc8ac --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.ts @@ -0,0 +1,208 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { promises as fs } from 'fs'; +import { join } from 'path'; + +const { readdir, readFile, access } = fs; +const { constants: fsConstants } = fs; + +@Injectable() +export class CpuTopologyService { + private readonly logger = new Logger(CpuTopologyService.name); + + private topologyCache: { id: number; cores: number[][] }[] | null = null; + + // ----------------------------------------------------------------- + // Read static CPU topology, per-package core thread pairs + // ----------------------------------------------------------------- + async generateTopology(): Promise { + const packages: Record = {}; + 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); + } + } + // 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 { + 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>> { + 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 => { + 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(); + const prevT = new Map(); + + 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> = {}; + + 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 [pkgId, domains] of Object.entries(results)) { + const total = Object.values(domains).reduce((a, b) => a + b, 0); + (domains as any)['total'] = Math.round(total * 100) / 100; + } + + 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 = []; + + 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; + } +} diff --git a/api/src/unraid-api/graph/resolvers/info/cpu/cpu.model.ts b/api/src/unraid-api/graph/resolvers/info/cpu/cpu.model.ts index 9f4715a31..ca4faa671 100644 --- a/api/src/unraid-api/graph/resolvers/info/cpu/cpu.model.ts +++ b/api/src/unraid-api/graph/resolvers/info/cpu/cpu.model.ts @@ -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[]; +} + @ObjectType({ implements: () => Node }) export class CpuUtilization extends Node { @Field(() => Float, { description: 'Total CPU load in percent' }) @@ -115,4 +127,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; } diff --git a/api/src/unraid-api/graph/resolvers/info/cpu/cpu.module.ts b/api/src/unraid-api/graph/resolvers/info/cpu/cpu.module.ts index d645c57d5..9a5620c00 100644 --- a/api/src/unraid-api/graph/resolvers/info/cpu/cpu.module.ts +++ b/api/src/unraid-api/graph/resolvers/info/cpu/cpu.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { CpuPowerService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-power.service.js'; +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, CpuPowerService], - exports: [CpuService, CpuPowerService], + providers: [CpuService, CpuPowerService, CpuTopologyService], + exports: [CpuService, CpuPowerService, CpuTopologyService], }) export class CpuModule {} diff --git a/api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.ts b/api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.ts index d3ca6d490..d9814ed2a 100644 --- a/api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.ts +++ b/api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.ts @@ -6,30 +6,54 @@ import { basename, join } from 'node:path'; import { cpu, cpuFlags, currentLoad } from 'systeminformation'; import { CpuPowerService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-power.service.js'; -import { CpuUtilization, InfoCpu } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js'; +import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js'; +import { + CpuPackages, + CpuUtilization, + InfoCpu, +} from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js'; @Injectable() export class CpuService { private readonly logger = new Logger(CpuService.name); - constructor(private readonly cpuPowerService: CpuPowerService) {} + constructor( + private readonly cpuPowerService: CpuPowerService, + private readonly cpuTopologyService: CpuTopologyService + ) {} async generateCpu(): Promise { - const { cores, physicalCores, speedMin, speedMax, stepping, ...rest } = await cpu(); + const { cores, physicalCores, speedMin, speedMax, stepping, processors, ...rest } = await cpu(); const flags = await cpuFlags() - .then((flags) => flags.split(' ')) + .then((f) => f.split(' ')) .catch(() => []); + const packageList = await this.cpuTopologyService.generateTelemetry(); + const topology = await this.cpuTopologyService.generateTopology(); + + // Compute total power + const totalpower = + Math.round(packageList.reduce((sum, pkg) => sum + (pkg.power ?? 0), 0) * 100) / 100; + + // Build packages object \u2014 plain object matching CpuPackages GraphQL type + const packages: CpuPackages = { + totalpower, + power: packageList.map((pkg) => pkg.power ?? -1), + temp: packageList.map((pkg) => pkg.temp ?? -1), + }; + return { id: 'info/cpu', ...rest, cores: physicalCores, threads: cores, + processors, flags, - power: await this.cpuPowerService.generateCpuPower(), stepping: Number(stepping), speedmin: speedMin || -1, speedmax: speedMax || -1, + packages, + topology, }; } diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts index bc5cd7fed..15633c3a0 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; -import { CpuPowerService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-power.service.js'; +import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js'; import { CpuModule } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.module.js'; import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js'; import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js'; diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts index 7deb1b1a0..911e84d3f 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; import { CpuPowerService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-power.service.js'; +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'; import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js'; import { MetricsResolver } from '@app/unraid-api/graph/resolvers/metrics/metrics.resolver.js'; @@ -24,6 +25,7 @@ describe('MetricsResolver Integration Tests', () => { MetricsResolver, CpuService, CpuPowerService, + CpuTopologyService, MemoryService, SubscriptionTrackerService, SubscriptionHelperService, diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts index 392c8b892..39a5addbc 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts @@ -6,7 +6,12 @@ import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; import { CpuPowerService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-power.service.js'; -import { CpuPower, CpuUtilization } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js'; +import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js'; +import { + CpuPackages, + CpuPower, + CpuUtilization, +} from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js'; import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js'; import { MemoryUtilization } from '@app/unraid-api/graph/resolvers/info/memory/memory.model.js'; import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js'; @@ -19,6 +24,7 @@ export class MetricsResolver implements OnModuleInit { constructor( private readonly cpuService: CpuService, private readonly cpuPowerService: CpuPowerService, + private readonly cpuTopologyService: CpuTopologyService, private readonly memoryService: MemoryService, private readonly subscriptionTracker: SubscriptionTrackerService, private readonly subscriptionHelper: SubscriptionHelperService @@ -34,6 +40,29 @@ export class MetricsResolver implements OnModuleInit { }, 1000 ); + // Register CPU power polling with 5 second interval + this.subscriptionTracker.registerTopic( + PUBSUB_CHANNEL.CPU_TELEMETRY, + async () => { + // --- Gather telemetry --- + const packageList = await this.cpuTopologyService.generateTelemetry(); + + // --- Compute total power with 2 decimals --- + const totalpower = Number( + packageList.reduce((sum, pkg) => sum + (pkg.power ?? 0), 0).toFixed(2) + ); + + // --- Build CpuPackages object --- + const packages: CpuPackages = { + totalpower, + power: packageList.map((pkg) => pkg.power ?? -1), + temp: packageList.map((pkg) => pkg.temp ?? -1), + }; + pubsub.publish(PUBSUB_CHANNEL.CPU_TELEMETRY, { systemMetricsCpuTelemetry: packages }); + }, + 5000 + ); + // Register CPU power polling with 1 second interval this.subscriptionTracker.registerTopic( PUBSUB_CHANNEL.CPU_POWER, @@ -100,6 +129,18 @@ export class MetricsResolver implements OnModuleInit { return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.CPU_POWER); } + @Subscription(() => CpuPackages, { + name: 'systemMetricsCpuTelemtry', + resolve: (value) => value.systemMetricsCpuTelemetry, + }) + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) + public async systemMetricsCpuTelemetrySubscription() { + return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.CPU_TELEMETRY); + } + @Subscription(() => MemoryUtilization, { name: 'systemMetricsMemory', resolve: (value) => value.systemMetricsMemory,