Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions api/generated-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -1401,6 +1401,27 @@ type CpuUtilization implements Node {
cpus: [CpuLoad!]!
}

type CpuPower implements Node {
id: PrefixedID!

"""CPU power in watts"""
totalPower: Float

"""CPU power in watts for each physical processor"""
coresPower: [Float!]
}

Comment on lines +1404 to +1413
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Regenerate client GraphQL types to include CpuPower and systemMetricsCpuPower.

Schema adds CpuPower and systemMetricsCpuPower, but client types in snippets don’t include them. Regenerate CLI/web GraphQL typings to avoid runtime/type errors.

Run to verify and locate missing/updated references:

Also applies to: 1459-1461, 2658-2658


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Searching for systemMetricsCpuPower in repo..."
rg -n "systemMetricsCpuPower" -g "!**/node_modules/**" -C2 || true

echo
echo "Checking generated Subscription types in known clients..."
rg -nP "(?s)export\s+type\s+Subscription\s*=\s*{[^}]*}" api/src/unraid-api/cli/generated/graphql.ts -C2 || true
rg -nP "(?s)export\s+type\s+Subscription\s*=\s*{[^}]*}" web/composables/gql/graphql.ts -C2 || true

Length of output: 254


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locating all graphql.ts files..."
fd graphql.ts

echo
echo "Searching for systemMetricsCpuPower in TS files..."
rg -n "systemMetricsCpuPower" -t ts -C2 || true

echo
echo "Searching for CpuPower in TS files..."
rg -n "CpuPower" -t ts -C2 || true

echo
echo "Searching for Subscription type definitions in TS files..."
rg -nP "export\s+type\s+Subscription\s*=\s*{" -t ts -C2 || true

Length of output: 17850


Regenerate GraphQL client typings for CLI, plugin, and web: Subscription type in generated outputs (api/src/unraid-api/cli/generated/graphql.ts, packages/unraid-api-plugin-connect/src/graphql/generated/client/graphql.ts, web/composables/gql/graphql.ts, web/src/composables/gql/graphql.ts) is missing the new systemMetricsCpuPower and CpuPower fields. Rerun your GraphQL code generation to include them.

🤖 Prompt for AI Agents
In api/generated-schema.graphql around lines 1404 to 1413 the new CpuPower type
and related fields (systemMetricsCpuPower and CpuPower) were added but the
generated GraphQL client typings for CLI, plugin, and web were not updated;
rerun the project's GraphQL code generation (the repo's graphql-codegen /
generate scripts) for the following targets:
api/src/unraid-api/cli/generated/graphql.ts,
packages/unraid-api-plugin-connect/src/graphql/generated/client/graphql.ts,
web/composables/gql/graphql.ts, and web/src/composables/gql/graphql.ts so the
subscription types include systemMetricsCpuPower and CpuPower, then commit the
regenerated files.

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!]
}
Comment on lines +1414 to +1423
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Schema blocker: invalid type and missing id in CpuPackages.

  • totalpower: number is invalid GraphQL (should be Float).
  • CpuPackages implements Node but has no id: PrefixedID!.
  • Consider casing consistency: totalpowertotalPower.

Apply these changes in the code-first model (e.g., cpu.model.ts) and regenerate the schema:

 type CpuPackages implements Node {
-  """Total CPU package power draw (W)"""
-  totalpower: number
+  id: PrefixedID!
+  """Total CPU package power draw (W)"""
+  totalPower: Float

   """Power draw per package (W)"""
   power: [Float!]

-  """description: 'Temperature per package (°C)"""
+  """Temperature per package (°C)"""
   temp: [Float!]
 }

Then re-run codegen to fix the pipeline error.

Verification:


🏁 Script executed:

#!/bin/bash
# Fail build if invalid scalar 'number' remains in schema
rg -n ':[[:space:]]*number\b' api/generated-schema.graphql && exit 1 || echo "OK: no 'number' scalars"

