diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 02c3bbb0a..0dfe521f9 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -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! } \ 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..2c65c9749 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.ts @@ -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 { + const packages: Record = {}; + 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 { + 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>> { + 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'); + const parsed = parseInt(raw.trim(), 10); + return Number.isFinite(parsed) ? parsed : null; + } 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 (!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; + } +} 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 cb3414d6a..e0c7a4739 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({ 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; } 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 new file mode 100644 index 000000000..9ab095567 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/cpu/cpu.module.ts @@ -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 {} diff --git a/api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.spec.ts b/api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.spec.ts index e2cc3d352..7d526a325 100644 --- a/api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.spec.ts @@ -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], + ], + ], }); }); 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 e6095b06d..8b5dcb9e8 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 @@ -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 { - 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, }; } diff --git a/api/src/unraid-api/graph/resolvers/info/info.module.ts b/api/src/unraid-api/graph/resolvers/info/info.module.ts index c9684061f..c3bf00b65 100644 --- a/api/src/unraid-api/graph/resolvers/info/info.module.ts +++ b/api/src/unraid-api/graph/resolvers/info/info.module.ts @@ -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, diff --git a/api/src/unraid-api/graph/resolvers/info/info.resolver.integration.spec.ts b/api/src/unraid-api/graph/resolvers/info/info.resolver.integration.spec.ts index e8ae31a70..5073170a5 100644 --- a/api/src/unraid-api/graph/resolvers/info/info.resolver.integration.spec.ts +++ b/api/src/unraid-api/graph/resolvers/info/info.resolver.integration.spec.ts @@ -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, 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 93dbb7ded..f6e195087 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts @@ -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 {} 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 a1da82716..12b899a09 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 @@ -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, diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.spec.ts index f757eb5c5..385277e8c 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.spec.ts @@ -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; + 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), 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 abfff8077..cbd47e86b 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts @@ -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, diff --git a/packages/unraid-shared/deploy.sh b/packages/unraid-shared/deploy.sh new file mode 100755 index 000000000..7576aa158 --- /dev/null +++ b/packages/unraid-shared/deploy.sh @@ -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 " + 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 diff --git a/packages/unraid-shared/package.json b/packages/unraid-shared/package.json index 2abfd3399..9c4205bd5 100644 --- a/packages/unraid-shared/package.json +++ b/packages/unraid-shared/package.json @@ -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. ", diff --git a/packages/unraid-shared/src/pubsub/graphql.pubsub.ts b/packages/unraid-shared/src/pubsub/graphql.pubsub.ts index 602e57e89..1470b944e 100644 --- a/packages/unraid-shared/src/pubsub/graphql.pubsub.ts +++ b/packages/unraid-shared/src/pubsub/graphql.pubsub.ts @@ -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",