mirror of
https://github.com/unraid/api.git
synced 2025-12-31 21:49:57 -06:00
feat: add cpu power query & subscription (#1745)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Per-package CPU power and temperature displayed in hardware info (total and per-package values). * CPU package topology (cores/threads per package) included in CPU info. * Real-time per-package CPU telemetry exposed via a new system metrics subscription. * **Chores** * Added an automated deployment script and npm deploy script for the shared package. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Simon Fairweather <simon.n.fairweather@gmail.com> Co-authored-by: Simon Fairweather <simon.n.fairweather@gmail.com> Co-authored-by: SimonFair <39065407+SimonFair@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -1391,6 +1391,19 @@ type CpuLoad {
|
||||
percentSteal: Float!
|
||||
}
|
||||
|
||||
type CpuPackages implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""Total CPU package power draw (W)"""
|
||||
totalPower: Float!
|
||||
|
||||
"""Power draw per package (W)"""
|
||||
power: [Float!]!
|
||||
|
||||
"""Temperature per package (°C)"""
|
||||
temp: [Float!]!
|
||||
}
|
||||
|
||||
type CpuUtilization implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
@@ -1454,6 +1467,12 @@ type InfoCpu implements Node {
|
||||
|
||||
"""CPU feature flags"""
|
||||
flags: [String!]
|
||||
|
||||
"""
|
||||
Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]]
|
||||
"""
|
||||
topology: [[[Int!]!]!]!
|
||||
packages: CpuPackages!
|
||||
}
|
||||
|
||||
type MemoryLayout implements Node {
|
||||
@@ -2642,6 +2661,7 @@ type Subscription {
|
||||
arraySubscription: UnraidArray!
|
||||
logFile(path: String!): LogFileContent!
|
||||
systemMetricsCpu: CpuUtilization!
|
||||
systemMetricsCpuTelemetry: CpuPackages!
|
||||
systemMetricsMemory: MemoryUtilization!
|
||||
upsUpdates: UPSDevice!
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
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);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Read static CPU topology, per-package core thread pairs
|
||||
// -----------------------------------------------------------------
|
||||
async generateTopology(): Promise<number[][][]> {
|
||||
const packages: Record<number, number[][]> = {};
|
||||
let cpuDirs: string[];
|
||||
|
||||
try {
|
||||
cpuDirs = await readdir('/sys/devices/system/cpu');
|
||||
} catch (err) {
|
||||
this.logger.warn('CPU topology unavailable, /sys/devices/system/cpu not accessible');
|
||||
return [];
|
||||
}
|
||||
|
||||
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) {
|
||||
this.logger.warn(err, `Topology read error for ${dir}`);
|
||||
}
|
||||
}
|
||||
// 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');
|
||||
const parsed = parseInt(raw.trim(), 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
temps.push(parsed / 1000);
|
||||
} else {
|
||||
this.logger.warn(`Invalid temperature value: ${raw.trim()}`);
|
||||
}
|
||||
} 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');
|
||||
const parsed = parseInt(raw.trim(), 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
} 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 (!Number.isFinite(diffE) || !Number.isFinite(diffT)) {
|
||||
this.logger.warn(`Non-finite energy/time diff for ${p}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (diffT <= 0 || diffE < 0) continue;
|
||||
|
||||
const watts = (diffE * 1e-6) / (diffT * 1e-9);
|
||||
const powerW = Math.round(watts * 100) / 100;
|
||||
|
||||
if (!Number.isFinite(powerW)) {
|
||||
this.logger.warn(`Non-finite power value for ${p}: ${watts}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
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['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: {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,18 @@ export class CpuLoad {
|
||||
percentSteal!: number;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class CpuPackages extends Node {
|
||||
@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' })
|
||||
@@ -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
api/src/unraid-api/graph/resolvers/info/cpu/cpu.module.ts
Normal file
10
api/src/unraid-api/graph/resolvers/info/cpu/cpu.module.ts
Normal file
@@ -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 {}
|
||||
@@ -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', () => ({
|
||||
@@ -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 unknown as CpuTopologyService;
|
||||
|
||||
service = new CpuService(cpuTopologyService);
|
||||
});
|
||||
|
||||
describe('generateCpu', () => {
|
||||
@@ -121,6 +140,22 @@ describe('CpuService', () => {
|
||||
l3: 12582912,
|
||||
},
|
||||
flags: ['fpu', 'vme', 'de', 'pse', 'tsc', 'msr', 'pae', 'mce', 'cx8'],
|
||||
packages: {
|
||||
id: 'info/cpu/packages',
|
||||
totalPower: 65.5,
|
||||
power: [32.5, 33.0],
|
||||
temp: [45.0, 46.0],
|
||||
},
|
||||
topology: [
|
||||
[
|
||||
[0, 1],
|
||||
[2, 3],
|
||||
],
|
||||
[
|
||||
[4, 5],
|
||||
[6, 7],
|
||||
],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,25 +2,56 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { cpu, cpuFlags, currentLoad } from 'systeminformation';
|
||||
|
||||
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 {
|
||||
constructor(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(() => []);
|
||||
|
||||
// Gather telemetry
|
||||
const packageList = await this.cpuTopologyService.generateTelemetry();
|
||||
const topology = await this.cpuTopologyService.generateTopology();
|
||||
|
||||
// Compute total power (2 decimals)
|
||||
const totalPower = Number(
|
||||
packageList
|
||||
.map((pkg) => pkg.power)
|
||||
.filter((power) => power >= 0)
|
||||
.reduce((sum, power) => sum + power, 0)
|
||||
.toFixed(2)
|
||||
);
|
||||
|
||||
// Build CpuPackages object
|
||||
const packages: CpuPackages = {
|
||||
id: 'info/cpu/packages',
|
||||
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,
|
||||
stepping: Number(stepping),
|
||||
speedmin: speedMin || -1,
|
||||
speedmax: speedMax || -1,
|
||||
packages,
|
||||
topology,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
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 { DevicesResolver } from '@app/unraid-api/graph/resolvers/info/devices/devices.resolver.js';
|
||||
import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices/devices.service.js';
|
||||
@@ -14,7 +15,7 @@ import { VersionsService } from '@app/unraid-api/graph/resolvers/info/versions/v
|
||||
import { ServicesModule } from '@app/unraid-api/graph/services/services.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule, ServicesModule],
|
||||
imports: [ConfigModule, ServicesModule, CpuModule],
|
||||
providers: [
|
||||
// Main resolver
|
||||
InfoResolver,
|
||||
@@ -25,7 +26,6 @@ import { ServicesModule } from '@app/unraid-api/graph/services/services.module.j
|
||||
CoreVersionsResolver,
|
||||
|
||||
// Services
|
||||
CpuService,
|
||||
MemoryService,
|
||||
DevicesService,
|
||||
OsService,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Test } from '@nestjs/testing';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.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 { DevicesResolver } from '@app/unraid-api/graph/resolvers/info/devices/devices.resolver.js';
|
||||
import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices/devices.service.js';
|
||||
@@ -28,6 +29,7 @@ describe('InfoResolver Integration Tests', () => {
|
||||
InfoResolver,
|
||||
DevicesResolver,
|
||||
CpuService,
|
||||
CpuTopologyService,
|
||||
MemoryService,
|
||||
DevicesService,
|
||||
OsService,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
|
||||
import { CpuModule } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.module.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';
|
||||
import { ServicesModule } from '@app/unraid-api/graph/services/services.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [ServicesModule],
|
||||
providers: [MetricsResolver, CpuService, MemoryService],
|
||||
imports: [ServicesModule, CpuModule],
|
||||
providers: [MetricsResolver, MemoryService],
|
||||
exports: [MetricsResolver],
|
||||
})
|
||||
export class MetricsModule {}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Test } from '@nestjs/testing';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.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';
|
||||
@@ -22,6 +23,7 @@ describe('MetricsResolver Integration Tests', () => {
|
||||
providers: [
|
||||
MetricsResolver,
|
||||
CpuService,
|
||||
CpuTopologyService,
|
||||
MemoryService,
|
||||
SubscriptionTrackerService,
|
||||
SubscriptionHelperService,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Test } from '@nestjs/testing';
|
||||
|
||||
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';
|
||||
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';
|
||||
@@ -18,6 +19,7 @@ describe('MetricsResolver', () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
MetricsResolver,
|
||||
CpuTopologyService,
|
||||
{
|
||||
provide: CpuService,
|
||||
useValue: {
|
||||
@@ -161,8 +163,14 @@ describe('MetricsResolver', () => {
|
||||
registerTopic: vi.fn(),
|
||||
};
|
||||
|
||||
const cpuTopologyServiceMock = {
|
||||
generateTopology: vi.fn(),
|
||||
generateTelemetry: vi.fn().mockResolvedValue([{ id: 0, power: 42.5, temp: 68.3 }]),
|
||||
} satisfies Pick<CpuTopologyService, 'generateTopology' | 'generateTelemetry'>;
|
||||
|
||||
const testModule = new MetricsResolver(
|
||||
cpuService,
|
||||
cpuTopologyServiceMock as unknown as CpuTopologyService,
|
||||
memoryService,
|
||||
subscriptionTracker as any,
|
||||
{} as any
|
||||
@@ -170,7 +178,7 @@ describe('MetricsResolver', () => {
|
||||
|
||||
testModule.onModuleInit();
|
||||
|
||||
expect(subscriptionTracker.registerTopic).toHaveBeenCalledTimes(2);
|
||||
expect(subscriptionTracker.registerTopic).toHaveBeenCalledTimes(3);
|
||||
expect(subscriptionTracker.registerTopic).toHaveBeenCalledWith(
|
||||
'CPU_UTILIZATION',
|
||||
expect.any(Function),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { OnModuleInit } from '@nestjs/common';
|
||||
import { Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { 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, 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';
|
||||
@@ -15,8 +16,10 @@ import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subsc
|
||||
|
||||
@Resolver(() => Metrics)
|
||||
export class MetricsResolver implements OnModuleInit {
|
||||
private readonly logger = new Logger(MetricsResolver.name);
|
||||
constructor(
|
||||
private readonly cpuService: CpuService,
|
||||
private readonly cpuTopologyService: CpuTopologyService,
|
||||
private readonly memoryService: MemoryService,
|
||||
private readonly subscriptionTracker: SubscriptionTrackerService,
|
||||
private readonly subscriptionHelper: SubscriptionHelperService
|
||||
@@ -33,6 +36,38 @@ export class MetricsResolver implements OnModuleInit {
|
||||
1000
|
||||
);
|
||||
|
||||
this.subscriptionTracker.registerTopic(
|
||||
PUBSUB_CHANNEL.CPU_TELEMETRY,
|
||||
async () => {
|
||||
const packageList = (await this.cpuTopologyService.generateTelemetry()) ?? [];
|
||||
|
||||
// Compute total power with 2 decimals
|
||||
const totalPower = Number(
|
||||
packageList
|
||||
.map((pkg) => pkg.power)
|
||||
.filter((power) => power >= 0)
|
||||
.reduce((sum, power) => sum + power, 0)
|
||||
.toFixed(2)
|
||||
);
|
||||
|
||||
const packages: CpuPackages = {
|
||||
id: 'metrics/cpu/packages',
|
||||
totalPower,
|
||||
power: packageList.map((pkg) => pkg.power ?? -1),
|
||||
temp: packageList.map((pkg) => pkg.temp ?? -1),
|
||||
};
|
||||
this.logger.debug(`CPU_TELEMETRY payload: ${JSON.stringify(packages)}`);
|
||||
|
||||
// Publish the payload
|
||||
pubsub.publish(PUBSUB_CHANNEL.CPU_TELEMETRY, {
|
||||
systemMetricsCpuTelemetry: packages,
|
||||
});
|
||||
|
||||
this.logger.debug(`CPU_TELEMETRY payload2: ${JSON.stringify(packages)}`);
|
||||
},
|
||||
5000
|
||||
);
|
||||
|
||||
// Register memory polling with 2 second interval
|
||||
this.subscriptionTracker.registerTopic(
|
||||
PUBSUB_CHANNEL.MEMORY_UTILIZATION,
|
||||
@@ -77,6 +112,18 @@ export class MetricsResolver implements OnModuleInit {
|
||||
return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION);
|
||||
}
|
||||
|
||||
@Subscription(() => CpuPackages, {
|
||||
name: 'systemMetricsCpuTelemetry',
|
||||
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,
|
||||
|
||||
68
packages/unraid-shared/deploy.sh
Executable file
68
packages/unraid-shared/deploy.sh
Executable file
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Arguments
|
||||
# $1: SSH server name (required)
|
||||
|
||||
# Check if the server name is provided
|
||||
if [[ -z "$1" ]]; then
|
||||
echo "Error: SSH server name is required."
|
||||
echo "Usage: $0 <server_name>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Set server name from command-line argument
|
||||
server_name="$1"
|
||||
|
||||
# Build the package
|
||||
echo "Building unraid-shared package..."
|
||||
pnpm build
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Build failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Source directory path
|
||||
source_directory="./dist"
|
||||
|
||||
# Check if dist directory exists
|
||||
if [ ! -d "$source_directory" ]; then
|
||||
echo "The dist directory does not exist after build!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Destination directory path - deploy to node_modules/@unraid/shared/dist
|
||||
destination_directory="/usr/local/unraid-api/node_modules/@unraid/shared"
|
||||
|
||||
# Create destination directory on remote server
|
||||
ssh root@"${server_name}" "mkdir -p $destination_directory"
|
||||
|
||||
# Replace the value inside the rsync command with the user's input
|
||||
rsync_command="rsync -avz --delete --progress --stats -e ssh \"$source_directory/\" \"root@${server_name}:$destination_directory/\""
|
||||
|
||||
echo "Executing the following command:"
|
||||
echo "$rsync_command"
|
||||
|
||||
# Execute the rsync command and capture the exit code
|
||||
eval "$rsync_command"
|
||||
exit_code=$?
|
||||
|
||||
# Chown the directory
|
||||
ssh root@"${server_name}" "chown -R root:root /usr/local/unraid-api/node_modules/@unraid/"
|
||||
|
||||
# Run unraid-api restart on remote host
|
||||
ssh root@"${server_name}" 'INTROSPECTION=true LOG_LEVEL=trace unraid-api restart'
|
||||
|
||||
# Play built-in sound based on the operating system
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
# macOS
|
||||
afplay /System/Library/Sounds/Glass.aiff
|
||||
elif [[ "$OSTYPE" == "linux-gnu" ]]; then
|
||||
# Linux
|
||||
paplay /usr/share/sounds/freedesktop/stereo/complete.oga
|
||||
elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" ]]; then
|
||||
# Windows
|
||||
powershell.exe -c "(New-Object Media.SoundPlayer 'C:\Windows\Media\Windows Default.wav').PlaySync()"
|
||||
fi
|
||||
|
||||
# Exit with the rsync command's exit code
|
||||
exit $exit_code
|
||||
@@ -21,7 +21,8 @@
|
||||
"build": "rimraf dist && tsc --project tsconfig.build.json",
|
||||
"prepare": "npm run build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"unraid:deploy": "./deploy.sh"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Lime Technology, Inc. <unraid.net>",
|
||||
|
||||
@@ -5,6 +5,7 @@ export const GRAPHQL_PUBSUB_TOKEN = "GRAPHQL_PUBSUB";
|
||||
export enum GRAPHQL_PUBSUB_CHANNEL {
|
||||
ARRAY = "ARRAY",
|
||||
CPU_UTILIZATION = "CPU_UTILIZATION",
|
||||
CPU_TELEMETRY = "CPU_TELEMETRY",
|
||||
DASHBOARD = "DASHBOARD",
|
||||
DISPLAY = "DISPLAY",
|
||||
INFO = "INFO",
|
||||
|
||||
Reference in New Issue
Block a user