# Regenerate (project-specific)
pnpm run generate || npm run generate || yarn generate

Length of output: 25


🏁 Script executed:

#!/bin/bash
# Get full CpuPackages type definition with context
rg -n -A 15 'type CpuPackages' api/generated-schema.graphql

# Check if InfoCpu type exists and its fields
rg -n -A 10 'type InfoCpu' api/generated-schema.graphql

# Search for code-first model files (TypeScript/TS)
fd -e ts -e js --type f | xargs rg -l 'CpuPackages|class.*Cpu' | head -20

Length of output: 1000


🏁 Script executed:

#!/bin/bash
# Get full InfoCpu definition (may span multiple lines)
rg -n -A 30 'type InfoCpu implements Node' api/generated-schema.graphql | head -50

# Inspect the code-first model to see source definitions
cat api/src/unraid-api/graph/resolvers/info/cpu/cpu.model.ts

Length of output: 5068


Schema blocker: invalid number scalar and missing id in CpuPackages type.

The code-first model (api/src/unraid-api/graph/resolvers/info/cpu/cpu.model.ts) has structural issues that cause invalid schema generation:

  • Line 1416 in schema: totalpower: number is invalid GraphQL (must be Float). The model uses @Field(() => Float) but TypeScript field is number — change to match decorator intent.
  • CpuPackages implements Node in schema but lacks id: PrefixedID! field. Model must extend Node or include the id field.
  • Casing inconsistency: totalpower should be totalPower (camelCase convention).
  • Line 1421 schema docstring malformed: """description: 'Temperature... should be """Temperature....

Update the model:

