mirror of
https://github.com/unraid/api.git
synced 2026-01-05 08:00:33 -06:00
Add topology and temps
Signed-off-by: Simon Fairweather <simon.n.fairweather@gmail.com>
This commit is contained in:
committed by
Pujit Mehrotra
parent
3e2b1eff18
commit
e78819d9b7
@@ -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!
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user