Add topology and temps

Signed-off-by: Simon Fairweather <simon.n.fairweather@gmail.com>
This commit is contained in:
Simon Fairweather
2025-10-18 22:49:51 +01:00
committed by Pujit Mehrotra
parent 3e2b1eff18
commit e78819d9b7
8 changed files with 317 additions and 9 deletions

View File

@@ -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!
}

View File

@@ -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<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);
}
}
// 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 [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;
}
}

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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<InfoCpu> {
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,
};
}

View File

@@ -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';

View File

@@ -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,

View File

@@ -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,