-@ObjectType()
+@ObjectType({ implements: () => Node })
-export class CpuPackages {
-    @Field(() => Float, { description: 'Total CPU package power draw (W)' })
-    totalpower?: number;
+export class CpuPackages extends Node {
+    @Field(() => Float, { description: 'Total CPU package power draw (W)' })
+    totalPower?: number;

Then regenerate the schema.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In api/generated-schema.graphql around lines 1414 to 1423 the CpuPackages type
is invalid: change the code-first model
(api/src/unraid-api/graph/resolvers/info/cpu/cpu.model.ts) so the class
implements Node (or add an id: PrefixedID! field with @Field(() => PrefixedID)
id: PrefixedID), rename the field from totalpower to totalPower to follow
camelCase, ensure the field uses @Field(() => Float) with a TypeScript number
type so the generated schema emits Float (not the literal "number"), and fix the
temp docstring to a plain description ("""Temperature per package (°C)""");
after these edits regenerate the schema.


type InfoCpu implements Node {
id: PrefixedID!

Expand Down Expand Up @@ -1446,6 +1467,9 @@ type InfoCpu implements Node {
"""Number of physical processors"""
processors: Int

"""CPU power information"""
power: CpuPower

Comment on lines +1470 to +1472
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Add missing fields to InfoCpu or remove them from resolver.

CpuService.generateCpu() returns packages and topology, but schema shows only power. Add fields to the model so they appear here, or stop returning them.

Proposed additions in the model:

 type InfoCpu implements Node {
   ...
   """CPU power information"""
   power: CpuPower
+  """Per-package power/temperature telemetry"""
+  packages: CpuPackages
+  """Per-package core/thread groupings"""
+  topology: [[[Int!]!]!]
   ...
 }

Ensure generateTopology()’s triple-nested shape matches the GraphQL type.


I'll search the codebase to verify the review comment. Let me examine the GraphQL schema and the CpuService implementation.


🏁 Script executed:

#!/bin/bash
# Find and examine the GraphQL schema file
fd -e graphql -e gql | head -20

Length of output: 71


🏁 Script executed:

#!/bin/bash
# Search for CpuService and generateCpu implementation
rg -n "CpuService|generateCpu" --type=ts --type=js -B2 -A5 | head -100

Length of output: 11144


🏁 Script executed:

#!/bin/bash
# Look for InfoCpu type definition in schema files
rg -n "type InfoCpu|interface InfoCpu" --type=graphql -A10 -B2

Length of output: 31


🏁 Script executed:

#!/bin/bash
# Search for topology and packages field definitions
rg -n "topology|packages" --type=graphql -B2 -A2 | head -80

Length of output: 31


🏁 Script executed:

#!/bin/bash
# Look for generateTopology function
rg -n "generateTopology" --type=ts --type=js -B2 -A10

Length of output: 2937


🏁 Script executed:

#!/bin/bash
# Examine the specific line range in schema file if it exists
if [ -f "api/generated-schema.graphql" ]; then
  sed -n '1465,1480p' api/generated-schema.graphql
fi

Length of output: 251


🏁 Script executed:

#!/bin/bash
# Search for CpuPackages type definition
rg -n "type CpuPackages|interface CpuPackages" --type=graphql -B2 -A5

Length of output: 31


🏁 Script executed:

#!/bin/bash
# Look for CpuPower type to understand context
rg -n "type CpuPower" --type=graphql -B2 -A10

Length of output: 31


🏁 Script executed:

#!/bin/bash
# Find all CPU-related service files
fd -i cpu --type=f | grep -E "\.(ts|js|graphql)$" | head -30

Length of output: 421


🏁 Script executed:

#!/bin/bash
# Search for return statements in generateCpu to see what's being returned
rg -n "return.*packages|return.*topology" --type=ts --type=js -B5 -A2

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look for CpuPower definition to understand existing structure
rg -n "CpuPower" --type=graphql -B2 -A8

Length of output: 31


🏁 Script executed:

#!/bin/bash
# Search for InfoCpu in resolver or service files
rg -n "InfoCpu" --type=ts --type=js -B3 -A3 | head -60

Length of output: 4280


🏁 Script executed:

#!/bin/bash
# Look for function that returns cpu data with packages/topology
rg -n "packages.*topology|topology.*packages" --type=ts --type=js -B5 -A5

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check what the current schema structure shows for CPU
rg -n "\"CPU" --type=graphql -B2 -A5 | head -50

Length of output: 31


🏁 Script executed:

#!/bin/bash
# Look for all fields in InfoCpu type
rg -n "type InfoCpu" --type=graphql -A50 | head -80

Length of output: 31


🏁 Script executed:

#!/bin/bash
# Search for topology array definition pattern
rg -n "\[\[\[" --type=graphql -B2 -A2

Length of output: 31


🏁 Script executed:

#!/bin/bash
# Find resolver files for CPU
fd resolver.*cpu|fd cpu.*resolver --type=f | head -20

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look for info.ts or cpu.ts files that might contain service logic
fd -e ts -e js | grep -iE "(info|cpu)" | head -30

Length of output: 1891


🏁 Script executed:

#!/bin/bash
# Search for classes or exported functions related to CpuService
ast-grep --pattern 'class CpuService { $$$ }'

Length of output: 5923


🏁 Script executed:

#!/bin/bash
# Look for object property definitions related to cpu packages and topology
rg -n "packages\s*:|topology\s*:" --type=ts --type=js -B3 -A3 | head -80

Length of output: 3468


🏁 Script executed:

#!/bin/bash
# Check the exact structure being returned from generateCpu
rg -n "generateCpu\s*\(|function generateCpu" --type=ts --type=js -A30 | head -100

Length of output: 9916


🏁 Script executed:

#!/bin/bash
# Look at all type definitions in graphql file
fd -e graphql -exec wc -l {} \; | sort -rn | head -10

Length of output: 137


🏁 Script executed:

#!/bin/bash
# Get line count and examine schema structure
if [ -f "api/generated-schema.graphql" ]; then
  echo "=== Total lines in schema ==="; 
  wc -l api/generated-schema.graphql;
  echo "=== Content around line 1470 ===";
  sed -n '1460,1480p' api/generated-schema.graphql;
fi

Length of output: 423


🏁 Script executed:

#!/bin/bash
# Search for all imports and usages of CpuPower, CpuPackages
rg -n "CpuPower|CpuPackages" --type=graphql --type=ts --type=js -B1 -A1

Length of output: 31


🏁 Script executed:

#!/bin/bash
# Look for any TODO or FIXME comments related to cpu schema
rg -n "TODO|FIXME" --type=graphql --type=ts --type=js | grep -i cpu

Length of output: 31


🏁 Script executed:

#!/bin/bash
# Check file structure to understand path organization
find . -type f -name "*.graphql" -o -name "*cpu*" -o -name "*service*" | grep -v node_modules | head -40

Length of output: 2221


🏁 Script executed:

#!/bin/bash
# Look for schema merge or composition files
fd -e graphql -e schema | grep -iE "(index|merge|compose|generated)" | head -20

Length of output: 71


🏁 Script executed:

#!/bin/bash
# Examine CpuService class methods
rg -n "class CpuService" --type=ts --type=js -A100 | head -150

Length of output: 5923


🏁 Script executed:

#!/bin/bash
# Search for property definitions that might be causing the mismatch
rg -n "power.*:|packages.*:|topology.*:" --type=ts --type=js -B2 -A2 | head -100

Length of output: 18057


🏁 Script executed:

#!/bin/bash
# Look for destructuring or object construction with these fields
rg -n "\{.*power.*packages.*topology|\{.*packages.*topology" --type=ts --type=js -B3 -A3

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Final check: search for all field assignments in context of cpu object construction
rg -n "power:|packages:|topology:" --type=ts --type=js -B5 | head -80

Length of output: 7160


🏁 Script executed:

#!/bin/bash
# Check for any property spread or merge operations
rg -n "\.\.\." --type=ts --type=js | grep -i cpu -A2 -B2 | head -40

Length of output: 1070


🏁 Script executed:

#!/bin/bash
# Look for GraphQL type definitions that match InfoCpu pattern
rg -n "type.*Cpu.*{|interface.*Cpu.*{" --type=graphql -A20 | head -150

Length of output: 124


🏁 Script executed:

#!/bin/bash
# Search for resolver implementation files
fd -type f -name "*.ts" -o -name "*.js" | xargs grep -l "InfoCpu\|CpuService" | head -20

Length of output: 170


🏁 Script executed:

#!/bin/bash
# Get the exact structure around line 1470
if [ -f "api/generated-schema.graphql" ]; then
  nl -ba api/generated-schema.graphql | sed -n '1465,1475p'
fi

Length of output: 40


🏁 Script executed:

#!/bin/bash
# Look for all union or interface implementations
rg -n "implements Node" --type=graphql -B5 -A30 | grep -A30 "InfoCpu"

Length of output: 31


🏁 Script executed:

#!/bin/bash
# Find what entity extends or implements what
rg -n "InfoCpu|CpuInfo|Cpu" --type=graphql | head -50

Length of output: 31


🏁 Script executed:

#!/bin/bash
# Check broader search for node implementations
rg -n "implements Node" --type=graphql | head -30

Length of output: 31


🏁 Script executed:

#!/bin/bash
# List all files in api directory
ls -la api/ 2>/dev/null | head -30

Length of output: 1861


🏁 Script executed:

#!/bin/bash
# Get all graphql files and their structure
find . -name "*.graphql" -type f | head -20

Length of output: 75


🏁 Script executed:

#!/bin/bash
# Locate GraphQL schema and CpuService files
fd -type f \( -name "*.graphql" -o -name "*cpu*" \) 2>/dev/null | head -30

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for InfoCpu type and CpuService class
rg -n "type InfoCpu|class CpuService" --type graphql --type ts --type js -A 15 2>/dev/null | head -80

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for generateCpu method
rg -n "generateCpu\s*\(" --type ts --type js -A 20 2>/dev/null | head -100

Length of output: 9370


🏁 Script executed:

#!/bin/bash
# Search for packages and topology fields in services/resolvers
rg -n "packages|topology" --type ts --type js -B 2 -A 2 2>/dev/null | head -80

Length of output: 15193


🏁 Script executed:

#!/bin/bash
# Check the specific schema file if it exists
if [ -f "api/generated-schema.graphql" ]; then
  echo "=== Lines 1465-1480 ==="; 
  sed -n '1465,1480p' api/generated-schema.graphql
fi

Length of output: 275


🏁 Script executed:

#!/bin/bash
# Search for generateTopology function
rg -n "generateTopology" --type ts --type js -A 10 2>/dev/null

Length of output: 2455


🏁 Script executed:

#!/bin/bash
# Search for CpuPackages type definition
rg -n "type CpuPackages" --type graphql -A 8 2>/dev/null

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look at resolver files that might map service to schema
rg -n "CpuService|cpu.*resolver" --type ts --type js -l 2>/dev/null | head -10

Length of output: 636


🏁 Script executed:

#!/bin/bash
# Check for object construction patterns with power, packages, topology
rg -n "power\s*:|packages\s*:|topology\s*:" --type ts --type js -B 3 2>/dev/null | head -60

Length of output: 4938


🏁 Script executed:

#!/bin/bash
# List all files in api directory to find schema
ls -la api/ 2>/dev/null

Length of output: 1994


🏁 Script executed:

#!/bin/bash
# Broader search for CPU-related resolvers or services
find . -path ./node_modules -prune -o -type f \( -name "*.ts" -o -name "*.js" \) -print 2>/dev/null | xargs grep -l "InfoCpu\|generateCpu" 2>/dev/null | head -20

Length of output: 764


🏁 Script executed:

#!/bin/bash
# Search for return statements in CPU-related functions
rg -n "return\s*\{" --type ts --type js -A 10 2>/dev/null | grep -B 5 -A 10 "power\|packages\|topology" | head -80

Length of output: 7508


Schema is out of sync with TypeScript model—packages and topology fields exist in service but missing from generated schema.

The verification confirms the issue: CpuService.generateCpu() (api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.ts) returns both packages and topology fields (lines 55–56), and the TypeScript InfoCpu model defines them (lines 134, 137), but the generated GraphQL schema is missing both fields. The generateTopology() function's triple-nested return type (number[][][]) is confirmed correct.

The schema file needs regeneration or manual update to include these fields so they're exposed via GraphQL queries.

🤖 Prompt for AI Agents
In api/generated-schema.graphql around lines 1470 to 1472, the GraphQL schema is
missing the Cpu fields packages and topology that exist in the TypeScript model
and are returned by CpuService.generateCpu(); update the generated schema to
include these fields (packages: [Int!]! and topology: [[[Int!]!]!] or the
equivalent GraphQL list-of-list-of-list-of-Int with non-null constraints
matching the TypeScript types) so the schema matches the InfoCpu model —
regenerate the schema from the source types (preferred) or manually add the two
fields with the correct types and descriptions, then run the schema
generation/build step and commit the updated api/generated-schema.graphql.

"""CPU socket type"""
socket: String

Expand Down Expand Up @@ -2642,6 +2666,8 @@ type Subscription {
arraySubscription: UnraidArray!
logFile(path: String!): LogFileContent!
systemMetricsCpu: CpuUtilization!
systemMetricsCpuPower: CpuPower!
systemMetricsCpuTelemetry: CpuPackages!
systemMetricsMemory: MemoryUtilization!
upsUpdates: UPSDevice!
}
136 changes: 136 additions & 0 deletions api/src/unraid-api/graph/resolvers/info/cpu/cpu-power.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { Injectable, Logger } from '@nestjs/common';
import { constants as fsConstants } from 'node:fs';
import { access, readdir, readFile } from 'node:fs/promises';
import { basename, join } from 'path';

import { CpuPower } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js';

@Injectable()
export class CpuPowerService {
private readonly logger = new Logger(CpuPowerService.name);
async generateCpuPower(): Promise<CpuPower> {
const id = 'info/cpu-power';
try {
const powercapDir = '/sys/class/powercap';
const prefixes = ['intel-rapl', 'intel-rapl-mmio', 'amd-rapl'];

const dirEntries = await readdir(powercapDir, { withFileTypes: true }).catch(() => []);
const raplDomains = dirEntries
.filter((d) => prefixes.some((p) => d.name.startsWith(`${p}:`)))
.map((d) => join(powercapDir, d.name));

if (!raplDomains.length) {
this.logger.warn(`No RAPL domains found: ${JSON.stringify(dirEntries, null, 2)}`);
return { id };
}
this.logger.debug(JSON.stringify(raplDomains, null, 2));

const prevEnergy = new Map<string, number>();
const prevTime = new Map<string, bigint>();

for (const domainPath of raplDomains) {
const energyFile = join(domainPath, 'energy_uj');
try {
await access(energyFile, fsConstants.R_OK);
const content = await readFile(energyFile, 'utf8');
const energy = Number.parseInt(content.trim(), 10);
if (!Number.isNaN(energy)) {
prevEnergy.set(domainPath, energy);
prevTime.set(domainPath, process.hrtime.bigint());
}
} catch {
// ignore unreadable files
}
}

// ~200ms delay to measure energy delta
await new Promise((resolve) => setTimeout(resolve, 200));

const packages = new Map<number, Record<string, number>>();

for (const domainPath of raplDomains) {
const energyFile = join(domainPath, 'energy_uj');
const nameFile = join(domainPath, 'name');

try {
await access(energyFile, fsConstants.R_OK);
await access(nameFile, fsConstants.R_OK);
} catch {
continue;
}

const label = (await readFile(nameFile, 'utf8')).trim();
const nowStr = await readFile(energyFile, 'utf8');
const now = Number.parseInt(nowStr.trim(), 10);
const tNow = process.hrtime.bigint();

const prevE = prevEnergy.get(domainPath);
const prevT = prevTime.get(domainPath);
if (prevE === undefined || prevT === undefined) continue;

const diffEnergy = now - prevE; // microjoules
const diffTimeNs = Number(tNow - prevT); // nanoseconds
if (!(diffTimeNs > 0)) continue;

let powerW = (diffEnergy * 1e-6) / (diffTimeNs * 1e-9);
if (!Number.isFinite(powerW) || powerW < 0) continue;
powerW = Math.round(powerW * 100) / 100;

let pkgId = 0;
const m1 = label.match(/^package-(\d+)$/);
if (m1) {
pkgId = Number(m1[1]);
} else {
const base = basename(domainPath);
const m2 = base.match(/:(\d+)/);
if (m2) {
pkgId = Number(m2[1]);
} else {
const m3 = domainPath.match(/:(\d+)(?:\/|$)/);
if (m3) pkgId = Number(m3[1]);
}
}

if (!packages.has(pkgId)) packages.set(pkgId, {});
const pkg = packages.get(pkgId)!;
if (/^package-\d+$/.test(label)) {
pkg['package'] = powerW;
} else {
pkg[label] = powerW;
}
}

if (!packages.size) {
return { id };
}

let total = 0;
const sortedPkgIds = [...packages.keys()].sort((a, b) => a - b);
const coresPower: number[] = [];

for (const pkgId of sortedPkgIds) {
const pkg = packages.get(pkgId)!;
if (pkg['package'] === undefined) {
const sum = Object.values(pkg)
.filter((v) => typeof v === 'number' && Number.isFinite(v))
.reduce((a, b) => a + b, 0);
if (sum > 0) {
pkg['package'] = Math.round(sum * 100) / 100;
}
}
if (pkg['package'] !== undefined) {
total += pkg['package'];
coresPower.push(pkg['package']);
}
}

const result: CpuPower = { id, coresPower: coresPower.length ? coresPower : undefined };
if (sortedPkgIds.length > 0 && total > 0) {
result.totalPower = Math.round(total * 100) / 100;
}
return result;
} catch {
return { id };
}
}
}
208 changes: 208 additions & 0 deletions api/src/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.ts
Original file line number Diff line number Diff line change
@@ -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)) {

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused variable pkgId.

Copilot Autofix

AI 1 day ago

To fix the problem, we simply need to remove the unused variable pkgId from the destructuring assignment in the for-of loop at line 182. In JavaScript/TypeScript, if only the value portion of [key, value] is needed, we can replace [pkgId, domains] with [, domains] to ignore the first element (pkgId) or simply iterate over the values using Object.values(results). The most concise and clear approach here is to use for (const domains of Object.values(results)), which expresses the intent to operate solely on the domains objects.

No imports or method changes are required; just this structure edit in the body of the class in the given file.


Suggested changeset 1
api/src/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
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
--- 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
@@ -179,7 +179,7 @@
             results[pkgId][label] = powerW;
         }
 
-        for (const [pkgId, domains] of Object.entries(results)) {
+        for (const domains of Object.values(results)) {
             const total = Object.values(domains).reduce((a, b) => a + b, 0);
             (domains as any)['total'] = Math.round(total * 100) / 100;
         }
EOF
@@ -179,7 +179,7 @@
results[pkgId][label] = powerW;
}

for (const [pkgId, domains] of Object.entries(results)) {
for (const domains of Object.values(results)) {
const total = Object.values(domains).reduce((a, b) => a + b, 0);
(domains as any)['total'] = Math.round(total * 100) / 100;
}
Copilot is powered by AI and may make mistakes. Always verify output.
Unable to commit as this autofix suggestion is now outdated
const total = Object.values(domains).reduce((a, b) => a + b, 0);
(domains as any)['total'] = Math.round(total * 100) / 100;
}
Comment on lines +182 to +185
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove unused loop variable or use Object.values.

Avoid pkgId unused warning and simplify.

-        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;
-        }
+        for (const domains of Object.values(results)) {
+            const total = Object.values(domains).reduce((a, b) => a + b, 0);
+            (domains as any)['total'] = Math.round(total * 100) / 100;
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
for (const domains of Object.values(results)) {
const total = Object.values(domains).reduce((a, b) => a + b, 0);
(domains as any)['total'] = Math.round(total * 100) / 100;
}
🧰 Tools
🪛 GitHub Check: CodeQL

[notice] 182-182: Unused variable, import, function or class
Unused variable pkgId.

🤖 Prompt for AI Agents
In api/src/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.ts around
lines 182 to 185, the for-loop uses Object.entries but never uses the pkgId
variable; replace the entries iteration with Object.values to avoid the
unused-variable warning and simplify the code. Iterate like `for (const domains
of Object.values(results)) { ... }` (or otherwise destructure to use the first
element) and keep the same logic to compute and assign the rounded total on each
domains object.


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;
Comment on lines +195 to +206
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix TS typing: result inferred as never[].

Add an explicit type so push({...}) type-checks.

-        const result = [];
+        const result: { id: number; power: number; temp: number }[] = [];
🧰 Tools
🪛 GitHub Actions: CI - Main (API)

[error] 199-199: TS2345: Argument of type '{ id: number; power: number; temp: number; }' is not assignable to parameter of type 'never'.

🤖 Prompt for AI Agents
In api/src/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.ts around
lines 195 to 206, the local variable result is inferred as never[] causing
push({...}) to fail; explicitly type result (for example: const result: { id:
number; power: number; temp: number }[] = []) so the pushed objects match the
declared shape, and update the function return type if necessary to reflect this
typed array.

}
}
Loading
Loading