From 46046ebee4fcd8b2778d895d4a4c66bbd4891458 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sat, 20 Dec 2025 06:56:30 +0000 Subject: [PATCH 01/86] Really just copy/paste from feature request --- .../metrics/temperature/temperature.model.ts | 145 ++++++++++++++++++ .../temperature/temperature.service.ts | 64 ++++++++ plugin/builder/build-txz.ts | 4 + plugin/builder/utils/monitor-tools.ts | 48 ++++++ 4 files changed, 261 insertions(+) create mode 100644 api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts create mode 100644 api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts create mode 100644 plugin/builder/utils/monitor-tools.ts diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts new file mode 100644 index 0000000000..1cfcb883e6 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts @@ -0,0 +1,145 @@ +// Location: api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts + +import { Field, Float, Int, ObjectType, registerEnumType } from '@nestjs/graphql'; + +import { Node } from '@unraid/shared/graphql.model.js'; +import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator'; + +// Extend existing Metrics model +// Location: api/src/unraid-api/graph/resolvers/metrics/metrics.model.ts + +import { TemperatureMetrics } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature/temperature.model.js'; + +export enum TemperatureUnit { + CELSIUS = 'CELSIUS', + FAHRENHEIT = 'FAHRENHEIT', +} + +registerEnumType(TemperatureUnit, { + name: 'TemperatureUnit', +}); + +export enum TemperatureStatus { + NORMAL = 'NORMAL', + WARNING = 'WARNING', + CRITICAL = 'CRITICAL', + UNKNOWN = 'UNKNOWN', +} + +registerEnumType(TemperatureStatus, { + name: 'TemperatureStatus', +}); + +export enum SensorType { + CPU_PACKAGE = 'CPU_PACKAGE', + CPU_CORE = 'CPU_CORE', + MOTHERBOARD = 'MOTHERBOARD', + CHIPSET = 'CHIPSET', + GPU = 'GPU', + DISK = 'DISK', + NVME = 'NVME', + AMBIENT = 'AMBIENT', + VRM = 'VRM', + CUSTOM = 'CUSTOM', +} + +registerEnumType(SensorType, { + name: 'SensorType', + description: 'Type of temperature sensor', +}); + +@ObjectType() +export class Temperature { + @Field(() => Float, { description: 'Temperature value' }) + @IsNumber() + value!: number; + + @Field(() => TemperatureUnit, { description: 'Temperature unit' }) + @IsEnum(TemperatureUnit) + unit!: TemperatureUnit; + + @Field(() => Date, { description: 'Timestamp of reading' }) + timestamp!: Date; + + @Field(() => TemperatureStatus, { description: 'Temperature status' }) + @IsEnum(TemperatureStatus) + status!: TemperatureStatus; +} + +@ObjectType({ implements: () => Node }) +export class TemperatureSensor extends Node { + @Field(() => String, { description: 'Sensor name' }) + @IsString() + name!: string; + + @Field(() => SensorType, { description: 'Type of sensor' }) + @IsEnum(SensorType) + type!: SensorType; + + @Field(() => String, { nullable: true, description: 'Physical location' }) + @IsOptional() + @IsString() + location?: string; + + @Field(() => Temperature, { description: 'Current temperature' }) + current!: Temperature; + + @Field(() => Temperature, { nullable: true, description: 'Minimum recorded' }) + @IsOptional() + min?: Temperature; + + @Field(() => Temperature, { nullable: true, description: 'Maximum recorded' }) + @IsOptional() + max?: Temperature; + + @Field(() => Float, { nullable: true, description: 'Warning threshold' }) + @IsOptional() + @IsNumber() + warning?: number; + + @Field(() => Float, { nullable: true, description: 'Critical threshold' }) + @IsOptional() + @IsNumber() + critical?: number; +} + +@ObjectType() +export class TemperatureSummary { + @Field(() => Float, { description: 'Average temperature across all sensors' }) + @IsNumber() + average!: number; + + @Field(() => TemperatureSensor, { description: 'Hottest sensor' }) + hottest!: TemperatureSensor; + + @Field(() => TemperatureSensor, { description: 'Coolest sensor' }) + coolest!: TemperatureSensor; + + @Field(() => Int, { description: 'Count of sensors at warning level' }) + @IsNumber() + warningCount!: number; + + @Field(() => Int, { description: 'Count of sensors at critical level' }) + @IsNumber() + criticalCount!: number; +} + +@ObjectType({ implements: () => Node }) +export class TemperatureMetrics extends Node { + @Field(() => [TemperatureSensor], { description: 'All temperature sensors' }) + sensors!: TemperatureSensor[]; + + @Field(() => TemperatureSummary, { description: 'Temperature summary' }) + summary!: TemperatureSummary; +} + +@ObjectType({ implements: () => Node }) +export class Metrics extends Node { + // ... existing fields ... + + @Field(() => TemperatureMetrics, { + nullable: true, + description: 'Temperature metrics', + }) + temperature?: TemperatureMetrics; +} diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts new file mode 100644 index 0000000000..0bca9142f6 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -0,0 +1,64 @@ +// temperature.service.ts - Use plugin-bundled binaries +import { ConfigService } from '@nestjs/config'; +import { join } from 'path'; + +export class TemperatureService implements OnModuleInit { + private readonly binPath: string; + private availableTools: Map = new Map(); + + constructor(private readonly configService: ConfigService) { + // Use binaries bundled with the plugin + this.binPath = this.configService.get( + 'API_MONITORING_BIN_PATH', + '/usr/local/emhttp/plugins/unraid-api/monitoring' + ); + } + + async onModuleInit() { + // Use bundled binaries instead of system tools + await this.initializeBundledTools(); + + // Initialize sensor detection for available tools + if (this.availableTools.has('sensors')) { + await this.initializeLmSensors(); + } + + if (this.availableTools.has('smartctl')) { + // Already available through DisksService + } + + if (this.availableTools.has('nvidia-smi')) { + await this.initializeNvidiaMonitoring(); + } + } + + private async initializeBundledTools(): Promise { + const tools = [ + 'sensors', // lm-sensors + 'smartctl', // smartmontools + 'nvidia-smi', // NVIDIA driver + 'ipmitool', // IPMI tools + ]; + + for (const tool of tools) { + const toolPath = join(this.binPath, tool); + try { + await execa(toolPath, ['--version']); + this.availableTools.set(tool, toolPath); + this.logger.log(`Temperature tool available: ${tool} at ${toolPath}`); + } catch { + this.logger.warn(`Temperature tool not found: ${tool}`); + } + } + } + + // Use bundled binary paths for all executions + private async execTool(toolName: string, args: string[]): Promise { + const toolPath = this.availableTools.get(toolName); + if (!toolPath) { + throw new Error(`Tool ${toolName} not available`); + } + const { stdout } = await execa(toolPath, args); + return stdout; + } +} diff --git a/plugin/builder/build-txz.ts b/plugin/builder/build-txz.ts index ce1bafaa7f..0a5ce331c0 100644 --- a/plugin/builder/build-txz.ts +++ b/plugin/builder/build-txz.ts @@ -11,6 +11,7 @@ import { apiDir } from "./utils/paths"; import { getVendorBundleName, getVendorFullPath } from "./build-vendor-store"; import { getAssetUrl } from "./utils/bucket-urls"; import { validateStandaloneManifest, getStandaloneManifestPath } from "./utils/manifest-validator"; +import { downloadMonitoringTools } from "./utils/monitor-tools"; // Check for manifest files in expected locations @@ -176,6 +177,9 @@ const validateSourceDir = async (validatedEnv: TxzEnv) => { const buildTxz = async (validatedEnv: TxzEnv) => { await validateSourceDir(validatedEnv); + + // Call during TXZ build process + await downloadMonitoringTools(sourceDir); // Use version from validated environment const version = validatedEnv.apiVersion; diff --git a/plugin/builder/utils/monitor-tools.ts b/plugin/builder/utils/monitor-tools.ts new file mode 100644 index 0000000000..fadbbd6921 --- /dev/null +++ b/plugin/builder/utils/monitor-tools.ts @@ -0,0 +1,48 @@ +// Enhancement to the plugin build process +// Location: plugin/builder/build-txz.ts + +// Add function to download monitoring tools during build +const downloadMonitoringTools = async (targetDir: string) => { + console.log("Downloading temperature monitoring tools from safe sources..."); + + const tools = [ + { + name: 'sensors', + url: 'https://github.com/lm-sensors/lm-sensors/releases/download/v3.6.0/sensors-3.6.0-x86_64', + sha256: 'abc123...', // Verify integrity + }, + { + name: 'smartctl', + url: 'https://sourceforge.net/projects/smartmontools/files/smartmontools/7.4/smartctl-7.4-x86_64', + sha256: 'def456...', // Verify integrity + }, + { + name: 'nvidia-smi', + url: 'https://developer.nvidia.com/downloads/nvidia-smi-545.29.06-x86_64', + sha256: 'ghi789...', // Verify integrity + } + ]; + + const monitoringDir = join(targetDir, 'usr/local/emhttp/plugins/unraid-api/monitoring'); + await fs.mkdir(monitoringDir, { recursive: true }); + + for (const tool of tools) { + console.log(`Downloading ${tool.name}...`); + const response = await fetch(tool.url); + const buffer = await response.arrayBuffer(); + + // Verify SHA256 checksum + const hash = crypto.createHash('sha256'); + hash.update(Buffer.from(buffer)); + if (hash.digest('hex') !== tool.sha256) { + throw new Error(`Checksum verification failed for ${tool.name}`); + } + + // Save binary + const toolPath = join(monitoringDir, tool.name); + await fs.writeFile(toolPath, Buffer.from(buffer)); + await fs.chmod(toolPath, 0o755); + + console.log(`✓ ${tool.name} downloaded and verified`); + } +}; From d8b53896311fcbe8b921d2b42a9f4d42bb867cbf Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 22 Dec 2025 04:58:19 +0000 Subject: [PATCH 02/86] modified these files a little bit to check for install --- .../temperature/temperature.service.ts | 18 +++---- plugin/builder/build-txz.ts | 2 + plugin/builder/utils/monitor-tools.ts | 54 +++++++++++++------ 3 files changed, 48 insertions(+), 26 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index 0bca9142f6..5fc746d2dd 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -23,21 +23,21 @@ export class TemperatureService implements OnModuleInit { await this.initializeLmSensors(); } - if (this.availableTools.has('smartctl')) { - // Already available through DisksService - } + //if (this.availableTools.has('smartctl')) { + // // Already available through DisksService + //} - if (this.availableTools.has('nvidia-smi')) { - await this.initializeNvidiaMonitoring(); - } + //if (this.availableTools.has('nvidia-smi')) { + // await this.initializeNvidiaMonitoring(); + //} } private async initializeBundledTools(): Promise { const tools = [ 'sensors', // lm-sensors - 'smartctl', // smartmontools - 'nvidia-smi', // NVIDIA driver - 'ipmitool', // IPMI tools + //'smartctl', // smartmontools + //'nvidia-smi', // NVIDIA driver + //'ipmitool', // IPMI tools ]; for (const tool of tools) { diff --git a/plugin/builder/build-txz.ts b/plugin/builder/build-txz.ts index 0a5ce331c0..e1a42ac9ad 100644 --- a/plugin/builder/build-txz.ts +++ b/plugin/builder/build-txz.ts @@ -179,6 +179,8 @@ const buildTxz = async (validatedEnv: TxzEnv) => { await validateSourceDir(validatedEnv); // Call during TXZ build process + + const sourceDir = join(startingDir, "source"); await downloadMonitoringTools(sourceDir); // Use version from validated environment diff --git a/plugin/builder/utils/monitor-tools.ts b/plugin/builder/utils/monitor-tools.ts index fadbbd6921..78c6696f84 100644 --- a/plugin/builder/utils/monitor-tools.ts +++ b/plugin/builder/utils/monitor-tools.ts @@ -1,32 +1,43 @@ +import { join } from 'path'; +import { promises as fs } from 'fs'; +import crypto from 'crypto'; + // Enhancement to the plugin build process // Location: plugin/builder/build-txz.ts // Add function to download monitoring tools during build -const downloadMonitoringTools = async (targetDir: string) => { +export const downloadMonitoringTools = async (targetDir: string) => { console.log("Downloading temperature monitoring tools from safe sources..."); - const tools = [ - { - name: 'sensors', - url: 'https://github.com/lm-sensors/lm-sensors/releases/download/v3.6.0/sensors-3.6.0-x86_64', - sha256: 'abc123...', // Verify integrity - }, - { - name: 'smartctl', - url: 'https://sourceforge.net/projects/smartmontools/files/smartmontools/7.4/smartctl-7.4-x86_64', - sha256: 'def456...', // Verify integrity - }, - { - name: 'nvidia-smi', - url: 'https://developer.nvidia.com/downloads/nvidia-smi-545.29.06-x86_64', - sha256: 'ghi789...', // Verify integrity + const tools_to_download = [ + //{ + // name: 'sensors', + // url: 'https://github.com/lm-sensors/lm-sensors/releases/download/v3.6.0/sensors-3.6.0-x86_64', + // sha256: 'abc123', // Verify integrity + //}, + //{ + // name: 'smartctl', + // url: 'https://sourceforge.net/projects/smartmontools/files/smartmontools/7.4/smartctl-7.4-x86_64', + // sha256: 'def456...', // Verify integrity + //}, + //{ + // name: 'nvidia-smi', + // url: 'https://developer.nvidia.com/downloads/nvidia-smi-545.29.06-x86_64', + // sha256: 'ghi789...', // Verify integrity + //} + ]; + + const installed_tools = [ + { + name: 'sensors', + path: '/usr/sbin/sensors' } ]; const monitoringDir = join(targetDir, 'usr/local/emhttp/plugins/unraid-api/monitoring'); await fs.mkdir(monitoringDir, { recursive: true }); - for (const tool of tools) { + for (const tool of tools_to_download) { console.log(`Downloading ${tool.name}...`); const response = await fetch(tool.url); const buffer = await response.arrayBuffer(); @@ -45,4 +56,13 @@ const downloadMonitoringTools = async (targetDir: string) => { console.log(`✓ ${tool.name} downloaded and verified`); } + + for (const tool of installed_tools) { + try { + await fs.access(tool.path); + console.log(`✓ ${tool.name} found at ${tool.path}`); + } catch { + console.warn(`⚠ ${tool.name} not found on this system`); + } + } }; From 89356bc2d70db8e8ff94041404e66080280dc5a9 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Tue, 23 Dec 2025 05:48:39 +0000 Subject: [PATCH 03/86] copy/paste some more functions and do some minor clean-up --- .../resolvers/metrics/metrics.resolver.ts | 29 +++++ plugin/builder/build-txz.ts | 4 +- plugin/builder/utils/monitor-tools.ts | 104 +++++++++--------- 3 files changed, 83 insertions(+), 54 deletions(-) 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 cbd47e86ba..2a2567ced0 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts @@ -77,6 +77,18 @@ export class MetricsResolver implements OnModuleInit { }, 2000 ); + + // Add temperature polling with 5 second interval + this.subscriptionTracker.registerTopic( + PUBSUB_CHANNEL.TEMPERATURE_METRICS, + async () => { + const payload = await this.temperatureService.getMetrics(); + pubsub.publish(PUBSUB_CHANNEL.TEMPERATURE_METRICS, { + systemMetricsTemperature: payload, + }); + }, + 5000 + ); } @Query(() => Metrics) @@ -135,4 +147,21 @@ export class MetricsResolver implements OnModuleInit { public async systemMetricsMemorySubscription() { return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.MEMORY_UTILIZATION); } + + @ResolveField(() => TemperatureMetrics, { nullable: true }) + public async temperature(): Promise { + return this.temperatureService.getMetrics(); + } + @Subscription(() => TemperatureMetrics, { + name: 'systemMetricsTemperature', + resolve: (value) => value.systemMetricsTemperature, + }) + @UsePermissions({ + action: AuthActionVerb.READ, + resource: Resource.INFO, + possession: AuthPossession.ANY, + }) + public async systemMetricsTemperatureSubscription() { + return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.TEMPERATURE_METRICS); + } } diff --git a/plugin/builder/build-txz.ts b/plugin/builder/build-txz.ts index e1a42ac9ad..c65c7c252d 100644 --- a/plugin/builder/build-txz.ts +++ b/plugin/builder/build-txz.ts @@ -11,7 +11,7 @@ import { apiDir } from "./utils/paths"; import { getVendorBundleName, getVendorFullPath } from "./build-vendor-store"; import { getAssetUrl } from "./utils/bucket-urls"; import { validateStandaloneManifest, getStandaloneManifestPath } from "./utils/manifest-validator"; -import { downloadMonitoringTools } from "./utils/monitor-tools"; +//import { downloadMonitoringTools } from "./utils/monitor-tools"; //TODO(@mitchellthompkins): Remove this // Check for manifest files in expected locations @@ -181,7 +181,7 @@ const buildTxz = async (validatedEnv: TxzEnv) => { // Call during TXZ build process const sourceDir = join(startingDir, "source"); - await downloadMonitoringTools(sourceDir); + //await downloadMonitoringTools(sourceDir); //TODO(@mitchellthompkins): Remove this // Use version from validated environment const version = validatedEnv.apiVersion; diff --git a/plugin/builder/utils/monitor-tools.ts b/plugin/builder/utils/monitor-tools.ts index 78c6696f84..b04dd28f79 100644 --- a/plugin/builder/utils/monitor-tools.ts +++ b/plugin/builder/utils/monitor-tools.ts @@ -7,62 +7,62 @@ import crypto from 'crypto'; // Add function to download monitoring tools during build export const downloadMonitoringTools = async (targetDir: string) => { - console.log("Downloading temperature monitoring tools from safe sources..."); - - const tools_to_download = [ - //{ - // name: 'sensors', - // url: 'https://github.com/lm-sensors/lm-sensors/releases/download/v3.6.0/sensors-3.6.0-x86_64', - // sha256: 'abc123', // Verify integrity - //}, - //{ - // name: 'smartctl', - // url: 'https://sourceforge.net/projects/smartmontools/files/smartmontools/7.4/smartctl-7.4-x86_64', - // sha256: 'def456...', // Verify integrity - //}, - //{ - // name: 'nvidia-smi', - // url: 'https://developer.nvidia.com/downloads/nvidia-smi-545.29.06-x86_64', - // sha256: 'ghi789...', // Verify integrity - //} - ]; + console.log("Downloading temperature monitoring tools from safe sources..."); - const installed_tools = [ - { - name: 'sensors', - path: '/usr/sbin/sensors' - } - ]; + const tools_to_download = [ + //{ + // name: 'sensors', + // url: 'https://github.com/lm-sensors/lm-sensors/releases/download/v3.6.0/sensors-3.6.0-x86_64', + // sha256: 'abc123', // Verify integrity + //}, + //{ + // name: 'smartctl', + // url: 'https://sourceforge.net/projects/smartmontools/files/smartmontools/7.4/smartctl-7.4-x86_64', + // sha256: 'def456...', // Verify integrity + //}, + //{ + // name: 'nvidia-smi', + // url: 'https://developer.nvidia.com/downloads/nvidia-smi-545.29.06-x86_64', + // sha256: 'ghi789...', // Verify integrity + //} + ]; + + const installed_tools = [ + { + name: 'sensors', + path: '/usr/sbin/sensors' + } + ]; + + const monitoringDir = join(targetDir, 'usr/local/emhttp/plugins/unraid-api/monitoring'); + await fs.mkdir(monitoringDir, { recursive: true }); + + for (const tool of tools_to_download) { + console.log(`Downloading ${tool.name}...`); + const response = await fetch(tool.url); + const buffer = await response.arrayBuffer(); + + // Verify SHA256 checksum + const hash = crypto.createHash('sha256'); + hash.update(Buffer.from(buffer)); + if (hash.digest('hex') !== tool.sha256) { + throw new Error(`Checksum verification failed for ${tool.name}`); + } - const monitoringDir = join(targetDir, 'usr/local/emhttp/plugins/unraid-api/monitoring'); - await fs.mkdir(monitoringDir, { recursive: true }); + // Save binary + const toolPath = join(monitoringDir, tool.name); + await fs.writeFile(toolPath, Buffer.from(buffer)); + await fs.chmod(toolPath, 0o755); - for (const tool of tools_to_download) { - console.log(`Downloading ${tool.name}...`); - const response = await fetch(tool.url); - const buffer = await response.arrayBuffer(); - - // Verify SHA256 checksum - const hash = crypto.createHash('sha256'); - hash.update(Buffer.from(buffer)); - if (hash.digest('hex') !== tool.sha256) { - throw new Error(`Checksum verification failed for ${tool.name}`); + console.log(`✓ ${tool.name} downloaded and verified`); } - - // Save binary - const toolPath = join(monitoringDir, tool.name); - await fs.writeFile(toolPath, Buffer.from(buffer)); - await fs.chmod(toolPath, 0o755); - - console.log(`✓ ${tool.name} downloaded and verified`); - } - for (const tool of installed_tools) { - try { - await fs.access(tool.path); - console.log(`✓ ${tool.name} found at ${tool.path}`); - } catch { - console.warn(`⚠ ${tool.name} not found on this system`); - } + for (const tool of installed_tools) { + try { + await fs.access(tool.path); + console.log(`✓ ${tool.name} found at ${tool.path}`); + } catch { + console.warn(`⚠ ${tool.name} not found on this system`); + } } }; From a878c47242b3fdd78d318ceea5d3616eb026d2be Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Tue, 23 Dec 2025 05:56:16 +0000 Subject: [PATCH 04/86] add temperature module --- api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts | 2 +- api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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 f6e195087e..02d0641410 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts @@ -7,7 +7,7 @@ import { ServicesModule } from '@app/unraid-api/graph/services/services.module.j @Module({ imports: [ServicesModule, CpuModule], - providers: [MetricsResolver, MemoryService], + providers: [MetricsResolver, MemoryService, TemperatureService], exports: [MetricsResolver], }) export class MetricsModule {} 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 2a2567ced0..3cc7e3fdc0 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts @@ -21,6 +21,7 @@ export class MetricsResolver implements OnModuleInit { private readonly cpuService: CpuService, private readonly cpuTopologyService: CpuTopologyService, private readonly memoryService: MemoryService, + private readonly temperatureService: TemperatureService, private readonly subscriptionTracker: SubscriptionTrackerService, private readonly subscriptionHelper: SubscriptionHelperService ) {} From b288994ae6c7250fe5da9c84def50477624f2ca9 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Tue, 23 Dec 2025 06:27:15 +0000 Subject: [PATCH 05/86] okay, I'm new to typescript and don't know what I'm doing yet but getting closer --- .../graph/resolvers/metrics/metrics.model.ts | 7 +++++++ .../graph/resolvers/metrics/metrics.module.ts | 2 +- .../graph/resolvers/metrics/metrics.resolver.ts | 4 ++-- .../metrics/temperature/temperature.model.ts | 16 ---------------- 4 files changed, 10 insertions(+), 19 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.model.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.model.ts index 0e76528889..438d800fbb 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.model.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.model.ts @@ -4,6 +4,7 @@ import { Node } from '@unraid/shared/graphql.model.js'; import { CpuUtilization } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js'; import { MemoryUtilization } from '@app/unraid-api/graph/resolvers/info/memory/memory.model.js'; +import { TemperatureMetrics } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; @ObjectType({ implements: () => Node, @@ -18,4 +19,10 @@ export class Metrics extends Node { nullable: true, }) memory?: MemoryUtilization; + + @Field(() => TemperatureMetrics, { + nullable: true, + description: 'Temperature metrics', + }) + temperature?: TemperatureMetrics; } 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 02d0641410..91755f7e25 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts @@ -7,7 +7,7 @@ import { ServicesModule } from '@app/unraid-api/graph/services/services.module.j @Module({ imports: [ServicesModule, CpuModule], - providers: [MetricsResolver, MemoryService, TemperatureService], + providers: [MetricsResolver, e, TemperatureService], exports: [MetricsResolver], }) export class MetricsModule {} 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 3cc7e3fdc0..af2a39464d 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts @@ -11,6 +11,7 @@ import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service 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'; import { Metrics } from '@app/unraid-api/graph/resolvers/metrics/metrics.model.js'; +import { TemperatureMetrics } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; import { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js'; import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js'; @@ -158,9 +159,8 @@ export class MetricsResolver implements OnModuleInit { resolve: (value) => value.systemMetricsTemperature, }) @UsePermissions({ - action: AuthActionVerb.READ, + action: AuthAction.READ_ANY, resource: Resource.INFO, - possession: AuthPossession.ANY, }) public async systemMetricsTemperatureSubscription() { return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.TEMPERATURE_METRICS); diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts index 1cfcb883e6..fe4cb6bfae 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts @@ -5,11 +5,6 @@ import { Field, Float, Int, ObjectType, registerEnumType } from '@nestjs/graphql import { Node } from '@unraid/shared/graphql.model.js'; import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator'; -// Extend existing Metrics model -// Location: api/src/unraid-api/graph/resolvers/metrics/metrics.model.ts - -import { TemperatureMetrics } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature/temperature.model.js'; - export enum TemperatureUnit { CELSIUS = 'CELSIUS', FAHRENHEIT = 'FAHRENHEIT', @@ -132,14 +127,3 @@ export class TemperatureMetrics extends Node { @Field(() => TemperatureSummary, { description: 'Temperature summary' }) summary!: TemperatureSummary; } - -@ObjectType({ implements: () => Node }) -export class Metrics extends Node { - // ... existing fields ... - - @Field(() => TemperatureMetrics, { - nullable: true, - description: 'Temperature metrics', - }) - temperature?: TemperatureMetrics; -} From 3ac98bc397ffc19cb4d28bb436b7a916494db205 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Fri, 26 Dec 2025 23:48:34 +0000 Subject: [PATCH 06/86] starting to actually build this out --- .../graph/resolvers/metrics/metrics.module.ts | 3 ++- .../graph/resolvers/metrics/metrics.resolver.ts | 3 ++- .../metrics/temperature/temperature.service.ts | 10 +++++++--- packages/unraid-shared/src/pubsub/graphql.pubsub.ts | 1 + 4 files changed, 12 insertions(+), 5 deletions(-) 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 91755f7e25..31d8388137 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts @@ -3,11 +3,12 @@ import { Module } from '@nestjs/common'; 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 { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; import { ServicesModule } from '@app/unraid-api/graph/services/services.module.js'; @Module({ imports: [ServicesModule, CpuModule], - providers: [MetricsResolver, e, TemperatureService], + providers: [MetricsResolver, MemoryService, TemperatureService], exports: [MetricsResolver], }) export class MetricsModule {} 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 af2a39464d..1f4edb4bb9 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts @@ -11,7 +11,8 @@ import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service 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'; import { Metrics } from '@app/unraid-api/graph/resolvers/metrics/metrics.model.js'; -import { TemperatureMetrics } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; +import { TemperatureMetrics } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; +import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; import { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js'; import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js'; diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index 5fc746d2dd..8f2957844b 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -1,8 +1,12 @@ // temperature.service.ts - Use plugin-bundled binaries +import { Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { join } from 'path'; +import { execa } from 'execa'; + export class TemperatureService implements OnModuleInit { + private readonly logger = new Logger(TemperatureService.name); private readonly binPath: string; private availableTools: Map = new Map(); @@ -19,9 +23,9 @@ export class TemperatureService implements OnModuleInit { await this.initializeBundledTools(); // Initialize sensor detection for available tools - if (this.availableTools.has('sensors')) { - await this.initializeLmSensors(); - } + //if (this.availableTools.has('sensors')) { + // await this.initializeLmSensors(); + //} //if (this.availableTools.has('smartctl')) { // // Already available through DisksService diff --git a/packages/unraid-shared/src/pubsub/graphql.pubsub.ts b/packages/unraid-shared/src/pubsub/graphql.pubsub.ts index 2c48757006..0359617f96 100644 --- a/packages/unraid-shared/src/pubsub/graphql.pubsub.ts +++ b/packages/unraid-shared/src/pubsub/graphql.pubsub.ts @@ -16,6 +16,7 @@ export enum GRAPHQL_PUBSUB_CHANNEL { NOTIFICATION_WARNINGS_AND_ALERTS = "NOTIFICATION_WARNINGS_AND_ALERTS", OWNER = "OWNER", SERVERS = "SERVERS", + TEMPERATURE_METRICS = "TEMPERATURE_METRICS", VMS = "VMS", DOCKER_STATS = "DOCKER_STATS", LOG_FILE = "LOG_FILE", From b867834af6f1f0f08f26f13ef575b52f767c1dbe Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Fri, 26 Dec 2025 23:54:57 +0000 Subject: [PATCH 07/86] fix typescript typing issues --- .../resolvers/metrics/metrics.resolver.ts | 2 +- .../temperature/temperature.service.ts | 133 ++++++++++++++---- 2 files changed, 110 insertions(+), 25 deletions(-) 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 1f4edb4bb9..15fb2d0f90 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts @@ -152,7 +152,7 @@ export class MetricsResolver implements OnModuleInit { } @ResolveField(() => TemperatureMetrics, { nullable: true }) - public async temperature(): Promise { + public async temperature(): Promise { return this.temperatureService.getMetrics(); } @Subscription(() => TemperatureMetrics, { diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index 8f2957844b..347b9c6ce7 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -1,17 +1,24 @@ -// temperature.service.ts - Use plugin-bundled binaries import { Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { join } from 'path'; import { execa } from 'execa'; +import { + SensorType, + Temperature, + TemperatureMetrics, + TemperatureSensor, + TemperatureStatus, + TemperatureUnit, +} from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; + export class TemperatureService implements OnModuleInit { private readonly logger = new Logger(TemperatureService.name); private readonly binPath: string; private availableTools: Map = new Map(); constructor(private readonly configService: ConfigService) { - // Use binaries bundled with the plugin this.binPath = this.configService.get( 'API_MONITORING_BIN_PATH', '/usr/local/emhttp/plugins/unraid-api/monitoring' @@ -19,44 +26,24 @@ export class TemperatureService implements OnModuleInit { } async onModuleInit() { - // Use bundled binaries instead of system tools await this.initializeBundledTools(); - - // Initialize sensor detection for available tools - //if (this.availableTools.has('sensors')) { - // await this.initializeLmSensors(); - //} - - //if (this.availableTools.has('smartctl')) { - // // Already available through DisksService - //} - - //if (this.availableTools.has('nvidia-smi')) { - // await this.initializeNvidiaMonitoring(); - //} } private async initializeBundledTools(): Promise { - const tools = [ - 'sensors', // lm-sensors - //'smartctl', // smartmontools - //'nvidia-smi', // NVIDIA driver - //'ipmitool', // IPMI tools - ]; + const tools = ['sensors']; for (const tool of tools) { const toolPath = join(this.binPath, tool); try { await execa(toolPath, ['--version']); this.availableTools.set(tool, toolPath); - this.logger.log(`Temperature tool available: ${tool} at ${toolPath}`); + this.logger.log(`Temperature tool available: ${tool}`); } catch { this.logger.warn(`Temperature tool not found: ${tool}`); } } } - // Use bundled binary paths for all executions private async execTool(toolName: string, args: string[]): Promise { const toolPath = this.availableTools.get(toolName); if (!toolPath) { @@ -65,4 +52,102 @@ export class TemperatureService implements OnModuleInit { const { stdout } = await execa(toolPath, args); return stdout; } + + // ============================ + // Public API + // ============================ + + async getMetrics(): Promise { + if (!this.availableTools.has('sensors')) { + this.logger.debug('Temperature metrics unavailable (sensors missing)'); + return null; + } + + const output = await this.execTool('sensors', []); + const sensors = this.parseSensorsOutput(output); + + if (sensors.length === 0) { + return null; + } + + return { + id: 'temperature-metrics', + sensors, + summary: this.buildSummary(sensors), + }; + } + + // ============================ + // Parsing + // ============================ + + private parseSensorsOutput(output: string): TemperatureSensor[] { + const lines = output.split('\n'); + const sensors: TemperatureSensor[] = []; + + for (const line of lines) { + // Matches: "+52.0°C" + const match = line.match(/(.+?):\s+\+?([0-9.]+)°C/); + if (!match) continue; + + const name = match[1].trim(); + const value = Number(match[2]); + + const temperature: Temperature = { + value, + unit: TemperatureUnit.CELSIUS, + timestamp: new Date(), + status: this.computeStatus(value), + }; + + sensors.push({ + id: `sensor:${name}`, + name, + type: this.inferSensorType(name), + current: temperature, + }); + } + + return sensors; + } + + private inferSensorType(name: string): SensorType { + const n = name.toLowerCase(); + + if (n.includes('package')) return SensorType.CPU_PACKAGE; + if (n.includes('core')) return SensorType.CPU_CORE; + if (n.includes('gpu')) return SensorType.GPU; + if (n.includes('nvme')) return SensorType.NVME; + if (n.includes('board')) return SensorType.MOTHERBOARD; + + return SensorType.CUSTOM; + } + + private computeStatus(value: number): TemperatureStatus { + if (value >= 90) return TemperatureStatus.CRITICAL; + if (value >= 80) return TemperatureStatus.WARNING; + return TemperatureStatus.NORMAL; + } + + // ============================ + // Summary + // ============================ + + private buildSummary(sensors: TemperatureSensor[]) { + const values = sensors.map((s) => s.current.value); + + const average = values.reduce((a, b) => a + b, 0) / values.length; + + const hottest = sensors.reduce((a, b) => (a.current.value > b.current.value ? a : b)); + + const coolest = sensors.reduce((a, b) => (a.current.value < b.current.value ? a : b)); + + return { + average, + hottest, + coolest, + warningCount: sensors.filter((s) => s.current.status === TemperatureStatus.WARNING).length, + criticalCount: sensors.filter((s) => s.current.status === TemperatureStatus.CRITICAL).length, + }; + } } From 45b3816ccc9c570f4d14ad43b21d8180c7375941 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sat, 27 Dec 2025 00:09:17 +0000 Subject: [PATCH 08/86] I don't expect the tests to pass but pnpm exec tsc --noEmit is happy now at least --- .../graph/resolvers/metrics/metrics.resolver.spec.ts | 7 +++++++ 1 file changed, 7 insertions(+) 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 385277e8cd..09a36058a2 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 @@ -7,6 +7,7 @@ import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu 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'; +import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; import { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js'; import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js'; @@ -14,6 +15,7 @@ describe('MetricsResolver', () => { let resolver: MetricsResolver; let cpuService: CpuService; let memoryService: MemoryService; + let temperatureService: TemperatureService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -168,10 +170,15 @@ describe('MetricsResolver', () => { generateTelemetry: vi.fn().mockResolvedValue([{ id: 0, power: 42.5, temp: 68.3 }]), } satisfies Pick; + const temperatureServiceMock = { + getMetrics: vi.fn().mockResolvedValue(null), + } satisfies Pick; + const testModule = new MetricsResolver( cpuService, cpuTopologyServiceMock as unknown as CpuTopologyService, memoryService, + temperatureServiceMock as unknown as TemperatureService, subscriptionTracker as any, {} as any ); From 702ae9c4e9354a50ea0ce326d9ce8a2c380a5fee Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 28 Dec 2025 08:09:33 +0000 Subject: [PATCH 09/86] apparently this needs to be injectable for Nest? --- .../graph/resolvers/metrics/temperature/temperature.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index 347b9c6ce7..1a8c699fbd 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -1,4 +1,4 @@ -import { Logger, OnModuleInit } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { join } from 'path'; @@ -13,6 +13,7 @@ import { TemperatureUnit, } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; +@Injectable() export class TemperatureService implements OnModuleInit { private readonly logger = new Logger(TemperatureService.name); private readonly binPath: string; From 5a5d3d919d092b9064455bcac68e0e519470d3b3 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 28 Dec 2025 08:37:00 +0000 Subject: [PATCH 10/86] deconflict graphql name for now --- .../graph/resolvers/metrics/temperature/temperature.model.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts index fe4cb6bfae..3ebc6d544f 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts @@ -1,5 +1,3 @@ -// Location: api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts - import { Field, Float, Int, ObjectType, registerEnumType } from '@nestjs/graphql'; import { Node } from '@unraid/shared/graphql.model.js'; @@ -43,7 +41,7 @@ registerEnumType(SensorType, { description: 'Type of temperature sensor', }); -@ObjectType() +@ObjectType('TemperatureReading') export class Temperature { @Field(() => Float, { description: 'Temperature value' }) @IsNumber() From 694cf6862be379cce3f421db90bc210fcf07d2bc Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 28 Dec 2025 08:45:14 +0000 Subject: [PATCH 11/86] Deconflict graphql names --- .../metrics/temperature/temperature.model.ts | 18 ++++++++++-------- .../metrics/temperature/temperature.service.ts | 6 +++--- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts index 3ebc6d544f..59981af91c 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts @@ -1,3 +1,5 @@ +// Location: api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts + import { Field, Float, Int, ObjectType, registerEnumType } from '@nestjs/graphql'; import { Node } from '@unraid/shared/graphql.model.js'; @@ -41,8 +43,8 @@ registerEnumType(SensorType, { description: 'Type of temperature sensor', }); -@ObjectType('TemperatureReading') -export class Temperature { +@ObjectType() +export class TemperatureReading { @Field(() => Float, { description: 'Temperature value' }) @IsNumber() value!: number; @@ -74,16 +76,16 @@ export class TemperatureSensor extends Node { @IsString() location?: string; - @Field(() => Temperature, { description: 'Current temperature' }) - current!: Temperature; + @Field(() => TemperatureReading, { description: 'Current temperature' }) + current!: TemperatureReading; - @Field(() => Temperature, { nullable: true, description: 'Minimum recorded' }) + @Field(() => TemperatureReading, { nullable: true, description: 'Minimum recorded' }) @IsOptional() - min?: Temperature; + min?: TemperatureReading; - @Field(() => Temperature, { nullable: true, description: 'Maximum recorded' }) + @Field(() => TemperatureReading, { nullable: true, description: 'Maximum recorded' }) @IsOptional() - max?: Temperature; + max?: TemperatureReading; @Field(() => Float, { nullable: true, description: 'Warning threshold' }) @IsOptional() diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index 1a8c699fbd..74a55ab73b 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -6,8 +6,8 @@ import { execa } from 'execa'; import { SensorType, - Temperature, TemperatureMetrics, + TemperatureReading, TemperatureSensor, TemperatureStatus, TemperatureUnit, @@ -94,7 +94,7 @@ export class TemperatureService implements OnModuleInit { const name = match[1].trim(); const value = Number(match[2]); - const temperature: Temperature = { + const temperatureReading: TemperatureReading = { value, unit: TemperatureUnit.CELSIUS, timestamp: new Date(), @@ -105,7 +105,7 @@ export class TemperatureService implements OnModuleInit { id: `sensor:${name}`, name, type: this.inferSensorType(name), - current: temperature, + current: temperatureReading, }); } From 644b56ce55b8db4a865f22b518fa4eed89407538 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 28 Dec 2025 09:17:12 +0000 Subject: [PATCH 12/86] hardcode this file for now --- .../temperature/temperature.service.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index 74a55ab73b..b71bf79575 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -20,6 +20,7 @@ export class TemperatureService implements OnModuleInit { private availableTools: Map = new Map(); constructor(private readonly configService: ConfigService) { + // TODO(@mitchellthompkins): Make this something sensible this.binPath = this.configService.get( 'API_MONITORING_BIN_PATH', '/usr/local/emhttp/plugins/unraid-api/monitoring' @@ -31,17 +32,14 @@ export class TemperatureService implements OnModuleInit { } private async initializeBundledTools(): Promise { - const tools = ['sensors']; - - for (const tool of tools) { - const toolPath = join(this.binPath, tool); - try { - await execa(toolPath, ['--version']); - this.availableTools.set(tool, toolPath); - this.logger.log(`Temperature tool available: ${tool}`); - } catch { - this.logger.warn(`Temperature tool not found: ${tool}`); - } + const systemSensors = '/usr/bin/sensors'; + + try { + await execa(systemSensors, ['--version']); + this.availableTools.set('sensors', systemSensors); + this.logger.log(`Temperature tool available: sensors (from system path)`); + } catch (err) { + this.logger.warn(`Temperature tool not available at ${systemSensors}`, err); } } From 0063a8ae51f4e3fd2cc5c82e6772bccda712e540 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 28 Dec 2025 09:29:10 +0000 Subject: [PATCH 13/86] try a new parsing method --- .../resolvers/metrics/temperature/temperature.service.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index b71bf79575..0e9a4d677c 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -79,14 +79,12 @@ export class TemperatureService implements OnModuleInit { // ============================ // Parsing // ============================ - private parseSensorsOutput(output: string): TemperatureSensor[] { const lines = output.split('\n'); const sensors: TemperatureSensor[] = []; for (const line of lines) { - // Matches: "+52.0°C" - const match = line.match(/(.+?):\s+\+?([0-9.]+)°C/); + const match = line.match(/^(.+?):.*?\+([0-9.]+)°C/); if (!match) continue; const name = match[1].trim(); From a299d66e45aae8d5d186978e6556c712985700ee Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 29 Dec 2025 03:17:47 +0000 Subject: [PATCH 14/86] hey, this actually returns data to the graphql query --- .../graph/resolvers/metrics/temperature/temperature.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index 0e9a4d677c..36ef3025f5 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -84,7 +84,7 @@ export class TemperatureService implements OnModuleInit { const sensors: TemperatureSensor[] = []; for (const line of lines) { - const match = line.match(/^(.+?):.*?\+([0-9.]+)°C/); + const match = line.match(/^(.+?):.*?\+([0-9.]+)\s*°?C/); if (!match) continue; const name = match[1].trim(); From 88719fd9f853e29c3e14777851606f84ad65da3e Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 29 Dec 2025 06:41:06 +0000 Subject: [PATCH 15/86] Parsing json is a lot more reliable than stupid regex --- .../temperature/temperature.service.ts | 62 +++++++++++-------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index 36ef3025f5..b2d110843e 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -55,17 +55,17 @@ export class TemperatureService implements OnModuleInit { // ============================ // Public API // ============================ - async getMetrics(): Promise { if (!this.availableTools.has('sensors')) { this.logger.debug('Temperature metrics unavailable (sensors missing)'); return null; } - const output = await this.execTool('sensors', []); - const sensors = this.parseSensorsOutput(output); + const output = await this.execTool('sensors', ['-j']); + const sensors = this.parseSensorsJson(output); if (sensors.length === 0) { + this.logger.debug('No temperature sensors detected'); return null; } @@ -79,30 +79,42 @@ export class TemperatureService implements OnModuleInit { // ============================ // Parsing // ============================ - private parseSensorsOutput(output: string): TemperatureSensor[] { - const lines = output.split('\n'); + private parseSensorsJson(output: string): TemperatureSensor[] { + let data: Record; + + try { + data = JSON.parse(output); + } catch (err) { + this.logger.error('Failed to parse sensors JSON', err); + return []; + } + const sensors: TemperatureSensor[] = []; - for (const line of lines) { - const match = line.match(/^(.+?):.*?\+([0-9.]+)\s*°?C/); - if (!match) continue; - - const name = match[1].trim(); - const value = Number(match[2]); - - const temperatureReading: TemperatureReading = { - value, - unit: TemperatureUnit.CELSIUS, - timestamp: new Date(), - status: this.computeStatus(value), - }; - - sensors.push({ - id: `sensor:${name}`, - name, - type: this.inferSensorType(name), - current: temperatureReading, - }); + for (const [chipName, chip] of Object.entries(data)) { + for (const [label, values] of Object.entries(chip)) { + if (label === 'Adapter') continue; + if (typeof values !== 'object') continue; + + for (const [key, value] of Object.entries(values)) { + if (!key.endsWith('_input')) continue; + if (typeof value !== 'number') continue; + + const name = `${chipName} ${label}`; + + sensors.push({ + id: `sensor:${chipName}:${label}:${key}`, + name, + type: this.inferSensorType(name), + current: { + value, + unit: TemperatureUnit.CELSIUS, + timestamp: new Date(), + status: this.computeStatus(value), + }, + }); + } + } } return sensors; From 0a2d6be886e7d5208f8c51579682174f8ac1fa24 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Wed, 31 Dec 2025 07:13:00 +0000 Subject: [PATCH 16/86] Add some simple cacheing --- .../metrics/temperature/temperature.model.ts | 2 ++ .../metrics/temperature/temperature.service.ts | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts index 59981af91c..8edc8b19d8 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts @@ -8,6 +8,8 @@ import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator'; export enum TemperatureUnit { CELSIUS = 'CELSIUS', FAHRENHEIT = 'FAHRENHEIT', + KELVIN = 'KELVIN', + RANKINE = 'RANKINE', } registerEnumType(TemperatureUnit, { diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index b2d110843e..880528c583 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -19,6 +19,10 @@ export class TemperatureService implements OnModuleInit { private readonly binPath: string; private availableTools: Map = new Map(); + private cache: TemperatureMetrics | null = null; + private cacheTimestamp = 0; + private readonly CACHE_TTL_MS = 1000; + constructor(private readonly configService: ConfigService) { // TODO(@mitchellthompkins): Make this something sensible this.binPath = this.configService.get( @@ -56,6 +60,11 @@ export class TemperatureService implements OnModuleInit { // Public API // ============================ async getMetrics(): Promise { + const now = Date.now(); + if (this.cache && now - this.cacheTimestamp < this.CACHE_TTL_MS) { + return this.cache; + } + if (!this.availableTools.has('sensors')) { this.logger.debug('Temperature metrics unavailable (sensors missing)'); return null; @@ -69,11 +78,16 @@ export class TemperatureService implements OnModuleInit { return null; } - return { + const metrics: TemperatureMetrics = { id: 'temperature-metrics', sensors, summary: this.buildSummary(sensors), }; + + this.cache = metrics; + this.cacheTimestamp = now; + + return metrics; } // ============================ @@ -128,6 +142,7 @@ export class TemperatureService implements OnModuleInit { if (n.includes('gpu')) return SensorType.GPU; if (n.includes('nvme')) return SensorType.NVME; if (n.includes('board')) return SensorType.MOTHERBOARD; + if (n.includes('wmi')) return SensorType.MOTHERBOARD; // TODO Validate this return SensorType.CUSTOM; } From 8fc82c3e7824f193d49073859a7c973f8a3c6a20 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Thu, 1 Jan 2026 03:57:43 +0000 Subject: [PATCH 17/86] update this to use lm-sensors class --- .../temperature/temperature.service.ts | 43 ++++++++++++------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index 880528c583..eb9554eec8 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -4,6 +4,7 @@ import { join } from 'path'; import { execa } from 'execa'; +import { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm-sensors.service.js'; import { SensorType, TemperatureMetrics, @@ -16,20 +17,17 @@ import { @Injectable() export class TemperatureService implements OnModuleInit { private readonly logger = new Logger(TemperatureService.name); - private readonly binPath: string; + //private readonly binPath: string; private availableTools: Map = new Map(); private cache: TemperatureMetrics | null = null; private cacheTimestamp = 0; private readonly CACHE_TTL_MS = 1000; - constructor(private readonly configService: ConfigService) { - // TODO(@mitchellthompkins): Make this something sensible - this.binPath = this.configService.get( - 'API_MONITORING_BIN_PATH', - '/usr/local/emhttp/plugins/unraid-api/monitoring' - ); - } + constructor( + private readonly lmSensors: LmSensorsService, + private readonly configService: ConfigService + ) {} async onModuleInit() { await this.initializeBundledTools(); @@ -69,14 +67,27 @@ export class TemperatureService implements OnModuleInit { this.logger.debug('Temperature metrics unavailable (sensors missing)'); return null; } - - const output = await this.execTool('sensors', ['-j']); - const sensors = this.parseSensorsJson(output); - - if (sensors.length === 0) { - this.logger.debug('No temperature sensors detected'); - return null; - } + //const output = await this.execTool('sensors', ['-j']); + + //const sensors = this.parseSensorsJson(output); + + //if (sensors.length === 0) { + // this.logger.debug('No temperature sensors detected'); + // return null; + //} + + const rawSensors = await this.lmSensors.read(); + const sensors: TemperatureSensor[] = rawSensors.map((r) => ({ + id: r.id, + name: r.name, + type: r.type, + current: { + value: r.value, + unit: r.unit, + timestamp: new Date(), + status: this.computeStatus(r.value), + }, + })); const metrics: TemperatureMetrics = { id: 'temperature-metrics', From fe82be0c9cd30e705ddb85d12458024376898fe5 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sat, 3 Jan 2026 06:08:06 +0000 Subject: [PATCH 18/86] Need to pass LMSensorsObject For NodeJS --- api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 31d8388137..4a24fe8174 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts @@ -3,12 +3,13 @@ import { Module } from '@nestjs/common'; 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 { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm-sensors.service.js'; import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; import { ServicesModule } from '@app/unraid-api/graph/services/services.module.js'; @Module({ imports: [ServicesModule, CpuModule], - providers: [MetricsResolver, MemoryService, TemperatureService], + providers: [MetricsResolver, MemoryService, TemperatureService, LmSensorsService], exports: [MetricsResolver], }) export class MetricsModule {} From 35b5e242ede531a31118f7deb340e75daf097d06 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sat, 3 Jan 2026 06:08:47 +0000 Subject: [PATCH 19/86] Need to pass LMSensorsObject For NodeJS --- api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 4a24fe8174..31d8388137 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts @@ -3,13 +3,12 @@ import { Module } from '@nestjs/common'; 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 { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm-sensors.service.js'; import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; import { ServicesModule } from '@app/unraid-api/graph/services/services.module.js'; @Module({ imports: [ServicesModule, CpuModule], - providers: [MetricsResolver, MemoryService, TemperatureService, LmSensorsService], + providers: [MetricsResolver, MemoryService, TemperatureService], exports: [MetricsResolver], }) export class MetricsModule {} From db915a19b12afb8206616ea7c85fc1c05775642a Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sat, 3 Jan 2026 06:10:34 +0000 Subject: [PATCH 20/86] oops I forgot to check this in --- .../temperature/sensors/lm-sensors.service.ts | 67 +++++++++++++++++++ .../temperature/sensors/sensor.interface.ts | 20 ++++++ 2 files changed, 87 insertions(+) create mode 100644 api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm-sensors.service.ts create mode 100644 api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/sensor.interface.ts diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm-sensors.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm-sensors.service.ts new file mode 100644 index 0000000000..9b4a53bc36 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm-sensors.service.ts @@ -0,0 +1,67 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { execa } from 'execa'; + +import { + RawTemperatureSensor, + TemperatureSensorProvider, +} from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/sensor.interface.js'; +import { + SensorType, + TemperatureUnit, +} from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; + +@Injectable() +export class LmSensorsService implements TemperatureSensorProvider { + readonly id = 'lm-sensors'; + private readonly logger = new Logger(LmSensorsService.name); + + async isAvailable(): Promise { + try { + await execa('sensors', ['--version']); + return true; + } catch { + return false; + } + } + + async read(): Promise { + const { stdout } = await execa('sensors', ['-j']); + const data = JSON.parse(stdout); + + const sensors: RawTemperatureSensor[] = []; + + for (const [chipName, chip] of Object.entries(data)) { + for (const [label, values] of Object.entries(chip)) { + if (label === 'Adapter') continue; + if (typeof values !== 'object') continue; + + for (const [key, value] of Object.entries(values)) { + if (!key.endsWith('_input') || typeof value !== 'number') continue; + + const name = `${chipName} ${label}`; + + sensors.push({ + id: `${chipName}:${label}:${key}`, + name, + type: this.inferType(name), + value, + unit: TemperatureUnit.CELSIUS, + }); + } + } + } + + return sensors; + } + + private inferType(name: string): SensorType { + const n = name.toLowerCase(); + if (n.includes('package')) return SensorType.CPU_PACKAGE; + if (n.includes('core')) return SensorType.CPU_CORE; + if (n.includes('nvme')) return SensorType.NVME; + if (n.includes('gpu')) return SensorType.GPU; + if (n.includes('wmi')) return SensorType.MOTHERBOARD; + return SensorType.CUSTOM; + } +} diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/sensor.interface.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/sensor.interface.ts new file mode 100644 index 0000000000..1857683ace --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/sensor.interface.ts @@ -0,0 +1,20 @@ +import { + SensorType, + TemperatureUnit, +} from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; + +export interface RawTemperatureSensor { + id: string; + name: string; + type: SensorType; + value: number; + unit: TemperatureUnit; +} + +export interface TemperatureSensorProvider { + readonly id: string; + + isAvailable(): Promise; + + read(): Promise; +} From 9b350caf041f42b6a6032d33a80c611502e98915 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sat, 3 Jan 2026 06:17:56 +0000 Subject: [PATCH 21/86] idk what I was doing before but this actually passes in the LmServices object for NodeJs --- api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 31d8388137..4a24fe8174 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts @@ -3,12 +3,13 @@ import { Module } from '@nestjs/common'; 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 { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm-sensors.service.js'; import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; import { ServicesModule } from '@app/unraid-api/graph/services/services.module.js'; @Module({ imports: [ServicesModule, CpuModule], - providers: [MetricsResolver, MemoryService, TemperatureService], + providers: [MetricsResolver, MemoryService, TemperatureService, LmSensorsService], exports: [MetricsResolver], }) export class MetricsModule {} From 33150ec0b65c51f3043448f937a32cc9d7952cf8 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sat, 3 Jan 2026 06:44:44 +0000 Subject: [PATCH 22/86] Make this a bit more extensible --- .../graph/resolvers/metrics/metrics.module.ts | 7 +- .../metrics/temperature/temperature.module.ts | 16 ++ .../temperature/temperature.service.ts | 216 ++++++++---------- 3 files changed, 119 insertions(+), 120 deletions(-) create mode 100644 api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts 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 4a24fe8174..00ef567969 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts @@ -3,13 +3,12 @@ import { Module } from '@nestjs/common'; 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 { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm-sensors.service.js'; -import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; +import { TemperatureModule } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.module.js'; import { ServicesModule } from '@app/unraid-api/graph/services/services.module.js'; @Module({ - imports: [ServicesModule, CpuModule], - providers: [MetricsResolver, MemoryService, TemperatureService, LmSensorsService], + imports: [ServicesModule, CpuModule, TemperatureModule], + providers: [MetricsResolver, MemoryService], exports: [MetricsResolver], }) export class MetricsModule {} diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts new file mode 100644 index 0000000000..5ab285c1f6 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts @@ -0,0 +1,16 @@ +// temperature/temperature.module.ts +import { Module } from '@nestjs/common'; + +import { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm-sensors.service.js'; +import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; + +@Module({ + providers: [ + TemperatureService, + LmSensorsService, + // (@mitchellthompkins) Add other services here + // GpuSensorsService, + ], + exports: [TemperatureService], +}) +export class TemperatureModule {} diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index eb9554eec8..90829b6106 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -5,6 +5,10 @@ import { join } from 'path'; import { execa } from 'execa'; import { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm-sensors.service.js'; +import { + RawTemperatureSensor, + TemperatureSensorProvider, +} from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/sensor.interface.js'; import { SensorType, TemperatureMetrics, @@ -14,167 +18,147 @@ import { TemperatureUnit, } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; +// temperature.service.ts @Injectable() export class TemperatureService implements OnModuleInit { private readonly logger = new Logger(TemperatureService.name); - //private readonly binPath: string; - private availableTools: Map = new Map(); + private availableProviders: TemperatureSensorProvider[] = []; private cache: TemperatureMetrics | null = null; private cacheTimestamp = 0; private readonly CACHE_TTL_MS = 1000; constructor( + // Inject all available sensor providers private readonly lmSensors: LmSensorsService, + // Future: private readonly gpuSensors: GpuSensorsService, + // Future: private readonly diskSensors: DiskSensorsService, private readonly configService: ConfigService ) {} async onModuleInit() { - await this.initializeBundledTools(); + // Initialize all providers and check availability + await this.initializeProviders(); } - private async initializeBundledTools(): Promise { - const systemSensors = '/usr/bin/sensors'; - - try { - await execa(systemSensors, ['--version']); - this.availableTools.set('sensors', systemSensors); - this.logger.log(`Temperature tool available: sensors (from system path)`); - } catch (err) { - this.logger.warn(`Temperature tool not available at ${systemSensors}`, err); + private async initializeProviders(): Promise { + const potentialProviders = [ + this.lmSensors, + // Future: this.gpuSensors, + // Future: this.diskSensors, + ]; + + for (const provider of potentialProviders) { + try { + if (await provider.isAvailable()) { + this.availableProviders.push(provider); + this.logger.log(`Temperature provider available: ${provider.id}`); + } else { + this.logger.debug(`Temperature provider not available: ${provider.id}`); + } + } catch (err) { + this.logger.warn(`Failed to check provider ${provider.id}`, err); + } } - } - private async execTool(toolName: string, args: string[]): Promise { - const toolPath = this.availableTools.get(toolName); - if (!toolPath) { - throw new Error(`Tool ${toolName} not available`); + if (this.availableProviders.length === 0) { + this.logger.warn('No temperature providers available'); } - const { stdout } = await execa(toolPath, args); - return stdout; } - // ============================ - // Public API - // ============================ async getMetrics(): Promise { const now = Date.now(); if (this.cache && now - this.cacheTimestamp < this.CACHE_TTL_MS) { return this.cache; } - if (!this.availableTools.has('sensors')) { - this.logger.debug('Temperature metrics unavailable (sensors missing)'); + if (this.availableProviders.length === 0) { + this.logger.debug('Temperature metrics unavailable (no providers)'); return null; } - //const output = await this.execTool('sensors', ['-j']); - - //const sensors = this.parseSensorsJson(output); - - //if (sensors.length === 0) { - // this.logger.debug('No temperature sensors detected'); - // return null; - //} - - const rawSensors = await this.lmSensors.read(); - const sensors: TemperatureSensor[] = rawSensors.map((r) => ({ - id: r.id, - name: r.name, - type: r.type, - current: { - value: r.value, - unit: r.unit, - timestamp: new Date(), - status: this.computeStatus(r.value), - }, - })); - - const metrics: TemperatureMetrics = { - id: 'temperature-metrics', - sensors, - summary: this.buildSummary(sensors), - }; - - this.cache = metrics; - this.cacheTimestamp = now; - - return metrics; - } - - // ============================ - // Parsing - // ============================ - private parseSensorsJson(output: string): TemperatureSensor[] { - let data: Record; try { - data = JSON.parse(output); - } catch (err) { - this.logger.error('Failed to parse sensors JSON', err); - return []; - } - - const sensors: TemperatureSensor[] = []; - - for (const [chipName, chip] of Object.entries(data)) { - for (const [label, values] of Object.entries(chip)) { - if (label === 'Adapter') continue; - if (typeof values !== 'object') continue; - - for (const [key, value] of Object.entries(values)) { - if (!key.endsWith('_input')) continue; - if (typeof value !== 'number') continue; - - const name = `${chipName} ${label}`; - - sensors.push({ - id: `sensor:${chipName}:${label}:${key}`, - name, - type: this.inferSensorType(name), - current: { - value, - unit: TemperatureUnit.CELSIUS, - timestamp: new Date(), - status: this.computeStatus(value), - }, - }); + // Collect sensors from ALL available providers + const allRawSensors: RawTemperatureSensor[] = []; + + for (const provider of this.availableProviders) { + try { + const sensors = await provider.read(); + allRawSensors.push(...sensors); + } catch (err) { + this.logger.error(`Failed to read from provider ${provider.id}`, err); + // Continue with other providers } } - } - return sensors; - } - - private inferSensorType(name: string): SensorType { - const n = name.toLowerCase(); - - if (n.includes('package')) return SensorType.CPU_PACKAGE; - if (n.includes('core')) return SensorType.CPU_CORE; - if (n.includes('gpu')) return SensorType.GPU; - if (n.includes('nvme')) return SensorType.NVME; - if (n.includes('board')) return SensorType.MOTHERBOARD; - if (n.includes('wmi')) return SensorType.MOTHERBOARD; // TODO Validate this + if (allRawSensors.length === 0) { + this.logger.debug('No temperature sensors detected'); + return null; + } - return SensorType.CUSTOM; + const sensors: TemperatureSensor[] = allRawSensors.map((r) => ({ + id: r.id, + name: r.name, + type: r.type, + current: { + value: r.value, + unit: r.unit, + timestamp: new Date(), + status: this.computeStatus(r.value, r.type), + }, + })); + + const metrics: TemperatureMetrics = { + id: 'temperature-metrics', + sensors, + summary: this.buildSummary(sensors), + }; + + this.cache = metrics; + this.cacheTimestamp = now; + + return metrics; + } catch (err) { + this.logger.error('Failed to read temperature sensors', err); + return null; + } } - private computeStatus(value: number): TemperatureStatus { - if (value >= 90) return TemperatureStatus.CRITICAL; - if (value >= 80) return TemperatureStatus.WARNING; + // Make status computation type-aware for future per-type thresholds + private computeStatus(value: number, type: SensorType): TemperatureStatus { + // Future: load thresholds from config based on type + const thresholds = this.getThresholdsForType(type); + + if (value >= thresholds.critical) return TemperatureStatus.CRITICAL; + if (value >= thresholds.warning) return TemperatureStatus.WARNING; return TemperatureStatus.NORMAL; } - // ============================ - // Summary - // ============================ + private getThresholdsForType(type: SensorType): { warning: number; critical: number } { + // Future: load from configService + // For now, use sensible defaults per type + switch (type) { + case SensorType.CPU_PACKAGE: + case SensorType.CPU_CORE: + return { warning: 70, critical: 85 }; + case SensorType.GPU: + return { warning: 80, critical: 90 }; + case SensorType.DISK: + case SensorType.NVME: + return { warning: 50, critical: 60 }; + default: + return { warning: 80, critical: 90 }; + } + } private buildSummary(sensors: TemperatureSensor[]) { - const values = sensors.map((s) => s.current.value); + if (sensors.length === 0) { + throw new Error('Cannot build summary with no sensors'); + } + const values = sensors.map((s) => s.current.value); const average = values.reduce((a, b) => a + b, 0) / values.length; - const hottest = sensors.reduce((a, b) => (a.current.value > b.current.value ? a : b)); - const coolest = sensors.reduce((a, b) => (a.current.value < b.current.value ? a : b)); return { From 7c9717faaafacec3e7fd6b634792b9aefa5c730a Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sat, 3 Jan 2026 06:58:47 +0000 Subject: [PATCH 23/86] rename this file b/c I don't like underscores --- .../sensors/{lm-sensors.service.ts => lm_sensors.service.ts} | 0 .../graph/resolvers/metrics/temperature/temperature.module.ts | 2 +- .../graph/resolvers/metrics/temperature/temperature.service.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/{lm-sensors.service.ts => lm_sensors.service.ts} (100%) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm-sensors.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.ts similarity index 100% rename from api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm-sensors.service.ts rename to api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.ts diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts index 5ab285c1f6..a073f14591 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts @@ -1,7 +1,7 @@ // temperature/temperature.module.ts import { Module } from '@nestjs/common'; -import { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm-sensors.service.js'; +import { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.js'; import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; @Module({ diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index 90829b6106..a0edfe1857 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -4,7 +4,7 @@ import { join } from 'path'; import { execa } from 'execa'; -import { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm-sensors.service.js'; +import { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.js'; import { RawTemperatureSensor, TemperatureSensorProvider, From f576460cd502978a188cd55fa45e9b09d5894c4c Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sat, 3 Jan 2026 07:05:04 +0000 Subject: [PATCH 24/86] Add a disk sensor service as a wrapper --- .../sensors/disk_sensors.service.ts | 63 +++++++++++++++++++ .../metrics/temperature/temperature.module.ts | 4 ++ .../temperature/temperature.service.ts | 7 ++- 3 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.ts diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.ts new file mode 100644 index 0000000000..1ab741a6ab --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.ts @@ -0,0 +1,63 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.service.js'; +import { + RawTemperatureSensor, + TemperatureSensorProvider, +} from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/sensor.interface.js'; +import { + SensorType, + TemperatureUnit, +} from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; + +@Injectable() +export class DiskSensorsService implements TemperatureSensorProvider { + readonly id = 'disk-sensors'; + private readonly logger = new Logger(DiskSensorsService.name); + + constructor(private readonly disksService: DisksService) {} + + async isAvailable(): Promise { + // Disks are always "available" since DisksService exists + try { + const disks = await this.disksService.getDisks(); + return disks.length > 0; + } catch { + return false; + } + } + + async read(): Promise { + const disks = await this.disksService.getDisks(); + const sensors: RawTemperatureSensor[] = []; + + for (const disk of disks) { + try { + const temp = await this.disksService.getTemperature(disk.device); + + if (temp !== null) { + sensors.push({ + id: `disk:${disk.id}`, + name: disk.name || disk.device, + type: this.inferDiskType(disk.interfaceType), + value: temp, + unit: TemperatureUnit.CELSIUS, + }); + } + } catch (err) { + this.logger.warn(`Failed to get temperature for disk ${disk.device}`, err); + // Continue with other disks + } + } + + return sensors; + } + + private inferDiskType(interfaceType?: string): SensorType { + const type = interfaceType?.toLowerCase(); + if (type === 'nvme' || type === 'pcie') { + return SensorType.NVME; + } + return SensorType.DISK; + } +} diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts index a073f14591..e7a715b92f 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts @@ -1,13 +1,17 @@ // temperature/temperature.module.ts import { Module } from '@nestjs/common'; +import { DisksModule } from '@app/unraid-api/graph/resolvers/disks/disks.module.js'; +import { DiskSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.js'; import { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.js'; import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; @Module({ + imports: [DisksModule], providers: [ TemperatureService, LmSensorsService, + DiskSensorsService, // (@mitchellthompkins) Add other services here // GpuSensorsService, ], diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index a0edfe1857..4a98f0b104 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -4,6 +4,7 @@ import { join } from 'path'; import { execa } from 'execa'; +import { DiskSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.js'; import { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.js'; import { RawTemperatureSensor, @@ -31,6 +32,8 @@ export class TemperatureService implements OnModuleInit { constructor( // Inject all available sensor providers private readonly lmSensors: LmSensorsService, + private readonly diskSensors: DiskSensorsService, + // Future: private readonly gpuSensors: GpuSensorsService, // Future: private readonly diskSensors: DiskSensorsService, private readonly configService: ConfigService @@ -44,8 +47,8 @@ export class TemperatureService implements OnModuleInit { private async initializeProviders(): Promise { const potentialProviders = [ this.lmSensors, - // Future: this.gpuSensors, - // Future: this.diskSensors, + this.diskSensors, + // TODO(@mitchellthompkins): this.gpuSensors, ]; for (const provider of potentialProviders) { From 8736b0d32fca3033c9f1301f663bea869a9bea6b Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 4 Jan 2026 07:40:39 +0000 Subject: [PATCH 25/86] claude tells me this needs to be an export as well --- api/src/unraid-api/graph/resolvers/disks/disks.module.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.module.ts b/api/src/unraid-api/graph/resolvers/disks/disks.module.ts index 30e704d997..d5d361b0f1 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.module.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.module.ts @@ -5,6 +5,6 @@ import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.servic @Module({ providers: [DisksResolver, DisksService], - exports: [DisksResolver], + exports: [DisksResolver, DisksService], }) export class DisksModule {} From 69493bd52859937b538b6afda93ed96e4af8c2bb Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Thu, 8 Jan 2026 08:36:59 +0000 Subject: [PATCH 26/86] First pass at adding history --- .../metrics/temperature/temperature.model.ts | 7 + .../temperature/temperature.service.ts | 91 ++++++-- .../temperature_history.service.ts | 195 ++++++++++++++++++ 3 files changed, 274 insertions(+), 19 deletions(-) create mode 100644 api/src/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.ts diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts index 8edc8b19d8..36128b7179 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts @@ -98,6 +98,13 @@ export class TemperatureSensor extends Node { @IsOptional() @IsNumber() critical?: number; + + @Field(() => [TemperatureReading], { + nullable: true, + description: 'Historical readings for this sensor', + }) + @IsOptional() + history?: TemperatureReading[]; } @ObjectType() diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index 4a98f0b104..f17e093d22 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -10,6 +10,7 @@ import { RawTemperatureSensor, TemperatureSensorProvider, } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/sensor.interface.js'; +import { TemperatureHistoryService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.js'; import { SensorType, TemperatureMetrics, @@ -36,6 +37,7 @@ export class TemperatureService implements OnModuleInit { // Future: private readonly gpuSensors: GpuSensorsService, // Future: private readonly diskSensors: DiskSensorsService, + private readonly history: TemperatureHistoryService, private readonly configService: ConfigService ) {} @@ -70,18 +72,23 @@ export class TemperatureService implements OnModuleInit { } async getMetrics(): Promise { - const now = Date.now(); - if (this.cache && now - this.cacheTimestamp < this.CACHE_TTL_MS) { - return this.cache; + // Check if we can use recent history instead of re-reading sensors + const mostRecent = this.history.getMostRecentReading(); + const canUseHistory = + mostRecent && Date.now() - mostRecent.timestamp.getTime() < this.CACHE_TTL_MS; + + if (canUseHistory) { + // Build from history (fast path) + return this.buildMetricsFromHistory(); } + // Read fresh data from sensors if (this.availableProviders.length === 0) { this.logger.debug('Temperature metrics unavailable (no providers)'); return null; } try { - // Collect sensors from ALL available providers const allRawSensors: RawTemperatureSensor[] = []; for (const provider of this.availableProviders) { @@ -90,7 +97,6 @@ export class TemperatureService implements OnModuleInit { allRawSensors.push(...sensors); } catch (err) { this.logger.error(`Failed to read from provider ${provider.id}`, err); - // Continue with other providers } } @@ -99,34 +105,81 @@ export class TemperatureService implements OnModuleInit { return null; } - const sensors: TemperatureSensor[] = allRawSensors.map((r) => ({ - id: r.id, - name: r.name, - type: r.type, - current: { + const sensors: TemperatureSensor[] = allRawSensors.map((r) => { + const current: TemperatureReading = { value: r.value, unit: r.unit, timestamp: new Date(), status: this.computeStatus(r.value, r.type), - }, - })); - - const metrics: TemperatureMetrics = { + }; + + // Record in history + this.history.record(r.id, current, { + name: r.name, + type: r.type, + }); + + // Get historical data + const { min, max } = this.history.getMinMax(r.id); + const historicalReadings = this.history.getHistory(r.id); + + return { + id: r.id, + name: r.name, + type: r.type, + current, + min, + max, + history: historicalReadings, + warning: this.getThresholdsForType(r.type).warning, + critical: this.getThresholdsForType(r.type).critical, + }; + }); + + return { id: 'temperature-metrics', sensors, summary: this.buildSummary(sensors), }; - - this.cache = metrics; - this.cacheTimestamp = now; - - return metrics; } catch (err) { this.logger.error('Failed to read temperature sensors', err); return null; } } + private buildMetricsFromHistory(): TemperatureMetrics | null { + const allSensorIds = this.history.getAllSensorIds(); + + if (allSensorIds.length === 0) { + return null; + } + + const sensors: TemperatureSensor[] = allSensorIds.map((sensorId) => { + const { min, max } = this.history.getMinMax(sensorId); + const historicalReadings = this.history.getHistory(sensorId); + const current = historicalReadings[historicalReadings.length - 1]; + const metadata = this.history.getMetadata(sensorId)!; + + return { + id: sensorId, + name: metadata.name, + type: metadata.type, + current, + min, + max, + history: historicalReadings, + warning: this.getThresholdsForType(metadata.type).warning, + critical: this.getThresholdsForType(metadata.type).critical, + }; + }); + + return { + id: 'temperature-metrics', + sensors, + summary: this.buildSummary(sensors), + }; + } + // Make status computation type-aware for future per-type thresholds private computeStatus(value: number, type: SensorType): TemperatureStatus { // Future: load thresholds from config based on type diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.ts new file mode 100644 index 0000000000..43d51ead0b --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.ts @@ -0,0 +1,195 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { + SensorType, + TemperatureReading, +} from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; + +interface SensorMetadata { + name: string; + type: SensorType; +} + +interface SensorHistory { + sensorId: string; + metadata: SensorMetadata; + readings: TemperatureReading[]; + min?: TemperatureReading; + max?: TemperatureReading; +} + +@Injectable() +export class TemperatureHistoryService { + private readonly logger = new Logger(TemperatureHistoryService.name); + private history: Map = new Map(); + + // Configurable limits + private readonly maxReadingsPerSensor: number; + private readonly retentionMs: number; + + constructor(private readonly configService: ConfigService) { + // TODO: Load from config later + this.maxReadingsPerSensor = 1000; + this.retentionMs = 86400000; // 24 hours in ms + } + + /** + * Record a new reading with metadata + */ + record(sensorId: string, reading: TemperatureReading, metadata: SensorMetadata): void { + let sensorHistory = this.history.get(sensorId); + + if (!sensorHistory) { + sensorHistory = { + sensorId, + metadata, + readings: [], + min: undefined, + max: undefined, + }; + this.history.set(sensorId, sensorHistory); + } + + // Update metadata (in case name changed) + sensorHistory.metadata = metadata; + + // Add the reading + sensorHistory.readings.push(reading); + + // Update min/max + if (!sensorHistory.min || reading.value < sensorHistory.min.value) { + sensorHistory.min = reading; + } + if (!sensorHistory.max || reading.value > sensorHistory.max.value) { + sensorHistory.max = reading; + } + + // Trim old readings + this.trimOldReadings(sensorHistory); + } + + /** + * Get min/max for a sensor + */ + getMinMax(sensorId: string): { min?: TemperatureReading; max?: TemperatureReading } { + const history = this.history.get(sensorId); + return history ? { min: history.min, max: history.max } : {}; + } + + /** + * Get all historical readings for a sensor + */ + getHistory(sensorId: string): TemperatureReading[] { + const history = this.history.get(sensorId); + return history ? [...history.readings] : []; + } + + /** + * Get sensor metadata + */ + getMetadata(sensorId: string): SensorMetadata | null { + return this.history.get(sensorId)?.metadata || null; + } + + /** + * Get all tracked sensor IDs + */ + getAllSensorIds(): string[] { + return Array.from(this.history.keys()); + } + + /** + * Get the most recent reading across all sensors + */ + getMostRecentReading(): TemperatureReading | null { + let newest: TemperatureReading | null = null; + + for (const sensorHistory of this.history.values()) { + if (sensorHistory.readings.length === 0) continue; + + const lastReading = sensorHistory.readings[sensorHistory.readings.length - 1]; + + if (!newest || lastReading.timestamp > newest.timestamp) { + newest = lastReading; + } + } + + return newest; + } + + /** + * Get statistics + */ + getStats(): { totalSensors: number; totalReadings: number } { + let totalReadings = 0; + + for (const history of this.history.values()) { + totalReadings += history.readings.length; + } + + return { + totalSensors: this.history.size, + totalReadings, + }; + } + + /** + * Clear history + */ + clear(sensorId?: string): void { + if (sensorId) { + this.history.delete(sensorId); + } else { + this.history.clear(); + } + } + + // ============================ + // Private Methods + // ============================ + + private trimOldReadings(sensorHistory: SensorHistory): void { + const now = Date.now(); + const cutoffTime = new Date(now - this.retentionMs); + + // Remove readings older than retention period + sensorHistory.readings = sensorHistory.readings.filter((r) => r.timestamp >= cutoffTime); + + // Keep only maxReadingsPerSensor most recent readings + if (sensorHistory.readings.length > this.maxReadingsPerSensor) { + sensorHistory.readings = sensorHistory.readings.slice(-this.maxReadingsPerSensor); + } + + // Recalculate min/max if we trimmed + if (sensorHistory.readings.length > 0) { + this.recalculateMinMax(sensorHistory); + } else { + sensorHistory.min = undefined; + sensorHistory.max = undefined; + } + } + + private recalculateMinMax(sensorHistory: SensorHistory): void { + if (sensorHistory.readings.length === 0) { + sensorHistory.min = undefined; + sensorHistory.max = undefined; + return; + } + + let min = sensorHistory.readings[0]; + let max = sensorHistory.readings[0]; + + for (const reading of sensorHistory.readings) { + if (reading.value < min.value) { + min = reading; + } + if (reading.value > max.value) { + max = reading; + } + } + + sensorHistory.min = min; + sensorHistory.max = max; + } +} From ab7da0caaf5fb7455d1d8f9015ac5bea0c5cc0ba Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Thu, 8 Jan 2026 08:44:43 +0000 Subject: [PATCH 27/86] Forgot to add this to the service --- .../graph/resolvers/metrics/temperature/temperature.module.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts index e7a715b92f..796fbe2d7d 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts @@ -4,6 +4,7 @@ import { Module } from '@nestjs/common'; import { DisksModule } from '@app/unraid-api/graph/resolvers/disks/disks.module.js'; import { DiskSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.js'; import { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.js'; +import { TemperatureHistoryService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.js'; import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; @Module({ @@ -14,6 +15,7 @@ import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temp DiskSensorsService, // (@mitchellthompkins) Add other services here // GpuSensorsService, + TemperatureHistoryService, ], exports: [TemperatureService], }) From 61a054dc5e59c0eb017ddb41871c7ed4a94dd651 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sat, 10 Jan 2026 06:55:20 +0000 Subject: [PATCH 28/86] try to add basic config service --- .../graph/resolvers/metrics/metrics.resolver.ts | 9 ++++++--- .../metrics/temperature/temperature.service.ts | 16 ++++++++++------ .../temperature/temperature_history.service.ts | 13 ++++++++++--- 3 files changed, 26 insertions(+), 12 deletions(-) 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 15fb2d0f90..d8a8f65673 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts @@ -1,4 +1,5 @@ import { Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; @@ -25,7 +26,8 @@ export class MetricsResolver implements OnModuleInit { private readonly memoryService: MemoryService, private readonly temperatureService: TemperatureService, private readonly subscriptionTracker: SubscriptionTrackerService, - private readonly subscriptionHelper: SubscriptionHelperService + private readonly subscriptionHelper: SubscriptionHelperService, + private readonly configService: ConfigService ) {} onModuleInit() { @@ -81,7 +83,8 @@ export class MetricsResolver implements OnModuleInit { 2000 ); - // Add temperature polling with 5 second interval + const pollingInterval = this.configService.get('temperature.pollingInterval', 5000); + this.subscriptionTracker.registerTopic( PUBSUB_CHANNEL.TEMPERATURE_METRICS, async () => { @@ -90,7 +93,7 @@ export class MetricsResolver implements OnModuleInit { systemMetricsTemperature: payload, }); }, - 5000 + pollingInterval ); } diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index f17e093d22..aadc80d4e5 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -191,17 +191,21 @@ export class TemperatureService implements OnModuleInit { } private getThresholdsForType(type: SensorType): { warning: number; critical: number } { - // Future: load from configService - // For now, use sensible defaults per type + const thresholds = this.configService.get('temperature.thresholds', {}); + switch (type) { case SensorType.CPU_PACKAGE: case SensorType.CPU_CORE: - return { warning: 70, critical: 85 }; - case SensorType.GPU: - return { warning: 80, critical: 90 }; + return { + warning: thresholds.cpu_warning ?? 70, + critical: thresholds.cpu_critical ?? 85, + }; case SensorType.DISK: case SensorType.NVME: - return { warning: 50, critical: 60 }; + return { + warning: thresholds.disk_warning ?? 50, + critical: thresholds.disk_critical ?? 60, + }; default: return { warning: 80, critical: 90 }; } diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.ts index 43d51ead0b..3beda26998 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.ts @@ -29,9 +29,16 @@ export class TemperatureHistoryService { private readonly retentionMs: number; constructor(private readonly configService: ConfigService) { - // TODO: Load from config later - this.maxReadingsPerSensor = 1000; - this.retentionMs = 86400000; // 24 hours in ms + this.maxReadingsPerSensor = this.configService.get( + 'temperature.history.maxReadings', + 1000 + ); + + this.retentionMs = this.configService.get('temperature.history.retentionMs', 86400000); + + this.logger.log( + `Temperature history configured: maxReadings=${this.maxReadingsPerSensor}, retentionMs=${this.retentionMs}ms` + ); } /** From efd0e1f276f8508ddee8c3d9578a222e20b6ccab Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 11 Jan 2026 07:36:17 +0000 Subject: [PATCH 29/86] add this config service control --- .../resolvers/metrics/metrics.resolver.spec.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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 09a36058a2..098dd7e622 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 @@ -1,4 +1,5 @@ import type { TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; import { Test } from '@nestjs/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -84,6 +85,12 @@ describe('MetricsResolver', () => { createTrackedSubscription: vi.fn(), }, }, + { + provide: ConfigService, + useValue: { + get: vi.fn((key: string, defaultValue?: any) => defaultValue), + }, + }, ], }).compile(); @@ -174,13 +181,18 @@ describe('MetricsResolver', () => { getMetrics: vi.fn().mockResolvedValue(null), } satisfies Pick; + const configServiceMock = { + get: vi.fn((key: string, defaultValue?: any) => defaultValue), + }; + const testModule = new MetricsResolver( cpuService, cpuTopologyServiceMock as unknown as CpuTopologyService, memoryService, temperatureServiceMock as unknown as TemperatureService, subscriptionTracker as any, - {} as any + {} as any, + configServiceMock as any ); testModule.onModuleInit(); From dcefb9828cc3a093e747924335568a4a78ac8740 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 11 Jan 2026 19:09:17 +0000 Subject: [PATCH 30/86] adding unit tests --- .../metrics.resolver.integration.spec.ts | 7 + .../temperature-history.service.spec.ts | 237 ++++++++++++++++++ .../temperature/temperature.service.spec.ts | 197 +++++++++++++++ 3 files changed, 441 insertions(+) create mode 100644 api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-history.service.spec.ts create mode 100644 api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts 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 12b899a094..a2828fdb47 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 @@ -9,6 +9,7 @@ import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu 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'; +import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; import { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js'; import { SubscriptionManagerService } from '@app/unraid-api/graph/services/subscription-manager.service.js'; import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js'; @@ -25,6 +26,12 @@ describe('MetricsResolver Integration Tests', () => { CpuService, CpuTopologyService, MemoryService, + { + provide: TemperatureService, + useValue: { + getMetrics: vi.fn().mockResolvedValue(null), + }, + }, SubscriptionTrackerService, SubscriptionHelperService, SubscriptionManagerService, diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-history.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-history.service.spec.ts new file mode 100644 index 0000000000..03428713c2 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-history.service.spec.ts @@ -0,0 +1,237 @@ +import { ConfigService } from '@nestjs/config'; + +import { beforeEach, describe, expect, it } from 'vitest'; + +import { TemperatureHistoryService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.js'; +import { + SensorType, + TemperatureStatus, + TemperatureUnit, +} from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; + +describe('TemperatureHistoryService', () => { + let service: TemperatureHistoryService; + let configService: ConfigService; + + beforeEach(() => { + configService = { + get: (key: string, defaultValue?: any) => defaultValue, + } as any; + + service = new TemperatureHistoryService(configService); + }); + + describe('record and retrieval', () => { + it('should record a reading and retrieve it', () => { + const reading = { + value: 45.5, + unit: TemperatureUnit.CELSIUS, + timestamp: new Date(), + status: TemperatureStatus.NORMAL, + }; + + service.record('sensor1', reading, { + name: 'CPU Package', + type: SensorType.CPU_PACKAGE, + }); + + const history = service.getHistory('sensor1'); + expect(history).toHaveLength(1); + expect(history[0].value).toBe(45.5); + }); + + it('should return empty array for unknown sensor', () => { + const history = service.getHistory('unknown'); + expect(history).toEqual([]); + }); + }); + + describe('min/max tracking', () => { + it('should track minimum temperature', () => { + const metadata = { name: 'CPU', type: SensorType.CPU_CORE }; + + service.record( + 'sensor1', + { + value: 50, + unit: TemperatureUnit.CELSIUS, + timestamp: new Date(), + status: TemperatureStatus.NORMAL, + }, + metadata + ); + + service.record( + 'sensor1', + { + value: 45, + unit: TemperatureUnit.CELSIUS, + timestamp: new Date(), + status: TemperatureStatus.NORMAL, + }, + metadata + ); + + service.record( + 'sensor1', + { + value: 55, + unit: TemperatureUnit.CELSIUS, + timestamp: new Date(), + status: TemperatureStatus.NORMAL, + }, + metadata + ); + + const { min, max } = service.getMinMax('sensor1'); + expect(min?.value).toBe(45); + expect(max?.value).toBe(55); + }); + + it('should return empty object for unknown sensor', () => { + const result = service.getMinMax('unknown'); + expect(result).toEqual({}); + }); + }); + + describe('retention and trimming', () => { + it('should keep only max readings per sensor', () => { + const configServiceWithLimit = { + get: (key: string, defaultValue?: any) => { + if (key === 'temperature.history.maxReadings') return 3; + return defaultValue; + }, + } as any; + + const limitedService = new TemperatureHistoryService(configServiceWithLimit); + const metadata = { name: 'CPU', type: SensorType.CPU_CORE }; + + // Add 5 readings + for (let i = 0; i < 5; i++) { + limitedService.record( + 'sensor1', + { + value: 40 + i, + unit: TemperatureUnit.CELSIUS, + timestamp: new Date(Date.now() + i * 1000), + status: TemperatureStatus.NORMAL, + }, + metadata + ); + } + + const history = limitedService.getHistory('sensor1'); + expect(history).toHaveLength(3); + // Should keep the most recent 3 + expect(history[0].value).toBe(42); + expect(history[2].value).toBe(44); + }); + }); + + describe('metadata', () => { + it('should store and retrieve sensor metadata', () => { + service.record( + 'sensor1', + { + value: 50, + unit: TemperatureUnit.CELSIUS, + timestamp: new Date(), + status: TemperatureStatus.NORMAL, + }, + { + name: 'CPU Package', + type: SensorType.CPU_PACKAGE, + } + ); + + const metadata = service.getMetadata('sensor1'); + expect(metadata?.name).toBe('CPU Package'); + expect(metadata?.type).toBe(SensorType.CPU_PACKAGE); + }); + + it('should return null for unknown sensor', () => { + const metadata = service.getMetadata('unknown'); + expect(metadata).toBeNull(); + }); + }); + + describe('getMostRecentReading', () => { + it('should return the newest reading across all sensors', () => { + const metadata = { name: 'Sensor', type: SensorType.CUSTOM }; + const now = Date.now(); + + service.record( + 'sensor1', + { + value: 40, + unit: TemperatureUnit.CELSIUS, + timestamp: new Date(now - 1000), + status: TemperatureStatus.NORMAL, + }, + metadata + ); + + service.record( + 'sensor2', + { + value: 50, + unit: TemperatureUnit.CELSIUS, + timestamp: new Date(now), + status: TemperatureStatus.NORMAL, + }, + metadata + ); + + const newest = service.getMostRecentReading(); + expect(newest?.value).toBe(50); + }); + + it('should return null when no readings exist', () => { + const newest = service.getMostRecentReading(); + expect(newest).toBeNull(); + }); + }); + + describe('stats', () => { + it('should return correct statistics', () => { + const metadata = { name: 'Sensor', type: SensorType.CUSTOM }; + + service.record( + 'sensor1', + { + value: 40, + unit: TemperatureUnit.CELSIUS, + timestamp: new Date(), + status: TemperatureStatus.NORMAL, + }, + metadata + ); + + service.record( + 'sensor1', + { + value: 45, + unit: TemperatureUnit.CELSIUS, + timestamp: new Date(), + status: TemperatureStatus.NORMAL, + }, + metadata + ); + + service.record( + 'sensor2', + { + value: 50, + unit: TemperatureUnit.CELSIUS, + timestamp: new Date(), + status: TemperatureStatus.NORMAL, + }, + metadata + ); + + const stats = service.getStats(); + expect(stats.totalSensors).toBe(2); + expect(stats.totalReadings).toBe(3); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts new file mode 100644 index 0000000000..f702770549 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts @@ -0,0 +1,197 @@ +import { ConfigService } from '@nestjs/config'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DiskSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.js'; +import { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.js'; +import { TemperatureHistoryService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.js'; +import { + SensorType, + TemperatureStatus, + TemperatureUnit, +} from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; +import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; + +describe('TemperatureService', () => { + let service: TemperatureService; + let lmSensors: LmSensorsService; + let diskSensors: DiskSensorsService; + let history: TemperatureHistoryService; + let configService: ConfigService; + + beforeEach(async () => { + lmSensors = { + id: 'lm-sensors', + isAvailable: vi.fn().mockResolvedValue(true), + read: vi.fn().mockResolvedValue([ + { + id: 'cpu:package', + name: 'CPU Package', + type: SensorType.CPU_PACKAGE, + value: 55, + unit: TemperatureUnit.CELSIUS, + }, + ]), + } as any; + + diskSensors = { + id: 'disk-sensors', + isAvailable: vi.fn().mockResolvedValue(true), + read: vi.fn().mockResolvedValue([]), + } as any; + + configService = { + get: vi.fn((key: string, defaultValue?: any) => defaultValue), + } as any; + + history = new TemperatureHistoryService(configService); + + service = new TemperatureService(lmSensors, diskSensors, history, configService); + }); + + describe('initialization', () => { + it('should initialize available providers', async () => { + await service.onModuleInit(); + + expect(lmSensors.isAvailable).toHaveBeenCalled(); + expect(diskSensors.isAvailable).toHaveBeenCalled(); + }); + + it('should handle provider initialization errors gracefully', async () => { + vi.mocked(lmSensors.isAvailable).mockRejectedValue(new Error('Failed')); + + await service.onModuleInit(); + + // Should not throw + const metrics = await service.getMetrics(); + expect(metrics).toBeDefined(); + }); + }); + + describe('getMetrics', () => { + beforeEach(async () => { + await service.onModuleInit(); + }); + + it('should return temperature metrics', async () => { + const metrics = await service.getMetrics(); + + expect(metrics).toBeDefined(); + expect(metrics?.sensors).toHaveLength(1); + expect(metrics?.sensors[0].name).toBe('CPU Package'); + expect(metrics?.sensors[0].current.value).toBe(55); + }); + + it('should return null when no providers available', async () => { + vi.mocked(lmSensors.isAvailable).mockResolvedValue(false); + vi.mocked(diskSensors.isAvailable).mockResolvedValue(false); + + const emptyService = new TemperatureService(lmSensors, diskSensors, history, configService); + await emptyService.onModuleInit(); + + const metrics = await emptyService.getMetrics(); + expect(metrics).toBeNull(); + }); + + it('should compute correct status based on thresholds', async () => { + vi.mocked(lmSensors.read).mockResolvedValue([ + { + id: 'cpu:hot', + name: 'Hot CPU', + type: SensorType.CPU_CORE, + value: 85, + unit: TemperatureUnit.CELSIUS, + }, + ]); + + const metrics = await service.getMetrics(); + expect(metrics?.sensors[0].current.status).toBe(TemperatureStatus.WARNING); + }); + + it('should use config thresholds when provided', async () => { + const customConfigService = { + get: vi.fn((key: string, defaultValue?: any) => { + if (key === 'temperature.thresholds') { + return { cpu_warning: 60, cpu_critical: 80 }; + } + return defaultValue; + }), + } as any; + + const customService = new TemperatureService( + lmSensors, + diskSensors, + history, + customConfigService + ); + await customService.onModuleInit(); + + vi.mocked(lmSensors.read).mockResolvedValue([ + { + id: 'cpu:warm', + name: 'Warm CPU', + type: SensorType.CPU_CORE, + value: 65, + unit: TemperatureUnit.CELSIUS, + }, + ]); + + const metrics = await customService.getMetrics(); + expect(metrics?.sensors[0].current.status).toBe(TemperatureStatus.WARNING); + }); + }); + + describe('buildSummary', () => { + it('should calculate correct average', async () => { + vi.mocked(lmSensors.read).mockResolvedValue([ + { + id: 'sensor1', + name: 'Sensor 1', + type: SensorType.CPU_CORE, + value: 40, + unit: TemperatureUnit.CELSIUS, + }, + { + id: 'sensor2', + name: 'Sensor 2', + type: SensorType.CPU_CORE, + value: 60, + unit: TemperatureUnit.CELSIUS, + }, + ]); + + const metrics = await service.getMetrics(); + expect(metrics?.summary.average).toBe(50); + }); + + it('should identify hottest and coolest sensors', async () => { + vi.mocked(lmSensors.read).mockResolvedValue([ + { + id: 's1', + name: 'Cool', + type: SensorType.CPU_CORE, + value: 30, + unit: TemperatureUnit.CELSIUS, + }, + { + id: 's2', + name: 'Hot', + type: SensorType.CPU_CORE, + value: 80, + unit: TemperatureUnit.CELSIUS, + }, + { + id: 's3', + name: 'Medium', + type: SensorType.CPU_CORE, + value: 50, + unit: TemperatureUnit.CELSIUS, + }, + ]); + + const metrics = await service.getMetrics(); + expect(metrics?.summary.hottest.name).toBe('Hot'); + expect(metrics?.summary.coolest.name).toBe('Cool'); + }); + }); +}); From 2901d2d24536fa5c41027cb2170d8474f3497d29 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Wed, 14 Jan 2026 07:13:42 +0000 Subject: [PATCH 31/86] update tests --- api/dev/configs/api.json | 4 +--- .../resolvers/metrics/temperature/temperature.service.spec.ts | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index d44a115dd2..744a692192 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -3,7 +3,5 @@ "extraOrigins": [], "sandbox": true, "ssoSubIds": [], - "plugins": [ - "unraid-api-plugin-connect" - ] + "plugins": [] } \ No newline at end of file diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts index f702770549..7f9cb433e0 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts @@ -99,7 +99,7 @@ describe('TemperatureService', () => { id: 'cpu:hot', name: 'Hot CPU', type: SensorType.CPU_CORE, - value: 85, + value: 75, unit: TemperatureUnit.CELSIUS, }, ]); @@ -143,6 +143,7 @@ describe('TemperatureService', () => { describe('buildSummary', () => { it('should calculate correct average', async () => { + await service.onModuleInit(); vi.mocked(lmSensors.read).mockResolvedValue([ { id: 'sensor1', @@ -165,6 +166,7 @@ describe('TemperatureService', () => { }); it('should identify hottest and coolest sensors', async () => { + await service.onModuleInit(); vi.mocked(lmSensors.read).mockResolvedValue([ { id: 's1', From 19441e4e64eb0f389a00a8dbbab945c2a27b2aaf Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 19 Jan 2026 01:13:14 +0000 Subject: [PATCH 32/86] update unit tests --- .../sensors/disk_sensors.service.spec.ts | 170 +++++++++++ .../sensors/lm_sensors.service.spec.ts | 281 ++++++++++++++++++ .../temperature.resolver.integration.spec.ts | 118 ++++++++ .../temperature/temperature.service.spec.ts | 163 ++++++++++ .../temperature/temperature.service.ts | 3 - 5 files changed, 732 insertions(+), 3 deletions(-) create mode 100644 api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts create mode 100644 api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.spec.ts create mode 100644 api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts new file mode 100644 index 0000000000..5902c5416e --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts @@ -0,0 +1,170 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.service.js'; +import { + SensorType, + TemperatureUnit, +} from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; + +describe('DiskSensorsService', () => { + let service: DiskSensorsService; + let disksService: DisksService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DiskSensorsService, + { + provide: DisksService, + useValue: { + getDisks: vi.fn(), + getTemperature: vi.fn(), + }, + }, + ], + }).compile(); + + service = module.get(DiskSensorsService); + disksService = module.get(DisksService); + }); + + describe('isAvailable', () => { + it('should return true when disks exist', async () => { + vi.mocked(disksService.getDisks).mockResolvedValue([ + { id: 'disk1', device: '/dev/sda', name: 'Test Disk' }, + ] as any); + + const available = await service.isAvailable(); + expect(available).toBe(true); + }); + + it('should return false when no disks exist', async () => { + vi.mocked(disksService.getDisks).mockResolvedValue([]); + + const available = await service.isAvailable(); + expect(available).toBe(false); + }); + + it('should return false when DisksService throws', async () => { + vi.mocked(disksService.getDisks).mockRejectedValue(new Error('Failed')); + + const available = await service.isAvailable(); + expect(available).toBe(false); + }); + }); + + describe('read', () => { + it('should return disk temperatures', async () => { + vi.mocked(disksService.getDisks).mockResolvedValue([ + { id: 'disk1', device: '/dev/sda', name: 'Seagate HDD', interfaceType: 'sata' }, + { id: 'disk2', device: '/dev/nvme0n1', name: 'Samsung NVMe', interfaceType: 'nvme' }, + ] as any); + + vi.mocked(disksService.getTemperature).mockResolvedValueOnce(35).mockResolvedValueOnce(45); + + const sensors = await service.read(); + + expect(sensors).toHaveLength(2); + expect(sensors[0]).toEqual({ + id: 'disk:disk1', + name: 'Seagate HDD', + type: SensorType.DISK, + value: 35, + unit: TemperatureUnit.CELSIUS, + }); + expect(sensors[1]).toEqual({ + id: 'disk:disk2', + name: 'Samsung NVMe', + type: SensorType.NVME, + value: 45, + unit: TemperatureUnit.CELSIUS, + }); + }); + + it('should skip disks without temperature data', async () => { + vi.mocked(disksService.getDisks).mockResolvedValue([ + { id: 'disk1', device: '/dev/sda', name: 'Disk 1' }, + { id: 'disk2', device: '/dev/sdb', name: 'Disk 2' }, + ] as any); + + vi.mocked(disksService.getTemperature).mockResolvedValueOnce(35).mockResolvedValueOnce(null); // No temp for disk2 + + const sensors = await service.read(); + + expect(sensors).toHaveLength(1); + expect(sensors[0].name).toBe('Disk 1'); + }); + + it('should handle getTemperature errors gracefully', async () => { + vi.mocked(disksService.getDisks).mockResolvedValue([ + { id: 'disk1', device: '/dev/sda', name: 'Disk 1' }, + { id: 'disk2', device: '/dev/sdb', name: 'Disk 2' }, + ] as any); + + vi.mocked(disksService.getTemperature) + .mockResolvedValueOnce(35) + .mockRejectedValueOnce(new Error('SMART failed')); + + const sensors = await service.read(); + + expect(sensors).toHaveLength(1); + expect(sensors[0].name).toBe('Disk 1'); + }); + + it('should use device name as fallback when name is empty', async () => { + vi.mocked(disksService.getDisks).mockResolvedValue([ + { id: 'disk1', device: '/dev/sda', name: '' }, + ] as any); + + vi.mocked(disksService.getTemperature).mockResolvedValue(35); + + const sensors = await service.read(); + + expect(sensors[0].name).toBe('/dev/sda'); + }); + }); + + describe('inferDiskType', () => { + it('should return NVME for nvme interface', async () => { + vi.mocked(disksService.getDisks).mockResolvedValue([ + { id: 'disk1', device: '/dev/nvme0n1', name: 'NVMe', interfaceType: 'nvme' }, + ] as any); + vi.mocked(disksService.getTemperature).mockResolvedValue(40); + + const sensors = await service.read(); + expect(sensors[0].type).toBe(SensorType.NVME); + }); + + it('should return NVME for pcie interface', async () => { + vi.mocked(disksService.getDisks).mockResolvedValue([ + { id: 'disk1', device: '/dev/nvme0n1', name: 'NVMe', interfaceType: 'pcie' }, + ] as any); + vi.mocked(disksService.getTemperature).mockResolvedValue(40); + + const sensors = await service.read(); + expect(sensors[0].type).toBe(SensorType.NVME); + }); + + it('should return DISK for sata interface', async () => { + vi.mocked(disksService.getDisks).mockResolvedValue([ + { id: 'disk1', device: '/dev/sda', name: 'HDD', interfaceType: 'sata' }, + ] as any); + vi.mocked(disksService.getTemperature).mockResolvedValue(35); + + const sensors = await service.read(); + expect(sensors[0].type).toBe(SensorType.DISK); + }); + + it('should return DISK for undefined interface', async () => { + vi.mocked(disksService.getDisks).mockResolvedValue([ + { id: 'disk1', device: '/dev/sda', name: 'HDD' }, + ] as any); + vi.mocked(disksService.getTemperature).mockResolvedValue(35); + + const sensors = await service.read(); + expect(sensors[0].type).toBe(SensorType.DISK); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.spec.ts new file mode 100644 index 0000000000..5151985fb7 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.spec.ts @@ -0,0 +1,281 @@ +import { execa } from 'execa'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.js'; +import { + SensorType, + TemperatureUnit, +} from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; + +// Mock execa +vi.mock('execa', () => ({ + execa: vi.fn(), +})); + +describe('LmSensorsService', () => { + let service: LmSensorsService; + + beforeEach(() => { + service = new LmSensorsService(); + vi.clearAllMocks(); + }); + + describe('isAvailable', () => { + it('should return true when sensors command exists', async () => { + vi.mocked(execa).mockResolvedValue({ stdout: 'sensors version 3.6.0' } as any); + + const available = await service.isAvailable(); + + expect(available).toBe(true); + expect(execa).toHaveBeenCalledWith('sensors', ['--version']); + }); + + it('should return false when sensors command not found', async () => { + vi.mocked(execa).mockRejectedValue(new Error('Command not found')); + + const available = await service.isAvailable(); + + expect(available).toBe(false); + }); + }); + + describe('read', () => { + it('should parse sensors JSON output correctly', async () => { + const mockOutput = { + 'coretemp-isa-0000': { + Adapter: 'ISA adapter', + 'Package id 0': { + temp1_input: 55.0, + }, + 'Core 0': { + temp2_input: 52.0, + }, + 'Core 1': { + temp3_input: 54.0, + }, + }, + }; + + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + + const sensors = await service.read(); + + expect(sensors).toHaveLength(3); + expect(sensors[0]).toEqual({ + id: 'coretemp-isa-0000:Package id 0:temp1_input', + name: 'coretemp-isa-0000 Package id 0', + type: SensorType.CPU_PACKAGE, + value: 55.0, + unit: TemperatureUnit.CELSIUS, + }); + }); + + it('should handle multiple chips', async () => { + const mockOutput = { + 'coretemp-isa-0000': { + Adapter: 'ISA adapter', + 'Core 0': { temp1_input: 50.0 }, + }, + 'nvme-pci-0100': { + Adapter: 'PCI adapter', + Composite: { temp1_input: 40.0 }, + }, + }; + + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + + const sensors = await service.read(); + + expect(sensors).toHaveLength(2); + }); + + it('should skip Adapter field', async () => { + const mockOutput = { + 'coretemp-isa-0000': { + Adapter: 'ISA adapter', + 'Core 0': { temp1_input: 50.0 }, + }, + }; + + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + + const sensors = await service.read(); + + expect(sensors).toHaveLength(1); + expect(sensors.find((s) => s.name.includes('Adapter'))).toBeUndefined(); + }); + + it('should only process _input fields', async () => { + const mockOutput = { + 'coretemp-isa-0000': { + Adapter: 'ISA adapter', + 'Core 0': { + temp1_input: 50.0, + temp1_max: 100.0, + temp1_crit: 105.0, + }, + }, + }; + + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + + const sensors = await service.read(); + + expect(sensors).toHaveLength(1); + expect(sensors[0].value).toBe(50.0); + }); + + it('should handle malformed JSON', async () => { + vi.mocked(execa).mockResolvedValue({ stdout: 'not valid json' } as any); + + await expect(service.read()).rejects.toThrow(); + }); + + it('should handle empty output', async () => { + vi.mocked(execa).mockResolvedValue({ stdout: '{}' } as any); + + const sensors = await service.read(); + + expect(sensors).toEqual([]); + }); + + it('should handle non-object values in chip data', async () => { + const mockOutput = { + 'coretemp-isa-0000': { + Adapter: 'ISA adapter', + 'some-string-value': 'not an object', + 'some-number-value': 123, + 'Core 0': { temp1_input: 50.0 }, + }, + }; + + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + + const sensors = await service.read(); + + expect(sensors).toHaveLength(1); + expect(sensors[0].name).toContain('Core 0'); + }); + + it('should handle non-number temperature values', async () => { + const mockOutput = { + 'coretemp-isa-0000': { + Adapter: 'ISA adapter', + 'Core 0': { + temp1_input: 'not a number', + }, + 'Core 1': { + temp1_input: 50.0, + }, + }, + }; + + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + + const sensors = await service.read(); + + expect(sensors).toHaveLength(1); + expect(sensors[0].name).toContain('Core 1'); + }); + }); + + describe('inferType', () => { + it('should return CPU_PACKAGE for package sensors', async () => { + const mockOutput = { + 'coretemp-isa-0000': { + Adapter: 'ISA adapter', + 'Package id 0': { temp1_input: 55.0 }, + }, + }; + + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + + const sensors = await service.read(); + + expect(sensors[0].type).toBe(SensorType.CPU_PACKAGE); + }); + + it('should return CPU_CORE for core sensors', async () => { + const mockOutput = { + 'coretemp-isa-0000': { + Adapter: 'ISA adapter', + 'Core 0': { temp1_input: 50.0 }, + }, + }; + + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + + const sensors = await service.read(); + + expect(sensors[0].type).toBe(SensorType.CPU_CORE); + }); + + it('should return NVME for nvme sensors', async () => { + const mockOutput = { + 'nvme-pci-0100': { + Adapter: 'PCI adapter', + Composite: { temp1_input: 40.0 }, + }, + }; + + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + + const sensors = await service.read(); + + expect(sensors[0].type).toBe(SensorType.NVME); + }); + + it('should return GPU for gpu sensors', async () => { + const mockOutput = { + 'amdgpu-pci-0800': { + Adapter: 'PCI adapter', + 'GPU temp': { temp1_input: 65.0 }, + }, + }; + + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + + const sensors = await service.read(); + + expect(sensors[0].type).toBe(SensorType.GPU); + }); + + it('should return MOTHERBOARD for wmi sensors', async () => { + const mockOutput = { + 'asus-wmi-sensors': { + Adapter: 'Virtual device', + 'CPU Temperature': { temp1_input: 45.0 }, + }, + }; + + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + + const sensors = await service.read(); + + expect(sensors[0].type).toBe(SensorType.MOTHERBOARD); + }); + + it('should return CUSTOM for unknown sensor types', async () => { + const mockOutput = { + 'unknown-device-0000': { + Adapter: 'Some adapter', + 'Random Sensor': { temp1_input: 30.0 }, + }, + }; + + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + + const sensors = await service.read(); + + expect(sensors[0].type).toBe(SensorType.CUSTOM); + }); + }); + + describe('error handling', () => { + it('should throw when execa fails', async () => { + vi.mocked(execa).mockRejectedValue(new Error('sensors command failed')); + + await expect(service.read()).rejects.toThrow('sensors command failed'); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts new file mode 100644 index 0000000000..315321bd23 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts @@ -0,0 +1,118 @@ +import { INestApplication } from '@nestjs/common'; +import { GraphQLModule } from '@nestjs/graphql'; +import { Test, TestingModule } from '@nestjs/testing'; + +import request from 'supertest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MetricsResolver } from '@app/unraid-api/graph/resolvers/metrics/metrics.resolver.js'; +import { + SensorType, + TemperatureStatus, + TemperatureUnit, +} from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; +import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; + +// ... other imports as needed + +describe('Temperature GraphQL Integration', () => { + let app: INestApplication; + let temperatureService: TemperatureService; + + const mockTemperatureMetrics = { + id: 'temperature-metrics', + sensors: [ + { + id: 'cpu:package', + name: 'CPU Package', + type: SensorType.CPU_PACKAGE, + current: { + value: 55, + unit: TemperatureUnit.CELSIUS, + timestamp: new Date(), + status: TemperatureStatus.NORMAL, + }, + }, + ], + summary: { + average: 55, + hottest: { + /* ... */ + }, + coolest: { + /* ... */ + }, + warningCount: 0, + criticalCount: 0, + }, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + // Set up your test module with mocked services + providers: [ + { + provide: TemperatureService, + useValue: { + getMetrics: vi.fn().mockResolvedValue(mockTemperatureMetrics), + }, + }, + // ... other required providers + ], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + temperatureService = module.get(TemperatureService); + }); + + it('should return temperature data via metrics query', async () => { + const query = ` + query { + metrics { + temperature { + sensors { + id + name + type + current { + value + unit + status + } + } + summary { + average + warningCount + criticalCount + } + } + } + } + `; + + const response = await request(app.getHttpServer()).post('/graphql').send({ query }).expect(200); + + expect(response.body.data.metrics.temperature).toBeDefined(); + expect(response.body.data.metrics.temperature.sensors).toHaveLength(1); + expect(response.body.data.metrics.temperature.sensors[0].name).toBe('CPU Package'); + }); + + it('should handle null temperature metrics gracefully', async () => { + vi.mocked(temperatureService.getMetrics).mockResolvedValue(null); + + const query = ` + query { + metrics { + temperature { + sensors { id } + } + } + } + `; + + const response = await request(app.getHttpServer()).post('/graphql').send({ query }).expect(200); + + expect(response.body.data.metrics.temperature).toBeNull(); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts index 7f9cb433e0..bb65e3ecf1 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts @@ -197,3 +197,166 @@ describe('TemperatureService', () => { }); }); }); + +// Add to existing temperature.service.spec.ts + +describe('edge cases', () => { + it('should handle provider read timeout gracefully', async () => { + await service.onModuleInit(); + + // Simulate a slow/hanging provider + vi.mocked(lmSensors.read).mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve([]), 10000)) + ); + + // If you have timeout logic, test it here + // Otherwise, this documents expected behavior + const startTime = Date.now(); + const metrics = await service.getMetrics(); + const elapsed = Date.now() - startTime; + + // Should either timeout or complete - document expected behavior + expect(metrics).toBeDefined(); + }); + + it('should deduplicate sensors with same ID from different providers', async () => { + await service.onModuleInit(); + + // Both providers return a sensor with the same ID + vi.mocked(lmSensors.read).mockResolvedValue([ + { + id: 'duplicate-sensor', + name: 'Sensor from lm-sensors', + type: SensorType.CPU_CORE, + value: 50, + unit: TemperatureUnit.CELSIUS, + }, + ]); + + vi.mocked(diskSensors.read).mockResolvedValue([ + { + id: 'duplicate-sensor', + name: 'Sensor from disk', + type: SensorType.DISK, + value: 55, + unit: TemperatureUnit.CELSIUS, + }, + ]); + + const metrics = await service.getMetrics(); + + // Document expected behavior - currently allows duplicates + // If you want to dedupe, add logic and update this test + expect(metrics?.sensors.filter((s) => s.id === 'duplicate-sensor')).toHaveLength(2); + }); + + it('should handle empty sensor name', async () => { + await service.onModuleInit(); + + vi.mocked(lmSensors.read).mockResolvedValue([ + { + id: 'sensor-no-name', + name: '', + type: SensorType.CUSTOM, + value: 45, + unit: TemperatureUnit.CELSIUS, + }, + ]); + + const metrics = await service.getMetrics(); + + expect(metrics?.sensors[0].name).toBe(''); + // Or if you want to enforce non-empty names: + // expect(metrics?.sensors[0].name).toBe('Unknown Sensor'); + }); + + it('should handle negative temperature values', async () => { + await service.onModuleInit(); + + vi.mocked(lmSensors.read).mockResolvedValue([ + { + id: 'cold-sensor', + name: 'Freezer Sensor', + type: SensorType.CUSTOM, + value: -20, + unit: TemperatureUnit.CELSIUS, + }, + ]); + + const metrics = await service.getMetrics(); + + expect(metrics?.sensors[0].current.value).toBe(-20); + expect(metrics?.sensors[0].current.status).toBe(TemperatureStatus.NORMAL); + }); + + it('should handle extremely high temperature values', async () => { + await service.onModuleInit(); + + vi.mocked(lmSensors.read).mockResolvedValue([ + { + id: 'hot-sensor', + name: 'Very Hot Sensor', + type: SensorType.CPU_CORE, + value: 150, + unit: TemperatureUnit.CELSIUS, + }, + ]); + + const metrics = await service.getMetrics(); + + expect(metrics?.sensors[0].current.value).toBe(150); + expect(metrics?.sensors[0].current.status).toBe(TemperatureStatus.CRITICAL); + }); + + it('should handle NaN temperature values', async () => { + await service.onModuleInit(); + + vi.mocked(lmSensors.read).mockResolvedValue([ + { + id: 'nan-sensor', + name: 'Bad Sensor', + type: SensorType.CUSTOM, + value: NaN, + unit: TemperatureUnit.CELSIUS, + }, + ]); + + const metrics = await service.getMetrics(); + + // Document expected behavior - should either filter out or handle gracefully + // Current implementation would include it; you may want to filter + expect(metrics?.sensors).toHaveLength(1); + }); + + it('should handle all providers failing', async () => { + await service.onModuleInit(); + + vi.mocked(lmSensors.read).mockRejectedValue(new Error('lm-sensors failed')); + vi.mocked(diskSensors.read).mockRejectedValue(new Error('disk sensors failed')); + + const metrics = await service.getMetrics(); + + expect(metrics).toBeNull(); + }); + + it('should handle partial provider failures', async () => { + await service.onModuleInit(); + + vi.mocked(lmSensors.read).mockRejectedValue(new Error('lm-sensors failed')); + vi.mocked(diskSensors.read).mockResolvedValue([ + { + id: 'disk:sda', + name: 'HDD', + type: SensorType.DISK, + value: 35, + unit: TemperatureUnit.CELSIUS, + }, + ]); + + const metrics = await service.getMetrics(); + + expect(metrics).toBeDefined(); + expect(metrics?.sensors).toHaveLength(1); + expect(metrics?.sensors[0].name).toBe('HDD'); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index aadc80d4e5..f9d8fabb9e 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -1,8 +1,5 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { join } from 'path'; - -import { execa } from 'execa'; import { DiskSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.js'; import { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.js'; From 6345e7551b4b0cc3e66796b75008a43683c0d5bd Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 19 Jan 2026 01:27:28 +0000 Subject: [PATCH 33/86] fixing unit tests --- .../metrics.resolver.integration.spec.ts | 6 + .../sensors/disk_sensors.service.spec.ts | 1 + .../temperature/temperature.service.spec.ts | 267 +++++++++--------- 3 files changed, 139 insertions(+), 135 deletions(-) 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 a2828fdb47..b551b271f3 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 @@ -35,6 +35,12 @@ describe('MetricsResolver Integration Tests', () => { SubscriptionTrackerService, SubscriptionHelperService, SubscriptionManagerService, + { + provide: ConfigService, + useValue: { + get: vi.fn((key: string, defaultValue?: any) => defaultValue), + }, + }, ], }).compile(); diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts index 5902c5416e..e654c01fee 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts @@ -3,6 +3,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.service.js'; +import { DiskSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.js'; import { SensorType, TemperatureUnit, diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts index bb65e3ecf1..06a0ac46c4 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts @@ -196,167 +196,164 @@ describe('TemperatureService', () => { expect(metrics?.summary.coolest.name).toBe('Cool'); }); }); -}); + describe('edge cases', () => { + it('should handle provider read timeout gracefully', async () => { + await service.onModuleInit(); -// Add to existing temperature.service.spec.ts + // Simulate a slow/hanging provider + vi.mocked(lmSensors.read).mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve([]), 10000)) + ); -describe('edge cases', () => { - it('should handle provider read timeout gracefully', async () => { - await service.onModuleInit(); + // If you have timeout logic, test it here + // Otherwise, this documents expected behavior + const startTime = Date.now(); + const metrics = await service.getMetrics(); + const elapsed = Date.now() - startTime; - // Simulate a slow/hanging provider - vi.mocked(lmSensors.read).mockImplementation( - () => new Promise((resolve) => setTimeout(() => resolve([]), 10000)) - ); + // Should either timeout or complete - document expected behavior + expect(metrics).toBeDefined(); + }); - // If you have timeout logic, test it here - // Otherwise, this documents expected behavior - const startTime = Date.now(); - const metrics = await service.getMetrics(); - const elapsed = Date.now() - startTime; + it('should deduplicate sensors with same ID from different providers', async () => { + await service.onModuleInit(); - // Should either timeout or complete - document expected behavior - expect(metrics).toBeDefined(); - }); + // Both providers return a sensor with the same ID + vi.mocked(lmSensors.read).mockResolvedValue([ + { + id: 'duplicate-sensor', + name: 'Sensor from lm-sensors', + type: SensorType.CPU_CORE, + value: 50, + unit: TemperatureUnit.CELSIUS, + }, + ]); - it('should deduplicate sensors with same ID from different providers', async () => { - await service.onModuleInit(); - - // Both providers return a sensor with the same ID - vi.mocked(lmSensors.read).mockResolvedValue([ - { - id: 'duplicate-sensor', - name: 'Sensor from lm-sensors', - type: SensorType.CPU_CORE, - value: 50, - unit: TemperatureUnit.CELSIUS, - }, - ]); - - vi.mocked(diskSensors.read).mockResolvedValue([ - { - id: 'duplicate-sensor', - name: 'Sensor from disk', - type: SensorType.DISK, - value: 55, - unit: TemperatureUnit.CELSIUS, - }, - ]); - - const metrics = await service.getMetrics(); - - // Document expected behavior - currently allows duplicates - // If you want to dedupe, add logic and update this test - expect(metrics?.sensors.filter((s) => s.id === 'duplicate-sensor')).toHaveLength(2); - }); + vi.mocked(diskSensors.read).mockResolvedValue([ + { + id: 'duplicate-sensor', + name: 'Sensor from disk', + type: SensorType.DISK, + value: 55, + unit: TemperatureUnit.CELSIUS, + }, + ]); - it('should handle empty sensor name', async () => { - await service.onModuleInit(); + const metrics = await service.getMetrics(); - vi.mocked(lmSensors.read).mockResolvedValue([ - { - id: 'sensor-no-name', - name: '', - type: SensorType.CUSTOM, - value: 45, - unit: TemperatureUnit.CELSIUS, - }, - ]); + // Document expected behavior - currently allows duplicates + // If you want to dedupe, add logic and update this test + expect(metrics?.sensors.filter((s) => s.id === 'duplicate-sensor')).toHaveLength(2); + }); - const metrics = await service.getMetrics(); + it('should handle empty sensor name', async () => { + await service.onModuleInit(); - expect(metrics?.sensors[0].name).toBe(''); - // Or if you want to enforce non-empty names: - // expect(metrics?.sensors[0].name).toBe('Unknown Sensor'); - }); + vi.mocked(lmSensors.read).mockResolvedValue([ + { + id: 'sensor-no-name', + name: '', + type: SensorType.CUSTOM, + value: 45, + unit: TemperatureUnit.CELSIUS, + }, + ]); - it('should handle negative temperature values', async () => { - await service.onModuleInit(); + const metrics = await service.getMetrics(); - vi.mocked(lmSensors.read).mockResolvedValue([ - { - id: 'cold-sensor', - name: 'Freezer Sensor', - type: SensorType.CUSTOM, - value: -20, - unit: TemperatureUnit.CELSIUS, - }, - ]); + expect(metrics?.sensors[0].name).toBe(''); + // Or if you want to enforce non-empty names: + // expect(metrics?.sensors[0].name).toBe('Unknown Sensor'); + }); - const metrics = await service.getMetrics(); + it('should handle negative temperature values', async () => { + await service.onModuleInit(); - expect(metrics?.sensors[0].current.value).toBe(-20); - expect(metrics?.sensors[0].current.status).toBe(TemperatureStatus.NORMAL); - }); + vi.mocked(lmSensors.read).mockResolvedValue([ + { + id: 'cold-sensor', + name: 'Freezer Sensor', + type: SensorType.CUSTOM, + value: -20, + unit: TemperatureUnit.CELSIUS, + }, + ]); + + const metrics = await service.getMetrics(); - it('should handle extremely high temperature values', async () => { - await service.onModuleInit(); + expect(metrics?.sensors[0].current.value).toBe(-20); + expect(metrics?.sensors[0].current.status).toBe(TemperatureStatus.NORMAL); + }); - vi.mocked(lmSensors.read).mockResolvedValue([ - { - id: 'hot-sensor', - name: 'Very Hot Sensor', - type: SensorType.CPU_CORE, - value: 150, - unit: TemperatureUnit.CELSIUS, - }, - ]); + it('should handle extremely high temperature values', async () => { + await service.onModuleInit(); - const metrics = await service.getMetrics(); + vi.mocked(lmSensors.read).mockResolvedValue([ + { + id: 'hot-sensor', + name: 'Very Hot Sensor', + type: SensorType.CPU_CORE, + value: 150, + unit: TemperatureUnit.CELSIUS, + }, + ]); - expect(metrics?.sensors[0].current.value).toBe(150); - expect(metrics?.sensors[0].current.status).toBe(TemperatureStatus.CRITICAL); - }); + const metrics = await service.getMetrics(); - it('should handle NaN temperature values', async () => { - await service.onModuleInit(); + expect(metrics?.sensors[0].current.value).toBe(150); + expect(metrics?.sensors[0].current.status).toBe(TemperatureStatus.CRITICAL); + }); - vi.mocked(lmSensors.read).mockResolvedValue([ - { - id: 'nan-sensor', - name: 'Bad Sensor', - type: SensorType.CUSTOM, - value: NaN, - unit: TemperatureUnit.CELSIUS, - }, - ]); + it('should handle NaN temperature values', async () => { + await service.onModuleInit(); - const metrics = await service.getMetrics(); + vi.mocked(lmSensors.read).mockResolvedValue([ + { + id: 'nan-sensor', + name: 'Bad Sensor', + type: SensorType.CUSTOM, + value: NaN, + unit: TemperatureUnit.CELSIUS, + }, + ]); - // Document expected behavior - should either filter out or handle gracefully - // Current implementation would include it; you may want to filter - expect(metrics?.sensors).toHaveLength(1); - }); + const metrics = await service.getMetrics(); - it('should handle all providers failing', async () => { - await service.onModuleInit(); + // Document expected behavior - should either filter out or handle gracefully + // Current implementation would include it; you may want to filter + expect(metrics?.sensors).toHaveLength(1); + }); - vi.mocked(lmSensors.read).mockRejectedValue(new Error('lm-sensors failed')); - vi.mocked(diskSensors.read).mockRejectedValue(new Error('disk sensors failed')); + it('should handle all providers failing', async () => { + await service.onModuleInit(); - const metrics = await service.getMetrics(); + vi.mocked(lmSensors.read).mockRejectedValue(new Error('lm-sensors failed')); + vi.mocked(diskSensors.read).mockRejectedValue(new Error('disk sensors failed')); - expect(metrics).toBeNull(); - }); + const metrics = await service.getMetrics(); + + expect(metrics).toBeNull(); + }); + + it('should handle partial provider failures', async () => { + await service.onModuleInit(); - it('should handle partial provider failures', async () => { - await service.onModuleInit(); - - vi.mocked(lmSensors.read).mockRejectedValue(new Error('lm-sensors failed')); - vi.mocked(diskSensors.read).mockResolvedValue([ - { - id: 'disk:sda', - name: 'HDD', - type: SensorType.DISK, - value: 35, - unit: TemperatureUnit.CELSIUS, - }, - ]); - - const metrics = await service.getMetrics(); - - expect(metrics).toBeDefined(); - expect(metrics?.sensors).toHaveLength(1); - expect(metrics?.sensors[0].name).toBe('HDD'); + vi.mocked(lmSensors.read).mockRejectedValue(new Error('lm-sensors failed')); + vi.mocked(diskSensors.read).mockResolvedValue([ + { + id: 'disk:sda', + name: 'HDD', + type: SensorType.DISK, + value: 35, + unit: TemperatureUnit.CELSIUS, + }, + ]); + + const metrics = await service.getMetrics(); + + expect(metrics).toBeDefined(); + expect(metrics?.sensors).toHaveLength(1); + expect(metrics?.sensors[0].name).toBe('HDD'); + }); }); }); From 71ab7e213345c4761d90ff171b69a11ad175f423 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 19 Jan 2026 01:34:35 +0000 Subject: [PATCH 34/86] more unit test fixes --- api/dev/configs/api.json | 7 - .../temperature.resolver.integration.spec.ts | 224 +++++++++++++----- .../temperature/temperature.service.spec.ts | 4 +- 3 files changed, 169 insertions(+), 66 deletions(-) diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index 744a692192..e69de29bb2 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -1,7 +0,0 @@ -{ - "version": "4.28.2", - "extraOrigins": [], - "sandbox": true, - "ssoSubIds": [], - "plugins": [] -} \ No newline at end of file diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts index 315321bd23..dec54edaec 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts @@ -1,10 +1,14 @@ -import { INestApplication } from '@nestjs/common'; -import { GraphQLModule } from '@nestjs/graphql'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import request from 'supertest'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + CpuTopologyService, + 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'; import { SensorType, @@ -12,11 +16,13 @@ import { TemperatureUnit, } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; - -// ... other imports as needed +import { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js'; +import { SubscriptionManagerService } from '@app/unraid-api/graph/services/subscription-manager.service.js'; +import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js'; describe('Temperature GraphQL Integration', () => { - let app: INestApplication; + let module: TestingModule; + let resolver: MetricsResolver; let temperatureService: TemperatureService; const mockTemperatureMetrics = { @@ -32,15 +38,45 @@ describe('Temperature GraphQL Integration', () => { timestamp: new Date(), status: TemperatureStatus.NORMAL, }, + min: { + value: 45, + unit: TemperatureUnit.CELSIUS, + timestamp: new Date(), + status: TemperatureStatus.NORMAL, + }, + max: { + value: 65, + unit: TemperatureUnit.CELSIUS, + timestamp: new Date(), + status: TemperatureStatus.WARNING, + }, + warning: 70, + critical: 85, }, ], summary: { average: 55, hottest: { - /* ... */ + id: 'cpu:package', + name: 'CPU Package', + type: SensorType.CPU_PACKAGE, + current: { + value: 55, + unit: TemperatureUnit.CELSIUS, + timestamp: new Date(), + status: TemperatureStatus.NORMAL, + }, }, coolest: { - /* ... */ + id: 'cpu:package', + name: 'CPU Package', + type: SensorType.CPU_PACKAGE, + current: { + value: 55, + unit: TemperatureUnit.CELSIUS, + timestamp: new Date(), + status: TemperatureStatus.NORMAL, + }, }, warningCount: 0, criticalCount: 0, @@ -48,71 +84,145 @@ describe('Temperature GraphQL Integration', () => { }; beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - // Set up your test module with mocked services + module = await Test.createTestingModule({ providers: [ + MetricsResolver, + { + provide: CpuService, + useValue: { + getUtilization: vi.fn().mockResolvedValue({}), + }, + }, + { + provide: CpuTopologyService, + useValue: { + generateTopology: vi.fn().mockResolvedValue([]), + generateTelemetry: vi.fn().mockResolvedValue([]), + }, + }, + { + provide: MemoryService, + useValue: { + getUtilization: vi.fn().mockResolvedValue({}), + }, + }, { provide: TemperatureService, useValue: { getMetrics: vi.fn().mockResolvedValue(mockTemperatureMetrics), }, }, - // ... other required providers + { + provide: SubscriptionTrackerService, + useValue: { + registerTopic: vi.fn(), + cleanup: vi.fn(), + }, + }, + { + provide: SubscriptionHelperService, + useValue: { + createTrackedSubscription: vi.fn(), + }, + }, + { + provide: SubscriptionManagerService, + useValue: { + stopAll: vi.fn(), + }, + }, + { + provide: ConfigService, + useValue: { + get: vi.fn((key: string, defaultValue?: any) => defaultValue), + }, + }, ], }).compile(); - app = module.createNestApplication(); - await app.init(); + resolver = module.get(MetricsResolver); temperatureService = module.get(TemperatureService); }); - it('should return temperature data via metrics query', async () => { - const query = ` - query { - metrics { - temperature { - sensors { - id - name - type - current { - value - unit - status - } - } - summary { - average - warningCount - criticalCount - } - } - } - } - `; - - const response = await request(app.getHttpServer()).post('/graphql').send({ query }).expect(200); - - expect(response.body.data.metrics.temperature).toBeDefined(); - expect(response.body.data.metrics.temperature.sensors).toHaveLength(1); - expect(response.body.data.metrics.temperature.sensors[0].name).toBe('CPU Package'); + afterEach(async () => { + await module.close(); }); - it('should handle null temperature metrics gracefully', async () => { - vi.mocked(temperatureService.getMetrics).mockResolvedValue(null); + describe('temperature field resolver', () => { + it('should return temperature data via resolver', async () => { + const result = await resolver.temperature(); + + expect(result).toBeDefined(); + expect(result?.sensors).toHaveLength(1); + expect(result?.sensors[0].name).toBe('CPU Package'); + expect(result?.sensors[0].type).toBe(SensorType.CPU_PACKAGE); + expect(result?.sensors[0].current.value).toBe(55); + expect(result?.summary.average).toBe(55); + }); + + it('should handle null temperature metrics gracefully', async () => { + vi.mocked(temperatureService.getMetrics).mockResolvedValue(null); + + const result = await resolver.temperature(); + + expect(result).toBeNull(); + }); + + it('should return summary with correct counts', async () => { + const metricsWithWarnings = { + ...mockTemperatureMetrics, + summary: { + ...mockTemperatureMetrics.summary, + warningCount: 2, + criticalCount: 1, + }, + }; - const query = ` - query { - metrics { - temperature { - sensors { id } - } - } - } - `; + vi.mocked(temperatureService.getMetrics).mockResolvedValue(metricsWithWarnings); + + const result = await resolver.temperature(); + + expect(result?.summary.warningCount).toBe(2); + expect(result?.summary.criticalCount).toBe(1); + }); + + it('should handle multiple sensors', async () => { + const multiSensorMetrics = { + id: 'temperature-metrics', + sensors: [ + mockTemperatureMetrics.sensors[0], + { + id: 'disk:sda', + name: 'Disk /dev/sda', + type: SensorType.DISK, + current: { + value: 35, + unit: TemperatureUnit.CELSIUS, + timestamp: new Date(), + status: TemperatureStatus.NORMAL, + }, + }, + ], + summary: mockTemperatureMetrics.summary, + }; + + vi.mocked(temperatureService.getMetrics).mockResolvedValue(multiSensorMetrics as any); + + const result = await resolver.temperature(); + + expect(result?.sensors).toHaveLength(2); + expect(result?.sensors[0].type).toBe(SensorType.CPU_PACKAGE); + expect(result?.sensors[1].type).toBe(SensorType.DISK); + }); + }); - const response = await request(app.getHttpServer()).post('/graphql').send({ query }).expect(200); + describe('error handling', () => { + it('should handle service errors gracefully', async () => { + vi.mocked(temperatureService.getMetrics).mockRejectedValue( + new Error('Failed to read sensors') + ); - expect(response.body.data.metrics.temperature).toBeNull(); + await expect(resolver.temperature()).rejects.toThrow('Failed to read sensors'); + }); }); }); diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts index 06a0ac46c4..337cbefbe8 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts @@ -202,7 +202,7 @@ describe('TemperatureService', () => { // Simulate a slow/hanging provider vi.mocked(lmSensors.read).mockImplementation( - () => new Promise((resolve) => setTimeout(() => resolve([]), 10000)) + () => new Promise((resolve) => setTimeout(() => resolve([]), 1000)) ); // If you have timeout logic, test it here @@ -213,7 +213,7 @@ describe('TemperatureService', () => { // Should either timeout or complete - document expected behavior expect(metrics).toBeDefined(); - }); + }, 10000); it('should deduplicate sensors with same ID from different providers', async () => { await service.onModuleInit(); From 7c4f66296dde7714130ab3fc8efd3b3bb3c90e59 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 19 Jan 2026 01:51:10 +0000 Subject: [PATCH 35/86] okay these tests actually pass now! --- api/dev/configs/api.json | 7 +++++++ .../metrics/metrics.resolver.integration.spec.ts | 1 + .../graph/resolvers/metrics/metrics.resolver.spec.ts | 9 ++++++++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index e69de29bb2..72e49be7c3 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -0,0 +1,7 @@ +{ + "version": "4.28.2", + "extraOrigins": [], + "sandbox": false, + "ssoSubIds": [], + "plugins": [] +} \ No newline at end of file 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 b551b271f3..f57bc8b23f 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 @@ -1,4 +1,5 @@ import type { TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; import { Test } from '@nestjs/testing'; 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 098dd7e622..801f7294af 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 @@ -85,6 +85,13 @@ describe('MetricsResolver', () => { createTrackedSubscription: vi.fn(), }, }, + { + provide: TemperatureService, + useValue: { + getMetrics: vi.fn().mockResolvedValue(null), + // Add any other methods your resolver calls + }, + }, { provide: ConfigService, useValue: { @@ -197,7 +204,7 @@ describe('MetricsResolver', () => { testModule.onModuleInit(); - expect(subscriptionTracker.registerTopic).toHaveBeenCalledTimes(3); + expect(subscriptionTracker.registerTopic).toHaveBeenCalledTimes(4); expect(subscriptionTracker.registerTopic).toHaveBeenCalledWith( 'CPU_UTILIZATION', expect.any(Function), From 281dcb1eb22b24c130c4787b17293029766f1318 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 19 Jan 2026 02:09:57 +0000 Subject: [PATCH 36/86] Add config to check if temperature feature is enabled --- .../resolvers/metrics/metrics.resolver.ts | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) 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 d8a8f65673..5dc57cc20c 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts @@ -84,17 +84,20 @@ export class MetricsResolver implements OnModuleInit { ); const pollingInterval = this.configService.get('temperature.pollingInterval', 5000); - - this.subscriptionTracker.registerTopic( - PUBSUB_CHANNEL.TEMPERATURE_METRICS, - async () => { - const payload = await this.temperatureService.getMetrics(); - pubsub.publish(PUBSUB_CHANNEL.TEMPERATURE_METRICS, { - systemMetricsTemperature: payload, - }); - }, - pollingInterval - ); + const isTemperatureEnabled = this.configService.get('temperature.enabled', true); + + if (isTemperatureEnabled) { + this.subscriptionTracker.registerTopic( + PUBSUB_CHANNEL.TEMPERATURE_METRICS, + async () => { + const payload = await this.temperatureService.getMetrics(); + pubsub.publish(PUBSUB_CHANNEL.TEMPERATURE_METRICS, { + systemMetricsTemperature: payload, + }); + }, + pollingInterval + ); + } } @Query(() => Metrics) From 90715224e9f398836bfdb35797aae8896e2f587b Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 19 Jan 2026 02:12:09 +0000 Subject: [PATCH 37/86] Remove duplicate import lol --- .../temperature/temperature.resolver.integration.spec.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts index dec54edaec..41b0704cd9 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts @@ -3,10 +3,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - CpuTopologyService, - CpuTopologyService, -} from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.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'; From 686cc81df41a23ac4e1476dcda34806988ae1add Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Tue, 20 Jan 2026 07:18:25 +0000 Subject: [PATCH 38/86] trying to get api to accept config file --- .../resolvers/metrics/metrics.resolver.ts | 2 +- .../temperature_history.service.ts | 4 +- .../unraid-shared/src/services/api-config.ts | 77 ++++++++++++++++++- 3 files changed, 78 insertions(+), 5 deletions(-) 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 5dc57cc20c..3ac8ac42ae 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts @@ -83,7 +83,7 @@ export class MetricsResolver implements OnModuleInit { 2000 ); - const pollingInterval = this.configService.get('temperature.pollingInterval', 5000); + const pollingInterval = this.configService.get('temperature.polling_interval', 5000); const isTemperatureEnabled = this.configService.get('temperature.enabled', true); if (isTemperatureEnabled) { diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.ts index 3beda26998..c5d1c1f830 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.ts @@ -30,11 +30,11 @@ export class TemperatureHistoryService { constructor(private readonly configService: ConfigService) { this.maxReadingsPerSensor = this.configService.get( - 'temperature.history.maxReadings', + 'temperature.history.max_readings', 1000 ); - this.retentionMs = this.configService.get('temperature.history.retentionMs', 86400000); + this.retentionMs = this.configService.get('temperature.history.retention_ms', 86400000); this.logger.log( `Temperature history configured: maxReadings=${this.maxReadingsPerSensor}, retentionMs=${this.retentionMs}ms` diff --git a/packages/unraid-shared/src/services/api-config.ts b/packages/unraid-shared/src/services/api-config.ts index d9179fcc37..34e10995d8 100644 --- a/packages/unraid-shared/src/services/api-config.ts +++ b/packages/unraid-shared/src/services/api-config.ts @@ -1,5 +1,71 @@ -import { Field, ObjectType } from "@nestjs/graphql"; -import { IsString, IsArray, IsOptional, IsBoolean } from "class-validator"; +import { Field, ObjectType, Int } from "@nestjs/graphql"; +import { IsString, IsArray, IsOptional, IsBoolean, IsNumber, ValidateNested } from "class-validator"; +import { Type } from "class-transformer"; + +// 1. Define the nested classes first + +@ObjectType() +export class TemperatureHistoryConfig { + @Field(() => Int, { nullable: true }) + @IsOptional() + @IsNumber() + max_readings?: number; + + @Field(() => Int, { nullable: true }) + @IsOptional() + @IsNumber() + retention_ms?: number; +} + +@ObjectType() +export class TemperatureThresholdsConfig { + @Field(() => Int, { nullable: true }) + @IsOptional() + @IsNumber() + cpu_warning?: number; + + @Field(() => Int, { nullable: true }) + @IsOptional() + @IsNumber() + cpu_critical?: number; + + @Field(() => Int, { nullable: true }) + @IsOptional() + @IsNumber() + disk_warning?: number; + + @Field(() => Int, { nullable: true }) + @IsOptional() + @IsNumber() + disk_critical?: number; +} + +@ObjectType() +export class TemperatureConfig { + @Field({ nullable: true }) + @IsOptional() + @IsBoolean() + enabled?: boolean; + + @Field(() => Int, { nullable: true }) + @IsOptional() + @IsNumber() + polling_interval?: number; + + @Field(() => TemperatureHistoryConfig, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => TemperatureHistoryConfig) + history?: TemperatureHistoryConfig; + + @Field(() => TemperatureThresholdsConfig, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => TemperatureThresholdsConfig) + thresholds?: TemperatureThresholdsConfig; +} + +// 2. Add the temperature field to the main ApiConfig class @ObjectType() export class ApiConfig { @@ -26,4 +92,11 @@ export class ApiConfig { @IsArray() @IsString({ each: true }) plugins!: string[]; + + // --- ADD THIS --- + @Field(() => TemperatureConfig, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => TemperatureConfig) + temperature?: TemperatureConfig; } From 097f768e440ddfb37e0fe34b233a0bf9725600bf Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Tue, 20 Jan 2026 07:48:18 +0000 Subject: [PATCH 39/86] add some debug info --- api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts | 3 +++ .../metrics/temperature/temperature_history.service.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) 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 3ac8ac42ae..ded16102d5 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts @@ -86,6 +86,9 @@ export class MetricsResolver implements OnModuleInit { const pollingInterval = this.configService.get('temperature.polling_interval', 5000); const isTemperatureEnabled = this.configService.get('temperature.enabled', true); + const tempConfig = this.configService.get('api'); + this.logger.debug(`Loaded test temperature config: ${JSON.stringify(tempConfig)}`); + if (isTemperatureEnabled) { this.subscriptionTracker.registerTopic( PUBSUB_CHANNEL.TEMPERATURE_METRICS, diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.ts index c5d1c1f830..3fd3595bad 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.ts @@ -37,7 +37,7 @@ export class TemperatureHistoryService { this.retentionMs = this.configService.get('temperature.history.retention_ms', 86400000); this.logger.log( - `Temperature history configured: maxReadings=${this.maxReadingsPerSensor}, retentionMs=${this.retentionMs}ms` + `Temperature history configured: max_readings=${this.maxReadingsPerSensor}, retentionMs=${this.retentionMs}ms` ); } From 02f6718352fb2e53f2c042fc26091c2b4468efcb Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Tue, 20 Jan 2026 08:31:17 +0000 Subject: [PATCH 40/86] hey the config actually loads now! --- .../unraid-api/graph/resolvers/metrics/metrics.resolver.ts | 7 ++----- .../resolvers/metrics/temperature/temperature.service.ts | 2 +- .../metrics/temperature/temperature_history.service.ts | 7 +++++-- 3 files changed, 8 insertions(+), 8 deletions(-) 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 ded16102d5..162115efca 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts @@ -83,11 +83,8 @@ export class MetricsResolver implements OnModuleInit { 2000 ); - const pollingInterval = this.configService.get('temperature.polling_interval', 5000); - const isTemperatureEnabled = this.configService.get('temperature.enabled', true); - - const tempConfig = this.configService.get('api'); - this.logger.debug(`Loaded test temperature config: ${JSON.stringify(tempConfig)}`); + const isTemperatureEnabled = this.configService.get('api.temperature.enabled', true); + const pollingInterval = this.configService.get('api.temperature.polling_interval', 5000); if (isTemperatureEnabled) { this.subscriptionTracker.registerTopic( diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index f9d8fabb9e..0b8033e7e7 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -188,7 +188,7 @@ export class TemperatureService implements OnModuleInit { } private getThresholdsForType(type: SensorType): { warning: number; critical: number } { - const thresholds = this.configService.get('temperature.thresholds', {}); + const thresholds = this.configService.get('api.temperature.thresholds', {}); switch (type) { case SensorType.CPU_PACKAGE: diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.ts index 3fd3595bad..a6801c9d8d 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.ts @@ -30,11 +30,14 @@ export class TemperatureHistoryService { constructor(private readonly configService: ConfigService) { this.maxReadingsPerSensor = this.configService.get( - 'temperature.history.max_readings', + 'api.temperature.history.max_readings', 1000 ); - this.retentionMs = this.configService.get('temperature.history.retention_ms', 86400000); + this.retentionMs = this.configService.get( + 'api.temperature.history.retention_ms', + 86400000 + ); this.logger.log( `Temperature history configured: max_readings=${this.maxReadingsPerSensor}, retentionMs=${this.retentionMs}ms` From 731e8f88be1f7c3a3c7b0c80232d24e2bc3c9605 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Tue, 20 Jan 2026 08:36:56 +0000 Subject: [PATCH 41/86] fix tests after I figured out how to read config file --- .../metrics/temperature/temperature-history.service.spec.ts | 2 +- .../resolvers/metrics/temperature/temperature.service.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-history.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-history.service.spec.ts index 03428713c2..b8ce5422c5 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-history.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-history.service.spec.ts @@ -98,7 +98,7 @@ describe('TemperatureHistoryService', () => { it('should keep only max readings per sensor', () => { const configServiceWithLimit = { get: (key: string, defaultValue?: any) => { - if (key === 'temperature.history.maxReadings') return 3; + if (key === 'api.temperature.history.max_readings') return 3; return defaultValue; }, } as any; diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts index 337cbefbe8..a32a7af5e0 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts @@ -111,7 +111,7 @@ describe('TemperatureService', () => { it('should use config thresholds when provided', async () => { const customConfigService = { get: vi.fn((key: string, defaultValue?: any) => { - if (key === 'temperature.thresholds') { + if (key === 'api.temperature.thresholds') { return { cpu_warning: 60, cpu_critical: 80 }; } return defaultValue; From f1a1e24c15bd9f921f4119f7033375cfd0b40c63 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sat, 24 Jan 2026 07:45:19 +0000 Subject: [PATCH 42/86] update api-config --- .../unraid-shared/src/services/api-config.ts | 53 +++++++++++++++++-- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/unraid-shared/src/services/api-config.ts b/packages/unraid-shared/src/services/api-config.ts index 34e10995d8..cb998b08f4 100644 --- a/packages/unraid-shared/src/services/api-config.ts +++ b/packages/unraid-shared/src/services/api-config.ts @@ -2,8 +2,6 @@ import { Field, ObjectType, Int } from "@nestjs/graphql"; import { IsString, IsArray, IsOptional, IsBoolean, IsNumber, ValidateNested } from "class-validator"; import { Type } from "class-transformer"; -// 1. Define the nested classes first - @ObjectType() export class TemperatureHistoryConfig { @Field(() => Int, { nullable: true }) @@ -40,6 +38,42 @@ export class TemperatureThresholdsConfig { disk_critical?: number; } +@ObjectType() +export class LmSensorsConfig { + @Field({ nullable: true }) + @IsOptional() + @IsBoolean() + enabled?: boolean; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + config_path?: string; +} + +@ObjectType() +export class SmartctlConfig { + @Field({ nullable: true }) + @IsOptional() + @IsBoolean() + enabled?: boolean; +} + +@ObjectType() +export class SensorsConfig { + @Field(() => LmSensorsConfig, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => LmSensorsConfig) + lm_sensors?: LmSensorsConfig; + + @Field(() => SmartctlConfig, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => SmartctlConfig) + smartctl?: SmartctlConfig; +} + @ObjectType() export class TemperatureConfig { @Field({ nullable: true }) @@ -47,6 +81,11 @@ export class TemperatureConfig { @IsBoolean() enabled?: boolean; + @Field({ nullable: true }) + @IsOptional() + @IsString() + default_unit?: string; + @Field(() => Int, { nullable: true }) @IsOptional() @IsNumber() @@ -63,9 +102,13 @@ export class TemperatureConfig { @ValidateNested() @Type(() => TemperatureThresholdsConfig) thresholds?: TemperatureThresholdsConfig; -} -// 2. Add the temperature field to the main ApiConfig class + @Field(() => SensorsConfig, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => SensorsConfig) + sensors?: SensorsConfig; +} @ObjectType() export class ApiConfig { @@ -93,10 +136,10 @@ export class ApiConfig { @IsString({ each: true }) plugins!: string[]; - // --- ADD THIS --- @Field(() => TemperatureConfig, { nullable: true }) @IsOptional() @ValidateNested() @Type(() => TemperatureConfig) temperature?: TemperatureConfig; } + From d9b80a47d5a155cca20f08e866ed638d07a4f188 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sat, 24 Jan 2026 07:49:17 +0000 Subject: [PATCH 43/86] update with new providers --- .../temperature/temperature.service.ts | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index 0b8033e7e7..6d2e4a3012 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -44,22 +44,40 @@ export class TemperatureService implements OnModuleInit { } private async initializeProviders(): Promise { + // 1. Get sensor specific configs + const lmSensorsConfig = this.configService.get('api.temperature.sensors.lm_sensors'); + const smartctlConfig = this.configService.get('api.temperature.sensors.smartctl'); + + // 2. Define providers with their config checks + // We default to TRUE if the config is missing const potentialProviders = [ - this.lmSensors, - this.diskSensors, + { + service: this.lmSensors, + enabled: lmSensorsConfig?.enabled ?? true, + }, + { + service: this.diskSensors, + enabled: smartctlConfig?.enabled ?? true, + }, // TODO(@mitchellthompkins): this.gpuSensors, ]; for (const provider of potentialProviders) { + // Skip if explicitly disabled in config + if (!provider.enabled) { + this.logger.debug(`Skipping ${provider.service.id} (disabled in config)`); + continue; + } + try { - if (await provider.isAvailable()) { - this.availableProviders.push(provider); - this.logger.log(`Temperature provider available: ${provider.id}`); + if (await provider.service.isAvailable()) { + this.availableProviders.push(provider.service); + this.logger.log(`Temperature provider available: ${provider.service.id}`); } else { - this.logger.debug(`Temperature provider not available: ${provider.id}`); + this.logger.debug(`Temperature provider not available: ${provider.service.id}`); } } catch (err) { - this.logger.warn(`Failed to check provider ${provider.id}`, err); + this.logger.warn(`Failed to check provider ${provider.service.id}`, err); } } From 8f9dd51fa02f12ab60844827a9df2b40604c555b Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sat, 24 Jan 2026 08:08:58 +0000 Subject: [PATCH 44/86] comments --- .../graph/resolvers/metrics/temperature/temperature.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index 6d2e4a3012..eb49ba9497 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -60,6 +60,7 @@ export class TemperatureService implements OnModuleInit { enabled: smartctlConfig?.enabled ?? true, }, // TODO(@mitchellthompkins): this.gpuSensors, + // TODO(@mitchellthompkins): this.ipmiSensors, ]; for (const provider of potentialProviders) { From 63722cdb4277404b80e024d3658e42498aa3a8b6 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 25 Jan 2026 00:50:24 +0000 Subject: [PATCH 45/86] fix unit tests for lm_sensors --- .../sensors/lm_sensors.service.spec.ts | 41 +++++++++++++++++-- .../temperature/sensors/lm_sensors.service.ts | 16 +++++++- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.spec.ts index 5151985fb7..18587603dd 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.spec.ts @@ -1,3 +1,5 @@ +import { ConfigService } from '@nestjs/config'; + import { execa } from 'execa'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -8,15 +10,24 @@ import { } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; // Mock execa -vi.mock('execa', () => ({ - execa: vi.fn(), -})); +vi.mock('execa', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + execa: vi.fn(), + }; +}); describe('LmSensorsService', () => { let service: LmSensorsService; + let configService: ConfigService; beforeEach(() => { - service = new LmSensorsService(); + configService = { + get: vi.fn(), + } as any; + + service = new LmSensorsService(configService); vi.clearAllMocks(); }); @@ -40,6 +51,28 @@ describe('LmSensorsService', () => { }); describe('read', () => { + it('should use default arguments when no config path is set', async () => { + // Mock config returning undefined + vi.mocked(configService.get).mockReturnValue(undefined); + vi.mocked(execa).mockResolvedValue({ stdout: '{}' } as any); + + await service.read(); + + // Verify called with defaults + expect(execa).toHaveBeenCalledWith('sensors', ['-j']); + }); + + it('should add -c flag when config path is present', async () => { + // Mock config returning a path + vi.mocked(configService.get).mockReturnValue('/etc/my-sensors.conf'); + vi.mocked(execa).mockResolvedValue({ stdout: '{}' } as any); + + await service.read(); + + // Verify called with extra args + expect(execa).toHaveBeenCalledWith('sensors', ['-j', '-c', '/etc/my-sensors.conf']); + }); + it('should parse sensors JSON output correctly', async () => { const mockOutput = { 'coretemp-isa-0000': { diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.ts index 9b4a53bc36..8650a60534 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; // Import ConfigService import { execa } from 'execa'; @@ -16,6 +17,8 @@ export class LmSensorsService implements TemperatureSensorProvider { readonly id = 'lm-sensors'; private readonly logger = new Logger(LmSensorsService.name); + constructor(private readonly configService: ConfigService) {} + async isAvailable(): Promise { try { await execa('sensors', ['--version']); @@ -26,7 +29,18 @@ export class LmSensorsService implements TemperatureSensorProvider { } async read(): Promise { - const { stdout } = await execa('sensors', ['-j']); + // Read the config path from your new configuration structure + const configPath = this.configService.get( + 'api.temperature.sensors.lm_sensors.config_path' + ); + + // Build arguments: add '-c path' if configPath exists + const args = ['-j']; + if (configPath) { + args.push('-c', configPath); + } + + const { stdout } = await execa('sensors', args); const data = JSON.parse(stdout); const sensors: RawTemperatureSensor[] = []; From 8861a82725636a3621cc1031a6d8392863d04c59 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 1 Feb 2026 01:21:00 +0000 Subject: [PATCH 46/86] implement remaining todos --- api/README.md | 1 + .../metrics/temperature/temperature.module.ts | 2 + .../temperature/temperature.service.spec.ts | 20 +++- .../temperature/temperature.service.ts | 109 +++++++++++++----- plugin/builder/build-txz.ts | 2 - plugin/builder/utils/monitor-tools.ts | 68 ----------- 6 files changed, 104 insertions(+), 98 deletions(-) delete mode 100644 plugin/builder/utils/monitor-tools.ts diff --git a/api/README.md b/api/README.md index 886558175e..1198ce0752 100644 --- a/api/README.md +++ b/api/README.md @@ -88,3 +88,4 @@ For detailed information about specific features: ## License Copyright Lime Technology Inc. All rights reserved. +- [Temperature Monitoring](docs/developer/temperature.md) - Configuration and API details for temperature sensors diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts index 796fbe2d7d..9c72226c3b 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts @@ -3,6 +3,7 @@ import { Module } from '@nestjs/common'; import { DisksModule } from '@app/unraid-api/graph/resolvers/disks/disks.module.js'; import { DiskSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.js'; +import { IpmiSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/ipmi_sensors.service.js'; import { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.js'; import { TemperatureHistoryService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.js'; import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; @@ -13,6 +14,7 @@ import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temp TemperatureService, LmSensorsService, DiskSensorsService, + IpmiSensorsService, // (@mitchellthompkins) Add other services here // GpuSensorsService, TemperatureHistoryService, diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts index a32a7af5e0..352e7ba8e6 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { DiskSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.js'; +import { IpmiSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/ipmi_sensors.service.js'; import { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.js'; import { TemperatureHistoryService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.js'; import { @@ -16,6 +17,7 @@ describe('TemperatureService', () => { let service: TemperatureService; let lmSensors: LmSensorsService; let diskSensors: DiskSensorsService; + let ipmiSensors: IpmiSensorsService; let history: TemperatureHistoryService; let configService: ConfigService; @@ -40,13 +42,19 @@ describe('TemperatureService', () => { read: vi.fn().mockResolvedValue([]), } as any; + ipmiSensors = { + id: 'ipmi-sensors', + isAvailable: vi.fn().mockResolvedValue(false), // Default to unavailable + read: vi.fn().mockResolvedValue([]), + } as any; + configService = { get: vi.fn((key: string, defaultValue?: any) => defaultValue), } as any; history = new TemperatureHistoryService(configService); - service = new TemperatureService(lmSensors, diskSensors, history, configService); + service = new TemperatureService(lmSensors, diskSensors, ipmiSensors, history, configService); }); describe('initialization', () => { @@ -85,8 +93,15 @@ describe('TemperatureService', () => { it('should return null when no providers available', async () => { vi.mocked(lmSensors.isAvailable).mockResolvedValue(false); vi.mocked(diskSensors.isAvailable).mockResolvedValue(false); + vi.mocked(ipmiSensors.isAvailable).mockResolvedValue(false); - const emptyService = new TemperatureService(lmSensors, diskSensors, history, configService); + const emptyService = new TemperatureService( + lmSensors, + diskSensors, + ipmiSensors, + history, + configService + ); await emptyService.onModuleInit(); const metrics = await emptyService.getMetrics(); @@ -121,6 +136,7 @@ describe('TemperatureService', () => { const customService = new TemperatureService( lmSensors, diskSensors, + ipmiSensors, history, customConfigService ); diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index eb49ba9497..d0a3ce06cf 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { DiskSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.js'; +import { IpmiSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/ipmi_sensors.service.js'; import { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.js'; import { RawTemperatureSensor, @@ -31,6 +32,7 @@ export class TemperatureService implements OnModuleInit { // Inject all available sensor providers private readonly lmSensors: LmSensorsService, private readonly diskSensors: DiskSensorsService, + private readonly ipmiSensors: IpmiSensorsService, // Future: private readonly gpuSensors: GpuSensorsService, // Future: private readonly diskSensors: DiskSensorsService, @@ -47,6 +49,7 @@ export class TemperatureService implements OnModuleInit { // 1. Get sensor specific configs const lmSensorsConfig = this.configService.get('api.temperature.sensors.lm_sensors'); const smartctlConfig = this.configService.get('api.temperature.sensors.smartctl'); + const ipmiConfig = this.configService.get('api.temperature.sensors.ipmi'); // 2. Define providers with their config checks // We default to TRUE if the config is missing @@ -59,8 +62,11 @@ export class TemperatureService implements OnModuleInit { service: this.diskSensors, enabled: smartctlConfig?.enabled ?? true, }, + { + service: this.ipmiSensors, + enabled: ipmiConfig?.enabled ?? true, + }, // TODO(@mitchellthompkins): this.gpuSensors, - // TODO(@mitchellthompkins): this.ipmiSensors, ]; for (const provider of potentialProviders) { @@ -121,32 +127,44 @@ export class TemperatureService implements OnModuleInit { return null; } + const targetUnit = + this.configService.get('api.temperature.default_unit') || 'celsius'; + const isFahrenheit = targetUnit.toLowerCase() === 'fahrenheit'; + const sensors: TemperatureSensor[] = allRawSensors.map((r) => { - const current: TemperatureReading = { + const rawCurrent: TemperatureReading = { value: r.value, unit: r.unit, timestamp: new Date(), status: this.computeStatus(r.value, r.type), }; - // Record in history - this.history.record(r.id, current, { + // Record in history (ALWAYS RAW) + this.history.record(r.id, rawCurrent, { name: r.name, type: r.type, }); - // Get historical data + // Get historical data (RAW) const { min, max } = this.history.getMinMax(r.id); - const historicalReadings = this.history.getHistory(r.id); + const rawHistory = this.history.getHistory(r.id); + + // Convert for output + const current = this.convertReading(rawCurrent, isFahrenheit) as TemperatureReading; + const history = rawHistory + .map((h) => this.convertReading(h, isFahrenheit)) + .filter((h): h is TemperatureReading => h !== undefined); + const minConverted = this.convertReading(min, isFahrenheit); + const maxConverted = this.convertReading(max, isFahrenheit); return { id: r.id, name: r.name, type: r.type, current, - min, - max, - history: historicalReadings, + min: minConverted, + max: maxConverted, + history, warning: this.getThresholdsForType(r.type).warning, critical: this.getThresholdsForType(r.type).critical, }; @@ -170,24 +188,39 @@ export class TemperatureService implements OnModuleInit { return null; } - const sensors: TemperatureSensor[] = allSensorIds.map((sensorId) => { - const { min, max } = this.history.getMinMax(sensorId); - const historicalReadings = this.history.getHistory(sensorId); - const current = historicalReadings[historicalReadings.length - 1]; - const metadata = this.history.getMetadata(sensorId)!; + const targetUnit = this.configService.get('api.temperature.default_unit') || 'celsius'; + const isFahrenheit = targetUnit.toLowerCase() === 'fahrenheit'; - return { - id: sensorId, - name: metadata.name, - type: metadata.type, - current, - min, - max, - history: historicalReadings, - warning: this.getThresholdsForType(metadata.type).warning, - critical: this.getThresholdsForType(metadata.type).critical, - }; - }); + const sensors = allSensorIds + .map((sensorId): TemperatureSensor | null => { + const { min, max } = this.history.getMinMax(sensorId); + const rawHistory = this.history.getHistory(sensorId); + const rawCurrent = rawHistory[rawHistory.length - 1]; + const metadata = this.history.getMetadata(sensorId)!; + + if (!rawCurrent) return null; + + // Convert for output + const current = this.convertReading(rawCurrent, isFahrenheit) as TemperatureReading; + const history = rawHistory + .map((h) => this.convertReading(h, isFahrenheit)) + .filter((h): h is TemperatureReading => h !== undefined); + const minConverted = this.convertReading(min, isFahrenheit); + const maxConverted = this.convertReading(max, isFahrenheit); + + return { + id: sensorId, + name: metadata.name, + type: metadata.type, + current, + min: minConverted, + max: maxConverted, + history, + warning: this.getThresholdsForType(metadata.type).warning, + critical: this.getThresholdsForType(metadata.type).critical, + }; + }) + .filter((s): s is TemperatureSensor => s !== null); return { id: 'temperature-metrics', @@ -196,6 +229,30 @@ export class TemperatureService implements OnModuleInit { }; } + private convertReading( + reading: TemperatureReading | undefined, + toFahrenheit: boolean + ): TemperatureReading | undefined { + if (!reading) return undefined; + + let val = reading.value; + let unit = reading.unit; + + if (toFahrenheit && reading.unit === TemperatureUnit.CELSIUS) { + val = (val * 9) / 5 + 32; + unit = TemperatureUnit.FAHRENHEIT; + } else if (!toFahrenheit && reading.unit === TemperatureUnit.FAHRENHEIT) { + val = ((val - 32) * 5) / 9; + unit = TemperatureUnit.CELSIUS; + } + + return { + ...reading, + value: Number(val.toFixed(2)), // Optional: round to 2 decimal places + unit, + }; + } + // Make status computation type-aware for future per-type thresholds private computeStatus(value: number, type: SensorType): TemperatureStatus { // Future: load thresholds from config based on type diff --git a/plugin/builder/build-txz.ts b/plugin/builder/build-txz.ts index c65c7c252d..f9490bc3d0 100644 --- a/plugin/builder/build-txz.ts +++ b/plugin/builder/build-txz.ts @@ -11,7 +11,6 @@ import { apiDir } from "./utils/paths"; import { getVendorBundleName, getVendorFullPath } from "./build-vendor-store"; import { getAssetUrl } from "./utils/bucket-urls"; import { validateStandaloneManifest, getStandaloneManifestPath } from "./utils/manifest-validator"; -//import { downloadMonitoringTools } from "./utils/monitor-tools"; //TODO(@mitchellthompkins): Remove this // Check for manifest files in expected locations @@ -181,7 +180,6 @@ const buildTxz = async (validatedEnv: TxzEnv) => { // Call during TXZ build process const sourceDir = join(startingDir, "source"); - //await downloadMonitoringTools(sourceDir); //TODO(@mitchellthompkins): Remove this // Use version from validated environment const version = validatedEnv.apiVersion; diff --git a/plugin/builder/utils/monitor-tools.ts b/plugin/builder/utils/monitor-tools.ts deleted file mode 100644 index b04dd28f79..0000000000 --- a/plugin/builder/utils/monitor-tools.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { join } from 'path'; -import { promises as fs } from 'fs'; -import crypto from 'crypto'; - -// Enhancement to the plugin build process -// Location: plugin/builder/build-txz.ts - -// Add function to download monitoring tools during build -export const downloadMonitoringTools = async (targetDir: string) => { - console.log("Downloading temperature monitoring tools from safe sources..."); - - const tools_to_download = [ - //{ - // name: 'sensors', - // url: 'https://github.com/lm-sensors/lm-sensors/releases/download/v3.6.0/sensors-3.6.0-x86_64', - // sha256: 'abc123', // Verify integrity - //}, - //{ - // name: 'smartctl', - // url: 'https://sourceforge.net/projects/smartmontools/files/smartmontools/7.4/smartctl-7.4-x86_64', - // sha256: 'def456...', // Verify integrity - //}, - //{ - // name: 'nvidia-smi', - // url: 'https://developer.nvidia.com/downloads/nvidia-smi-545.29.06-x86_64', - // sha256: 'ghi789...', // Verify integrity - //} - ]; - - const installed_tools = [ - { - name: 'sensors', - path: '/usr/sbin/sensors' - } - ]; - - const monitoringDir = join(targetDir, 'usr/local/emhttp/plugins/unraid-api/monitoring'); - await fs.mkdir(monitoringDir, { recursive: true }); - - for (const tool of tools_to_download) { - console.log(`Downloading ${tool.name}...`); - const response = await fetch(tool.url); - const buffer = await response.arrayBuffer(); - - // Verify SHA256 checksum - const hash = crypto.createHash('sha256'); - hash.update(Buffer.from(buffer)); - if (hash.digest('hex') !== tool.sha256) { - throw new Error(`Checksum verification failed for ${tool.name}`); - } - - // Save binary - const toolPath = join(monitoringDir, tool.name); - await fs.writeFile(toolPath, Buffer.from(buffer)); - await fs.chmod(toolPath, 0o755); - - console.log(`✓ ${tool.name} downloaded and verified`); - } - - for (const tool of installed_tools) { - try { - await fs.access(tool.path); - console.log(`✓ ${tool.name} found at ${tool.path}`); - } catch { - console.warn(`⚠ ${tool.name} not found on this system`); - } - } -}; From fa1354baf33c09d3684d3384500a010c54085296 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 1 Feb 2026 01:26:41 +0000 Subject: [PATCH 47/86] forgot to check this in --- api/docs/developer/temperature.md | 95 +++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 api/docs/developer/temperature.md diff --git a/api/docs/developer/temperature.md b/api/docs/developer/temperature.md new file mode 100644 index 0000000000..a7b67ae5d2 --- /dev/null +++ b/api/docs/developer/temperature.md @@ -0,0 +1,95 @@ +# Temperature Monitoring + +The Temperature Monitoring feature allows the Unraid API to collect and expose temperature metrics from various sensors (CPU, Disks, Motherboard, etc.). + +## Configuration + +You can configure the temperature monitoring behavior in your `api.json` (or via environment variables). + +### `api.temperature` Object + +| Key | Type | Default | Description | +| :--- | :--- | :--- | :--- | +| `enabled` | `boolean` | `true` | Globally enable or disable temperature monitoring. | +| `default_unit` | `string` | `"celsius"` | The unit to return values in. Options: `"celsius"`, `"fahrenheit"`. | +| `polling_interval` | `number` | `5000` | Polling interval in milliseconds for the subscription. | +| `history_size` | `number` | `100` | (Internal) Number of historical data points to keep in memory per sensor. | + +### `api.temperature.sensors` Object + +Enable or disable specific sensor providers. + +| Key | Type | Default | Description | +| :--- | :--- | :--- | :--- | +| `lm_sensors.enabled` | `boolean` | `true` | Enable `lm-sensors` provider (requires `sensors` binary). | +| `lm_sensors.config_path` | `string` | `null` | Optional path to a specific sensors config file (passed as `-c` to `sensors`). | +| `smartctl.enabled` | `boolean` | `true` | Enable disk temperature monitoring via `smartctl` (via DiskService). | +| `ipmi.enabled` | `boolean` | `true` | Enable IPMI sensor provider (requires `ipmitool`). | + +### `api.temperature.thresholds` Object + +Customize warning and critical thresholds. + +| Key | Type | Default | Description | +| :--- | :--- | :--- | :--- | +| `cpu_warning` | `number` | `70` | Warning threshold for CPU. | +| `cpu_critical` | `number` | `85` | Critical threshold for CPU. | +| `disk_warning` | `number` | `50` | Warning threshold for Disks. | +| `disk_critical` | `number` | `60` | Critical threshold for Disks. | + +## GraphQL API + +### Query: `metrics` -> `temperature` + +Returns a snapshot of the current temperature metrics. + +```graphql +query { + metrics { + temperature { + id + summary { + average + hottest { + name + current { value unit } + } + } + sensors { + id + name + type + current { + value + unit + status + } + history { + value + timestamp + } + } + } + } +} +``` + +### Subscription: `systemMetricsTemperature` + +Subscribes to temperature updates (pushed at `polling_interval`). + +```graphql +subscription { + systemMetricsTemperature { + summary { + average + } + sensors { + name + current { + value + } + } + } +} +``` From 9196778e12aa6abc746c6c74a596945ceaf8566f Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 1 Feb 2026 01:59:39 +0000 Subject: [PATCH 48/86] parse json instead of strings, because some drives return an unexpected format --- .../graph/resolvers/disks/disks.service.ts | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.service.ts b/api/src/unraid-api/graph/resolvers/disks/disks.service.ts index f1387ca23c..b1c352220e 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.service.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.service.ts @@ -19,25 +19,26 @@ export class DisksService { constructor(private readonly configService: ConfigService) {} public async getTemperature(device: string): Promise { try { - const { stdout } = await execa('smartctl', ['-A', device]); - const lines = stdout.split('\n'); - const header = lines.find((line) => line.startsWith('ID#')) ?? ''; - const fields = lines.splice(lines.indexOf(header) + 1, lines.length); - const field = fields.find( - (line) => - line.includes('Temperature_Celsius') || line.includes('Airflow_Temperature_Cel') - ); + // Use -j for JSON output + const { stdout } = await execa('smartctl', ['-n', 'standby', '-A', '-j', device]); + const data = JSON.parse(stdout); - if (!field) { - return null; + // 1. Try standard temperature object + if (data.temperature?.current) { + return data.temperature.current; } - if (field.includes('Min/Max')) { - return Number.parseInt(field.split(' - ')[1].trim().split(' ')[0], 10); + // 2. Try Attribute 194 or 190 + if (data.ata_smart_attributes?.table) { + const tempAttr = data.ata_smart_attributes.table.find( + (a: any) => a.id === 194 || a.id === 190 + ); + if (tempAttr?.raw?.value) { + return tempAttr.raw.value; + } } - const line = field.split(' '); - return Number.parseInt(line[line.length - 1], 10); + return null; } catch (error: unknown) { return null; } From f21e1aa443ad68d2f001a65a45aae8ce397fedc8 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 1 Feb 2026 03:28:45 +0000 Subject: [PATCH 49/86] fixed this diskservice unit test --- .../resolvers/disks/disks.service.spec.ts | 82 +++++++++++++++++-- 1 file changed, 73 insertions(+), 9 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts b/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts index 08e8bc87bd..e86c91491e 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts @@ -500,8 +500,23 @@ describe('DisksService', () => { describe('getTemperature', () => { it('should return temperature for a disk', async () => { mockExeca.mockResolvedValue({ - stdout: `ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE -194 Temperature_Celsius 0x0022 114 091 000 Old_age Always - 42`, + stdout: JSON.stringify({ + temperature: { current: 42 }, + ata_smart_attributes: { + table: [ + { + id: 194, + name: 'Temperature_Celsius', + flags: { string: '0x0022', value: 34 }, + value: 114, + worst: 91, + thresh: 0, + when_failed: '', + raw: { value: 42, string: '42' }, + }, + ], + }, + }), stderr: '', exitCode: 0, failed: false, @@ -512,19 +527,40 @@ describe('DisksService', () => { const temperature = await service.getTemperature('/dev/sda'); expect(temperature).toBe(42); - expect(mockExeca).toHaveBeenCalledWith('smartctl', ['-A', '/dev/sda']); + expect(mockExeca).toHaveBeenCalledWith('smartctl', [ + '-n', + 'standby', + '-A', + '-j', + '/dev/sda', + ]); }); it('should handle case where smartctl output has no temperature field', async () => { mockExeca.mockResolvedValue({ - stdout: 'ID# ATTRIBUTE_NAME\n1 Some_Attribute 100 100 000 Old_age Always - 0', + stdout: JSON.stringify({ + ata_smart_attributes: { + table: [ + { + id: 1, + name: 'Raw_Read_Error_Rate', + flags: { string: '0x002f', value: 47 }, + value: 200, + worst: 200, + thresh: 51, + when_failed: '', + raw: { value: 0, string: '0' }, + }, + ], + }, + }), stderr: '', exitCode: 0, failed: false, command: '', cwd: '', isCanceled: false, - }); // No temp line + }); const temperature = await service.getTemperature('/dev/sda'); expect(temperature).toBeNull(); @@ -532,8 +568,22 @@ describe('DisksService', () => { it('should handle case where smartctl output has Temperature_Celsius with Min/Max format', async () => { mockExeca.mockResolvedValue({ - stdout: `ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE -194 Temperature_Celsius 0x0022 070 060 000 Old_age Always - 30 (Min/Max 25/45)`, + stdout: JSON.stringify({ + ata_smart_attributes: { + table: [ + { + id: 194, + name: 'Temperature_Celsius', + flags: { string: '0x0022', value: 34 }, + value: 70, + worst: 60, + thresh: 0, + when_failed: '', + raw: { value: 30, string: '30 (Min/Max 25/45)' }, + }, + ], + }, + }), stderr: '', exitCode: 0, failed: false, @@ -548,8 +598,22 @@ describe('DisksService', () => { it('should handle case where smartctl output has Airflow_Temperature_Cel', async () => { mockExeca.mockResolvedValue({ - stdout: `ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE -190 Airflow_Temperature_Cel 0x0022 065 058 045 Old_age Always - 35 (Min/Max 30/42 #123)`, + stdout: JSON.stringify({ + ata_smart_attributes: { + table: [ + { + id: 190, + name: 'Airflow_Temperature_Cel', + flags: { string: '0x0022', value: 34 }, + value: 65, + worst: 58, + thresh: 45, + when_failed: '', + raw: { value: 35, string: '35 (Min/Max 30/42 #123)' }, + }, + ], + }, + }), stderr: '', exitCode: 0, failed: false, From a21c31c3e8f2cbd42042c6cf958272412e250972 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 1 Feb 2026 03:41:08 +0000 Subject: [PATCH 50/86] add ipmi config --- api/src/unraid-api/cli/generated/graphql.ts | 29 +++ .../unraid-shared/src/services/api-config.ts | 14 ++ .../dynamix.unraid.net/install/doinst.sh | 184 ++++++++++++++++++ 3 files changed, 227 insertions(+) diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index 27032c24b9..85d601268b 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -560,6 +560,17 @@ export type CpuLoad = { percentUser: Scalars['Float']['output']; }; +export type CpuPackages = Node & { + __typename?: 'CpuPackages'; + id: Scalars['PrefixedID']['output']; + /** Power draw per package (W) */ + power: Array; + /** Temperature per package (°C) */ + temp: Array; + /** Total CPU package power draw (W) */ + totalPower: Scalars['Float']['output']; +}; + export type CpuUtilization = Node & { __typename?: 'CpuUtilization'; /** CPU load for each core */ @@ -591,6 +602,19 @@ export type Customization = { theme: Theme; }; +/** Customization related mutations */ +export type CustomizationMutations = { + __typename?: 'CustomizationMutations'; + /** Update the UI theme (writes dynamix.cfg) */ + setTheme: Theme; +}; + + +/** Customization related mutations */ +export type CustomizationMutationsSetThemeArgs = { + theme: ThemeName; +}; + export type DeleteApiKeyInput = { ids: Array; }; @@ -1065,6 +1089,7 @@ export type InfoCpu = Node & { manufacturer?: Maybe; /** CPU model */ model?: Maybe; + packages: CpuPackages; /** Number of physical processors */ processors?: Maybe; /** CPU revision */ @@ -1081,6 +1106,8 @@ export type InfoCpu = Node & { stepping?: Maybe; /** Number of CPU threads */ threads?: Maybe; + /** Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]] */ + topology: Array>>; /** CPU vendor */ vendor?: Maybe; /** CPU voltage */ @@ -1422,6 +1449,7 @@ export type Mutation = { createDockerFolderWithItems: ResolvedOrganizerV1; /** Creates a new notification record */ createNotification: Notification; + customization: CustomizationMutations; /** Deletes all archived notifications on server. */ deleteArchivedNotifications: NotificationOverview; deleteDockerEntries: ResolvedOrganizerV1; @@ -2269,6 +2297,7 @@ export type Subscription = { parityHistorySubscription: ParityCheck; serversSubscription: Server; systemMetricsCpu: CpuUtilization; + systemMetricsCpuTelemetry: CpuPackages; systemMetricsMemory: MemoryUtilization; upsUpdates: UpsDevice; }; diff --git a/packages/unraid-shared/src/services/api-config.ts b/packages/unraid-shared/src/services/api-config.ts index cb998b08f4..3c679b6af0 100644 --- a/packages/unraid-shared/src/services/api-config.ts +++ b/packages/unraid-shared/src/services/api-config.ts @@ -59,6 +59,14 @@ export class SmartctlConfig { enabled?: boolean; } +@ObjectType() +export class IpmiConfig { + @Field({ nullable: true }) + @IsOptional() + @IsBoolean() + enabled?: boolean; +} + @ObjectType() export class SensorsConfig { @Field(() => LmSensorsConfig, { nullable: true }) @@ -72,6 +80,12 @@ export class SensorsConfig { @ValidateNested() @Type(() => SmartctlConfig) smartctl?: SmartctlConfig; + + @Field(() => IpmiConfig, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => IpmiConfig) + ipmi?: IpmiConfig; } @ObjectType() diff --git a/plugin/source/dynamix.unraid.net/install/doinst.sh b/plugin/source/dynamix.unraid.net/install/doinst.sh index e18f5f64eb..43c13d2abe 100644 --- a/plugin/source/dynamix.unraid.net/install/doinst.sh +++ b/plugin/source/dynamix.unraid.net/install/doinst.sh @@ -31,3 +31,187 @@ cp usr/local/unraid-api/.env.production usr/local/unraid-api/.env ( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npm-cli.js npm ) ( cd usr/local/bin ; rm -rf npx ) ( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npx-cli.js npx ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf apollo-pbjs ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../@apollo/protobufjs/bin/pbjs apollo-pbjs ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf apollo-pbts ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../@apollo/protobufjs/bin/pbts apollo-pbts ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf blessed ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../blessed/bin/tput.js blessed ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf esbuild ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../esbuild/bin/esbuild esbuild ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf escodegen ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../escodegen/bin/escodegen.js escodegen ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf esgenerate ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../escodegen/bin/esgenerate.js esgenerate ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf esparse ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../esprima/bin/esparse.js esparse ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf esvalidate ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../esprima/bin/esvalidate.js esvalidate ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf fxparser ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../fast-xml-parser/src/cli/cli.js fxparser ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf glob ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../glob/dist/esm/bin.mjs glob ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf js-yaml ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../js-yaml/bin/js-yaml.js js-yaml ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf jsesc ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../jsesc/bin/jsesc jsesc ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf loose-envify ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../loose-envify/cli.js loose-envify ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf mime ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../mime/cli.js mime ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf mkdirp ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../mkdirp/bin/cmd.js mkdirp ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf mustache ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../mustache/bin/mustache mustache ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf needle ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../needle/bin/needle needle ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf node-pre-gyp ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../@mapbox/node-pre-gyp/bin/node-pre-gyp node-pre-gyp ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf node-which ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../which/bin/node-which node-which ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf nopt ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../nopt/bin/nopt.js nopt ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf opencollective ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../@nuxt/opencollective/bin/opencollective.js opencollective ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf parser ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../@babel/parser/bin/babel-parser.js parser ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf pino ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../pino/bin.js pino ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf pino-pretty ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../pino-pretty/bin.js pino-pretty ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf pm2 ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../pm2/bin/pm2 pm2 ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf pm2-dev ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../pm2/bin/pm2-dev pm2-dev ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf pm2-docker ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../pm2/bin/pm2-docker pm2-docker ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf pm2-runtime ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../pm2/bin/pm2-runtime pm2-runtime ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf prettier ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../prettier/bin/prettier.cjs prettier ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf proto-loader-gen-types ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../@grpc/proto-loader/build/bin/proto-loader-gen-types.js proto-loader-gen-types ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf relay-compiler ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../@ardatan/relay-compiler/bin/relay-compiler relay-compiler ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf resolve ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../resolve/bin/resolve resolve ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf semver ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../semver/bin/semver.js semver ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf sha.js ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../sha.js/bin.js sha.js ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf systeminformation ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../systeminformation/lib/cli.js systeminformation ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf tsx ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../tsx/dist/cli.mjs tsx ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf ua-parser-js ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../ua-parser-js/script/cli.js ua-parser-js ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf uuid ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../uuid/dist-node/bin/uuid uuid ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf wireit ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../wireit/bin/wireit.js wireit ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf xss ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../xss/bin/xss xss ) +( cd usr/local/unraid-api/node_modules/@apollo/server/node_modules/.bin ; rm -rf uuid ) +( cd usr/local/unraid-api/node_modules/@apollo/server/node_modules/.bin ; ln -sf ../uuid/dist/bin/uuid uuid ) +( cd usr/local/unraid-api/node_modules/@grpc/grpc-js/node_modules/.bin ; rm -rf proto-loader-gen-types ) +( cd usr/local/unraid-api/node_modules/@grpc/grpc-js/node_modules/.bin ; ln -sf ../@grpc/proto-loader/build/bin/proto-loader-gen-types.js proto-loader-gen-types ) +( cd usr/local/unraid-api/node_modules/@pm2/agent/node_modules/.bin ; rm -rf semver ) +( cd usr/local/unraid-api/node_modules/@pm2/agent/node_modules/.bin ; ln -sf ../semver/bin/semver.js semver ) +( cd usr/local/unraid-api/node_modules/@pm2/io/node_modules/.bin ; rm -rf semver ) +( cd usr/local/unraid-api/node_modules/@pm2/io/node_modules/.bin ; ln -sf ../semver/bin/semver.js semver ) +( cd usr/local/unraid-api/node_modules/@runonflux/nat-upnp/node_modules/.bin ; rm -rf fxparser ) +( cd usr/local/unraid-api/node_modules/@runonflux/nat-upnp/node_modules/.bin ; ln -sf ../fast-xml-parser/src/cli/cli.js fxparser ) +( cd usr/local/unraid-api/node_modules/dockerode/node_modules/.bin ; rm -rf uuid ) +( cd usr/local/unraid-api/node_modules/dockerode/node_modules/.bin ; ln -sf ../uuid/dist/bin/uuid uuid ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf apollo-pbjs ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../@apollo/protobufjs/bin/pbjs apollo-pbjs ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf apollo-pbts ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../@apollo/protobufjs/bin/pbts apollo-pbts ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf blessed ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../blessed/bin/tput.js blessed ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf esbuild ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../esbuild/bin/esbuild esbuild ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf escodegen ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../escodegen/bin/escodegen.js escodegen ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf esgenerate ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../escodegen/bin/esgenerate.js esgenerate ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf esparse ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../esprima/bin/esparse.js esparse ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf esvalidate ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../esprima/bin/esvalidate.js esvalidate ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf fxparser ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../fast-xml-parser/src/cli/cli.js fxparser ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf glob ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../glob/dist/esm/bin.mjs glob ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf js-yaml ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../js-yaml/bin/js-yaml.js js-yaml ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf jsesc ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../jsesc/bin/jsesc jsesc ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf loose-envify ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../loose-envify/cli.js loose-envify ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf mime ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../mime/cli.js mime ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf mkdirp ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../mkdirp/bin/cmd.js mkdirp ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf mustache ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../mustache/bin/mustache mustache ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf needle ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../needle/bin/needle needle ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf node-pre-gyp ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../@mapbox/node-pre-gyp/bin/node-pre-gyp node-pre-gyp ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf node-which ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../which/bin/node-which node-which ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf nopt ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../nopt/bin/nopt.js nopt ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf opencollective ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../@nuxt/opencollective/bin/opencollective.js opencollective ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf parser ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../@babel/parser/bin/babel-parser.js parser ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf pino ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../pino/bin.js pino ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf pino-pretty ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../pino-pretty/bin.js pino-pretty ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf pm2 ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../pm2/bin/pm2 pm2 ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf pm2-dev ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../pm2/bin/pm2-dev pm2-dev ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf pm2-docker ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../pm2/bin/pm2-docker pm2-docker ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf pm2-runtime ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../pm2/bin/pm2-runtime pm2-runtime ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf prettier ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../prettier/bin/prettier.cjs prettier ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf proto-loader-gen-types ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../@grpc/proto-loader/build/bin/proto-loader-gen-types.js proto-loader-gen-types ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf relay-compiler ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../@ardatan/relay-compiler/bin/relay-compiler relay-compiler ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf resolve ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../resolve/bin/resolve resolve ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf semver ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../semver/bin/semver.js semver ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf sha.js ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../sha.js/bin.js sha.js ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf systeminformation ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../systeminformation/lib/cli.js systeminformation ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf tsx ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../tsx/dist/cli.mjs tsx ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf ua-parser-js ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../ua-parser-js/script/cli.js ua-parser-js ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf uuid ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../uuid/dist-node/bin/uuid uuid ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf wireit ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../wireit/bin/wireit.js wireit ) +( cd usr/local/unraid-api/node_modules/.bin ; rm -rf xss ) +( cd usr/local/unraid-api/node_modules/.bin ; ln -sf ../xss/bin/xss xss ) +( cd usr/local/unraid-api/node_modules/@apollo/server/node_modules/.bin ; rm -rf uuid ) +( cd usr/local/unraid-api/node_modules/@apollo/server/node_modules/.bin ; ln -sf ../uuid/dist/bin/uuid uuid ) +( cd usr/local/unraid-api/node_modules/@grpc/grpc-js/node_modules/.bin ; rm -rf proto-loader-gen-types ) +( cd usr/local/unraid-api/node_modules/@grpc/grpc-js/node_modules/.bin ; ln -sf ../@grpc/proto-loader/build/bin/proto-loader-gen-types.js proto-loader-gen-types ) +( cd usr/local/unraid-api/node_modules/@pm2/agent/node_modules/.bin ; rm -rf semver ) +( cd usr/local/unraid-api/node_modules/@pm2/agent/node_modules/.bin ; ln -sf ../semver/bin/semver.js semver ) +( cd usr/local/unraid-api/node_modules/@pm2/io/node_modules/.bin ; rm -rf semver ) +( cd usr/local/unraid-api/node_modules/@pm2/io/node_modules/.bin ; ln -sf ../semver/bin/semver.js semver ) +( cd usr/local/unraid-api/node_modules/@runonflux/nat-upnp/node_modules/.bin ; rm -rf fxparser ) +( cd usr/local/unraid-api/node_modules/@runonflux/nat-upnp/node_modules/.bin ; ln -sf ../fast-xml-parser/src/cli/cli.js fxparser ) +( cd usr/local/unraid-api/node_modules/dockerode/node_modules/.bin ; rm -rf uuid ) +( cd usr/local/unraid-api/node_modules/dockerode/node_modules/.bin ; ln -sf ../uuid/dist/bin/uuid uuid ) From 2f7a26a64c2cd51270b6ec33fe93ed0a9fc6c521 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 1 Feb 2026 03:47:59 +0000 Subject: [PATCH 51/86] slight update --- api/docs/developer/temperature.md | 47 ++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/api/docs/developer/temperature.md b/api/docs/developer/temperature.md index a7b67ae5d2..6dd16fa86c 100644 --- a/api/docs/developer/temperature.md +++ b/api/docs/developer/temperature.md @@ -4,7 +4,9 @@ The Temperature Monitoring feature allows the Unraid API to collect and expose t ## Configuration -You can configure the temperature monitoring behavior in your `api.json` (or via environment variables). +You can configure the temperature monitoring behavior in your `api.json` (or via +environment variables). Nominally the `api.json` file is found at +`/boot/config/plugins/dynamix.my.servers/configs/`. ### `api.temperature` Object @@ -37,6 +39,49 @@ Customize warning and critical thresholds. | `disk_warning` | `number` | `50` | Warning threshold for Disks. | | `disk_critical` | `number` | `60` | Critical threshold for Disks. | +### Sample Configuration + +Example of an `api.json` configuration file: + +```json +{ + "version": "4.28.2+9196778e", + "extraOrigins": [], + "sandbox": true, + "ssoSubIds": [], + "plugins": [ + "unraid-api-plugin-connect" + ], + "temperature": { + "enabled": true, + "polling_interval": 10000, + "default_unit": "celsius", + "history": { + "max_readings": 144, + "retention_ms": 86400000 + }, + "thresholds": { + "cpu_warning": 75, + "cpu_critical": 90, + "disk_warning": 50, + "disk_critical": 60 + }, + "sensors": { + "lm_sensors": { + "enabled": true, + "config_path": "/etc/sensors3.conf" + }, + "smartctl": { + "enabled": true + }, + "ipmi": { + "enabled": false + } + } + } +} +``` + ## GraphQL API ### Query: `metrics` -> `temperature` From 7b49f8b37ab543fdd535d5e4f9146142044701ba Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 1 Feb 2026 03:52:01 +0000 Subject: [PATCH 52/86] remove AI lies --- api/docs/developer/temperature.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/docs/developer/temperature.md b/api/docs/developer/temperature.md index 6dd16fa86c..f9e5608496 100644 --- a/api/docs/developer/temperature.md +++ b/api/docs/developer/temperature.md @@ -4,8 +4,8 @@ The Temperature Monitoring feature allows the Unraid API to collect and expose t ## Configuration -You can configure the temperature monitoring behavior in your `api.json` (or via -environment variables). Nominally the `api.json` file is found at +You can configure the temperature monitoring behavior in your `api.json`. +Nominally the `api.json` file is found at `/boot/config/plugins/dynamix.my.servers/configs/`. ### `api.temperature` Object From 01644ce36cb7c5a187c897b92b8f9d8081d666ac Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 1 Feb 2026 03:58:01 +0000 Subject: [PATCH 53/86] handle more temperature types --- .../temperature/temperature.service.spec.ts | 34 +++++++++ .../temperature/temperature.service.ts | 76 +++++++++++++------ 2 files changed, 87 insertions(+), 23 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts index 352e7ba8e6..82a3e8c14e 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts @@ -155,6 +155,40 @@ describe('TemperatureService', () => { const metrics = await customService.getMetrics(); expect(metrics?.sensors[0].current.status).toBe(TemperatureStatus.WARNING); }); + + it('should return temperature metrics in Kelvin when configured', async () => { + const customConfigService = { + get: vi.fn((key: string, defaultValue?: any) => { + if (key === 'api.temperature.default_unit') { + return 'kelvin'; + } + return defaultValue; + }), + } as any; + + const customService = new TemperatureService( + lmSensors, + diskSensors, + ipmiSensors, + history, + customConfigService + ); + await customService.onModuleInit(); + + vi.mocked(lmSensors.read).mockResolvedValue([ + { + id: 'cpu:package', + name: 'CPU Package', + type: SensorType.CPU_PACKAGE, + value: 0, + unit: TemperatureUnit.CELSIUS, + }, + ]); + + const metrics = await customService.getMetrics(); + expect(metrics?.sensors[0].current.value).toBe(273.15); + expect(metrics?.sensors[0].current.unit).toBe(TemperatureUnit.KELVIN); + }); }); describe('buildSummary', () => { diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index d0a3ce06cf..2e2aa3295b 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -127,9 +127,10 @@ export class TemperatureService implements OnModuleInit { return null; } - const targetUnit = + const configUnit = this.configService.get('api.temperature.default_unit') || 'celsius'; - const isFahrenheit = targetUnit.toLowerCase() === 'fahrenheit'; + const targetUnit = + (TemperatureUnit as any)[configUnit.toUpperCase()] || TemperatureUnit.CELSIUS; const sensors: TemperatureSensor[] = allRawSensors.map((r) => { const rawCurrent: TemperatureReading = { @@ -150,12 +151,12 @@ export class TemperatureService implements OnModuleInit { const rawHistory = this.history.getHistory(r.id); // Convert for output - const current = this.convertReading(rawCurrent, isFahrenheit) as TemperatureReading; + const current = this.convertReading(rawCurrent, targetUnit) as TemperatureReading; const history = rawHistory - .map((h) => this.convertReading(h, isFahrenheit)) + .map((h) => this.convertReading(h, targetUnit)) .filter((h): h is TemperatureReading => h !== undefined); - const minConverted = this.convertReading(min, isFahrenheit); - const maxConverted = this.convertReading(max, isFahrenheit); + const minConverted = this.convertReading(min, targetUnit); + const maxConverted = this.convertReading(max, targetUnit); return { id: r.id, @@ -188,8 +189,8 @@ export class TemperatureService implements OnModuleInit { return null; } - const targetUnit = this.configService.get('api.temperature.default_unit') || 'celsius'; - const isFahrenheit = targetUnit.toLowerCase() === 'fahrenheit'; + const configUnit = this.configService.get('api.temperature.default_unit') || 'celsius'; + const targetUnit = (TemperatureUnit as any)[configUnit.toUpperCase()] || TemperatureUnit.CELSIUS; const sensors = allSensorIds .map((sensorId): TemperatureSensor | null => { @@ -201,12 +202,12 @@ export class TemperatureService implements OnModuleInit { if (!rawCurrent) return null; // Convert for output - const current = this.convertReading(rawCurrent, isFahrenheit) as TemperatureReading; + const current = this.convertReading(rawCurrent, targetUnit) as TemperatureReading; const history = rawHistory - .map((h) => this.convertReading(h, isFahrenheit)) + .map((h) => this.convertReading(h, targetUnit)) .filter((h): h is TemperatureReading => h !== undefined); - const minConverted = this.convertReading(min, isFahrenheit); - const maxConverted = this.convertReading(max, isFahrenheit); + const minConverted = this.convertReading(min, targetUnit); + const maxConverted = this.convertReading(max, targetUnit); return { id: sensorId, @@ -231,25 +232,54 @@ export class TemperatureService implements OnModuleInit { private convertReading( reading: TemperatureReading | undefined, - toFahrenheit: boolean + targetUnit: TemperatureUnit ): TemperatureReading | undefined { if (!reading) return undefined; - let val = reading.value; - let unit = reading.unit; + let celsius: number; + + // Convert input to Celsius + switch (reading.unit) { + case TemperatureUnit.CELSIUS: + celsius = reading.value; + break; + case TemperatureUnit.FAHRENHEIT: + celsius = ((reading.value - 32) * 5) / 9; + break; + case TemperatureUnit.KELVIN: + celsius = reading.value - 273.15; + break; + case TemperatureUnit.RANKINE: + celsius = ((reading.value - 491.67) * 5) / 9; + break; + default: + celsius = reading.value; + } - if (toFahrenheit && reading.unit === TemperatureUnit.CELSIUS) { - val = (val * 9) / 5 + 32; - unit = TemperatureUnit.FAHRENHEIT; - } else if (!toFahrenheit && reading.unit === TemperatureUnit.FAHRENHEIT) { - val = ((val - 32) * 5) / 9; - unit = TemperatureUnit.CELSIUS; + let targetValue: number; + + // Convert Celsius to target + switch (targetUnit) { + case TemperatureUnit.CELSIUS: + targetValue = celsius; + break; + case TemperatureUnit.FAHRENHEIT: + targetValue = (celsius * 9) / 5 + 32; + break; + case TemperatureUnit.KELVIN: + targetValue = celsius + 273.15; + break; + case TemperatureUnit.RANKINE: + targetValue = ((celsius + 273.15) * 9) / 5; + break; + default: + targetValue = celsius; } return { ...reading, - value: Number(val.toFixed(2)), // Optional: round to 2 decimal places - unit, + value: Number(targetValue.toFixed(2)), + unit: targetUnit, }; } From 6fbad303a0129e542addceaf25d2566d7e1cf4aa Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 1 Feb 2026 03:59:10 +0000 Subject: [PATCH 54/86] add more silly temperatures --- .../temperature/temperature.service.spec.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts index 82a3e8c14e..5e18a3ba94 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts @@ -189,6 +189,41 @@ describe('TemperatureService', () => { expect(metrics?.sensors[0].current.value).toBe(273.15); expect(metrics?.sensors[0].current.unit).toBe(TemperatureUnit.KELVIN); }); + + it('should return temperature metrics in Rankine when configured', async () => { + const customConfigService = { + get: vi.fn((key: string, defaultValue?: any) => { + if (key === 'api.temperature.default_unit') { + return 'rankine'; + } + return defaultValue; + }), + } as any; + + const customService = new TemperatureService( + lmSensors, + diskSensors, + ipmiSensors, + history, + customConfigService + ); + await customService.onModuleInit(); + + vi.mocked(lmSensors.read).mockResolvedValue([ + { + id: 'cpu:package', + name: 'CPU Package', + type: SensorType.CPU_PACKAGE, + value: 25, + unit: TemperatureUnit.CELSIUS, + }, + ]); + + const metrics = await customService.getMetrics(); + // (25 + 273.15) * 9/5 = 536.67 + expect(metrics?.sensors[0].current.value).toBe(536.67); + expect(metrics?.sensors[0].current.unit).toBe(TemperatureUnit.RANKINE); + }); }); describe('buildSummary', () => { From d855946fd0486dce5ff5cdb6f9b6fac634e88544 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 1 Feb 2026 07:00:37 +0000 Subject: [PATCH 55/86] I forgot to check in this file! --- .../sensors/ipmi_sensors.service.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/ipmi_sensors.service.ts diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/ipmi_sensors.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/ipmi_sensors.service.ts new file mode 100644 index 0000000000..0560b50fd1 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/ipmi_sensors.service.ts @@ -0,0 +1,92 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { execa } from 'execa'; + +import { + RawTemperatureSensor, + TemperatureSensorProvider, +} from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/sensor.interface.js'; +import { + SensorType, + TemperatureUnit, +} from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; + +@Injectable() +export class IpmiSensorsService implements TemperatureSensorProvider { + readonly id = 'ipmi-sensors'; + private readonly logger = new Logger(IpmiSensorsService.name); + + constructor(private readonly configService: ConfigService) {} + + async isAvailable(): Promise { + try { + await execa('ipmitool', ['-V']); + return true; + } catch { + return false; + } + } + + async read(): Promise { + // We can add config for arguments if needed, similar to lm-sensors + // const extraArgs = this.configService.get('api.temperature.sensors.ipmi.args', []); + + try { + // 'sdr type temperature' returns sensors specifically for temperature + const { stdout } = await execa('ipmitool', ['sdr', 'type', 'temperature']); + + return this.parseIpmiOutput(stdout); + } catch (err) { + this.logger.error('Failed to read IPMI sensors', err); + return []; + } + } + + private parseIpmiOutput(output: string): RawTemperatureSensor[] { + const sensors: RawTemperatureSensor[] = []; + const lines = output.split('\n'); + + // Example output line: + // CPU Temp | 40 degrees C | ok + // System Temp | 35 degrees C | ok + + for (const line of lines) { + const parts = line.split('|').map((s) => s.trim()); + if (parts.length < 2) continue; + + const name = parts[0]; + const readingParts = parts[1].split(' '); + const valueStr = readingParts[0]; + const unitStr = readingParts.slice(1).join(' '); // "degrees C" + + const value = parseFloat(valueStr); + + if (isNaN(value)) continue; + + // Simple unit detection + let unit = TemperatureUnit.CELSIUS; + if (unitStr.toLowerCase().includes('f')) { + unit = TemperatureUnit.FAHRENHEIT; + } + + sensors.push({ + id: `ipmi:${name.replace(/\s+/g, '_').toLowerCase()}`, + name: name, + type: this.inferType(name), + value: value, + unit: unit, + }); + } + + return sensors; + } + + private inferType(name: string): SensorType { + const n = name.toLowerCase(); + if (n.includes('cpu')) return SensorType.CPU_PACKAGE; + if (n.includes('system') || n.includes('ambient')) return SensorType.MOTHERBOARD; + if (n.includes('fan')) return SensorType.CUSTOM; // Should not happen with 'type temperature' + return SensorType.CUSTOM; + } +} From 5c9474649ac2b58f5e1f7715e49eb72e9990a0f6 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 1 Feb 2026 07:05:27 +0000 Subject: [PATCH 56/86] add default api.json for development --- api/dev/configs/api.json | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index 72e49be7c3..606a309980 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -3,5 +3,31 @@ "extraOrigins": [], "sandbox": false, "ssoSubIds": [], - "plugins": [] -} \ No newline at end of file + "plugins": [], + "temperature": { + "enabled": true, + "polling_interval": 5000, + "default_unit": "celsius", + "history": { + "max_readings": 100, + "retention_ms": 86400000 + }, + "thresholds": { + "cpu_warning": 70, + "cpu_critical": 85, + "disk_warning": 50, + "disk_critical": 60 + }, + "sensors": { + "lm_sensors": { + "enabled": true + }, + "smartctl": { + "enabled": true + }, + "ipmi": { + "enabled": true + } + } + } +} From 5e18bfb719837bc6817a3d946955db25619f8825 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 1 Feb 2026 07:14:45 +0000 Subject: [PATCH 57/86] allow thresholds to use target units --- .../temperature/temperature.service.spec.ts | 36 +++++++++ .../temperature/temperature.service.ts | 73 +++++++++++++------ 2 files changed, 88 insertions(+), 21 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts index 5e18a3ba94..affcb3cca8 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts @@ -224,6 +224,42 @@ describe('TemperatureService', () => { expect(metrics?.sensors[0].current.value).toBe(536.67); expect(metrics?.sensors[0].current.unit).toBe(TemperatureUnit.RANKINE); }); + + it('should return thresholds in the target unit', async () => { + const customConfigService = { + get: vi.fn((key: string, defaultValue?: any) => { + if (key === 'api.temperature.default_unit') { + return 'fahrenheit'; + } + return defaultValue; + }), + } as any; + + const customService = new TemperatureService( + lmSensors, + diskSensors, + ipmiSensors, + history, + customConfigService + ); + await customService.onModuleInit(); + + vi.mocked(lmSensors.read).mockResolvedValue([ + { + id: 'cpu:package', + name: 'CPU Package', + type: SensorType.CPU_PACKAGE, + value: 20, + unit: TemperatureUnit.CELSIUS, + }, + ]); + + const metrics = await customService.getMetrics(); + // Default CPU warning is 70C -> 158F + // Default CPU critical is 85C -> 185F + expect(metrics?.sensors[0].warning).toBe(158); + expect(metrics?.sensors[0].critical).toBe(185); + }); }); describe('buildSummary', () => { diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index 2e2aa3295b..c44a44d513 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -137,7 +137,7 @@ export class TemperatureService implements OnModuleInit { value: r.value, unit: r.unit, timestamp: new Date(), - status: this.computeStatus(r.value, r.type), + status: this.computeStatus(r.value, r.unit, r.type), }; // Record in history (ALWAYS RAW) @@ -158,6 +158,18 @@ export class TemperatureService implements OnModuleInit { const minConverted = this.convertReading(min, targetUnit); const maxConverted = this.convertReading(max, targetUnit); + const rawThresholds = this.getThresholdsForType(r.type); + const warning = this.convertValue( + rawThresholds.warning, + TemperatureUnit.CELSIUS, + targetUnit + ); + const critical = this.convertValue( + rawThresholds.critical, + TemperatureUnit.CELSIUS, + targetUnit + ); + return { id: r.id, name: r.name, @@ -166,8 +178,8 @@ export class TemperatureService implements OnModuleInit { min: minConverted, max: maxConverted, history, - warning: this.getThresholdsForType(r.type).warning, - critical: this.getThresholdsForType(r.type).critical, + warning, + critical, }; }); @@ -209,6 +221,18 @@ export class TemperatureService implements OnModuleInit { const minConverted = this.convertReading(min, targetUnit); const maxConverted = this.convertReading(max, targetUnit); + const rawThresholds = this.getThresholdsForType(metadata.type); + const warning = this.convertValue( + rawThresholds.warning, + TemperatureUnit.CELSIUS, + targetUnit + ); + const critical = this.convertValue( + rawThresholds.critical, + TemperatureUnit.CELSIUS, + targetUnit + ); + return { id: sensorId, name: metadata.name, @@ -217,8 +241,8 @@ export class TemperatureService implements OnModuleInit { min: minConverted, max: maxConverted, history, - warning: this.getThresholdsForType(metadata.type).warning, - critical: this.getThresholdsForType(metadata.type).critical, + warning, + critical, }; }) .filter((s): s is TemperatureSensor => s !== null); @@ -236,30 +260,40 @@ export class TemperatureService implements OnModuleInit { ): TemperatureReading | undefined { if (!reading) return undefined; + return { + ...reading, + value: this.convertValue(reading.value, reading.unit, targetUnit), + unit: targetUnit, + }; + } + + private convertValue(value: number, fromUnit: TemperatureUnit, toUnit: TemperatureUnit): number { + if (fromUnit === toUnit) return Number(value.toFixed(2)); + let celsius: number; // Convert input to Celsius - switch (reading.unit) { + switch (fromUnit) { case TemperatureUnit.CELSIUS: - celsius = reading.value; + celsius = value; break; case TemperatureUnit.FAHRENHEIT: - celsius = ((reading.value - 32) * 5) / 9; + celsius = ((value - 32) * 5) / 9; break; case TemperatureUnit.KELVIN: - celsius = reading.value - 273.15; + celsius = value - 273.15; break; case TemperatureUnit.RANKINE: - celsius = ((reading.value - 491.67) * 5) / 9; + celsius = ((value - 491.67) * 5) / 9; break; default: - celsius = reading.value; + celsius = value; } let targetValue: number; // Convert Celsius to target - switch (targetUnit) { + switch (toUnit) { case TemperatureUnit.CELSIUS: targetValue = celsius; break; @@ -276,20 +310,17 @@ export class TemperatureService implements OnModuleInit { targetValue = celsius; } - return { - ...reading, - value: Number(targetValue.toFixed(2)), - unit: targetUnit, - }; + return Number(targetValue.toFixed(2)); } // Make status computation type-aware for future per-type thresholds - private computeStatus(value: number, type: SensorType): TemperatureStatus { - // Future: load thresholds from config based on type + private computeStatus(value: number, unit: TemperatureUnit, type: SensorType): TemperatureStatus { + // We always compute status using Celsius thresholds + const celsiusValue = this.convertValue(value, unit, TemperatureUnit.CELSIUS); const thresholds = this.getThresholdsForType(type); - if (value >= thresholds.critical) return TemperatureStatus.CRITICAL; - if (value >= thresholds.warning) return TemperatureStatus.WARNING; + if (celsiusValue >= thresholds.critical) return TemperatureStatus.CRITICAL; + if (celsiusValue >= thresholds.warning) return TemperatureStatus.WARNING; return TemperatureStatus.NORMAL; } From 52e5b322176f5fdec9184b7eab77e584fb161444 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 1 Feb 2026 07:36:01 +0000 Subject: [PATCH 58/86] need to include the original dev api content --- api/dev/configs/api.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index 606a309980..452680cdd4 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -3,7 +3,9 @@ "extraOrigins": [], "sandbox": false, "ssoSubIds": [], - "plugins": [], + "plugins": [ + "unraid-api-plugin-connect" + ], "temperature": { "enabled": true, "polling_interval": 5000, From fd8181025f9037e719d7eb1cb8ffa59891995a7a Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 1 Feb 2026 07:57:17 +0000 Subject: [PATCH 59/86] remove incorrect comment --- .../graph/resolvers/metrics/temperature/temperature.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index c44a44d513..d41b04ef6f 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -35,7 +35,6 @@ export class TemperatureService implements OnModuleInit { private readonly ipmiSensors: IpmiSensorsService, // Future: private readonly gpuSensors: GpuSensorsService, - // Future: private readonly diskSensors: DiskSensorsService, private readonly history: TemperatureHistoryService, private readonly configService: ConfigService ) {} From f9cad0dd41ab1c90c010bcf1466d2ab8a10cf869 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 1 Feb 2026 08:50:55 +0000 Subject: [PATCH 60/86] Handle user defined temperature thresholds and typo clean-up --- .../metrics/metrics.resolver.spec.ts | 1 - .../temperature/temperature.service.spec.ts | 43 +++++++++++++++++++ .../temperature/temperature.service.ts | 17 ++++++-- 3 files changed, 56 insertions(+), 5 deletions(-) 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 801f7294af..3eff9c820e 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 @@ -89,7 +89,6 @@ describe('MetricsResolver', () => { provide: TemperatureService, useValue: { getMetrics: vi.fn().mockResolvedValue(null), - // Add any other methods your resolver calls }, }, { diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts index affcb3cca8..b5384ae464 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts @@ -260,6 +260,49 @@ describe('TemperatureService', () => { expect(metrics?.sensors[0].warning).toBe(158); expect(metrics?.sensors[0].critical).toBe(185); }); + + it('should interpret user-defined thresholds in the default unit', async () => { + const customConfigService = { + get: vi.fn((key: string, defaultValue?: any) => { + if (key === 'api.temperature.default_unit') { + return 'fahrenheit'; + } + if (key === 'api.temperature.thresholds') { + // User sets warning to 160F (approx 71.1C) + return { cpu_warning: 160 }; + } + return defaultValue; + }), + } as any; + + const customService = new TemperatureService( + lmSensors, + diskSensors, + ipmiSensors, + history, + customConfigService + ); + await customService.onModuleInit(); + + vi.mocked(lmSensors.read).mockResolvedValue([ + { + id: 'cpu:package', + name: 'CPU Package', + type: SensorType.CPU_PACKAGE, + value: 72, // 72C (161.6F) -> Should trigger warning (161.6 > 160) + unit: TemperatureUnit.CELSIUS, + }, + ]); + + const metrics = await customService.getMetrics(); + + // Check status: 72C (161.6F) > 160F Warning -> Should be WARNING + expect(metrics?.sensors[0].current.status).toBe(TemperatureStatus.WARNING); + + // Check returned threshold: Should be 160 (F) + // Internal flow: Config 160(F) -> getThresholds(converts to 71.11C) -> getMetrics(converts 71.11C back to F) -> 160 + expect(metrics?.sensors[0].warning).toBe(160); + }); }); describe('buildSummary', () => { diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index d41b04ef6f..7b6a044f9a 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -325,19 +325,28 @@ export class TemperatureService implements OnModuleInit { private getThresholdsForType(type: SensorType): { warning: number; critical: number } { const thresholds = this.configService.get('api.temperature.thresholds', {}); + const configUnitStr = + this.configService.get('api.temperature.default_unit') || 'celsius'; + const sourceUnit = + (TemperatureUnit as any)[configUnitStr.toUpperCase()] || TemperatureUnit.CELSIUS; + + const getVal = (val: number | undefined, defaultCelsius: number): number => { + if (val === undefined || val === null) return defaultCelsius; + return this.convertValue(val, sourceUnit, TemperatureUnit.CELSIUS); + }; switch (type) { case SensorType.CPU_PACKAGE: case SensorType.CPU_CORE: return { - warning: thresholds.cpu_warning ?? 70, - critical: thresholds.cpu_critical ?? 85, + warning: getVal(thresholds.cpu_warning, 70), + critical: getVal(thresholds.cpu_critical, 85), }; case SensorType.DISK: case SensorType.NVME: return { - warning: thresholds.disk_warning ?? 50, - critical: thresholds.disk_critical ?? 60, + warning: getVal(thresholds.disk_warning, 50), + critical: getVal(thresholds.disk_critical, 60), }; default: return { warning: 80, critical: 90 }; From 9e2abc1df575d575802af68aef0826ae94dc9bfa Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 1 Feb 2026 09:15:09 +0000 Subject: [PATCH 61/86] update docs --- api/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/README.md b/api/README.md index 1198ce0752..7f8a2f9890 100644 --- a/api/README.md +++ b/api/README.md @@ -84,8 +84,8 @@ For detailed information about specific features: - [Feature Flags](docs/developer/feature-flags.md) - Conditionally enabling functionality - [Repository Organization](docs/developer/repo-organization.md) - Codebase structure - [Development Workflows](docs/developer/workflows.md) - Development processes +- [Temperature Monitoring](docs/developer/temperature.md) - Configuration and API details for temperature sensors ## License Copyright Lime Technology Inc. All rights reserved. -- [Temperature Monitoring](docs/developer/temperature.md) - Configuration and API details for temperature sensors From 5856142961c5e310bfca21514a82911b2de6ac32 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 1 Feb 2026 09:20:06 +0000 Subject: [PATCH 62/86] revert the default api sandbox --- api/dev/configs/api.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index 452680cdd4..5b8eaf9997 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -1,7 +1,7 @@ { "version": "4.28.2", "extraOrigins": [], - "sandbox": false, + "sandbox": true, "ssoSubIds": [], "plugins": [ "unraid-api-plugin-connect" From f735e9b01160528982ddcc6b9bcbfc0ff149886d Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Sun, 1 Feb 2026 09:21:07 +0000 Subject: [PATCH 63/86] revert this change --- plugin/builder/build-txz.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/plugin/builder/build-txz.ts b/plugin/builder/build-txz.ts index f9490bc3d0..ce1bafaa7f 100644 --- a/plugin/builder/build-txz.ts +++ b/plugin/builder/build-txz.ts @@ -176,10 +176,6 @@ const validateSourceDir = async (validatedEnv: TxzEnv) => { const buildTxz = async (validatedEnv: TxzEnv) => { await validateSourceDir(validatedEnv); - - // Call during TXZ build process - - const sourceDir = join(startingDir, "source"); // Use version from validated environment const version = validatedEnv.apiVersion; From bc6c1c5a1c1fe6d5c442c09db4d156c290a08696 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 2 Feb 2026 01:02:01 +0000 Subject: [PATCH 64/86] add lm_sensor timeout --- .../sensors/lm_sensors.service.spec.ts | 18 +++++++++++++++--- .../temperature/sensors/lm_sensors.service.ts | 18 ++++++++---------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.spec.ts index 18587603dd..61131f43a1 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.spec.ts @@ -38,7 +38,11 @@ describe('LmSensorsService', () => { const available = await service.isAvailable(); expect(available).toBe(true); - expect(execa).toHaveBeenCalledWith('sensors', ['--version']); + expect(execa).toHaveBeenCalledWith( + 'sensors', + ['--version'], + expect.objectContaining({ timeout: 3000 }) + ); }); it('should return false when sensors command not found', async () => { @@ -59,7 +63,11 @@ describe('LmSensorsService', () => { await service.read(); // Verify called with defaults - expect(execa).toHaveBeenCalledWith('sensors', ['-j']); + expect(execa).toHaveBeenCalledWith( + 'sensors', + ['-j'], + expect.objectContaining({ timeout: 3000 }) + ); }); it('should add -c flag when config path is present', async () => { @@ -70,7 +78,11 @@ describe('LmSensorsService', () => { await service.read(); // Verify called with extra args - expect(execa).toHaveBeenCalledWith('sensors', ['-j', '-c', '/etc/my-sensors.conf']); + expect(execa).toHaveBeenCalledWith( + 'sensors', + ['-j', '-c', '/etc/my-sensors.conf'], + expect.objectContaining({ timeout: 3000 }) + ); }); it('should parse sensors JSON output correctly', async () => { diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.ts index 8650a60534..6e6173a626 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.ts @@ -16,12 +16,13 @@ import { export class LmSensorsService implements TemperatureSensorProvider { readonly id = 'lm-sensors'; private readonly logger = new Logger(LmSensorsService.name); + private readonly timeoutMs = 3000; constructor(private readonly configService: ConfigService) {} async isAvailable(): Promise { try { - await execa('sensors', ['--version']); + await execa('sensors', ['--version'], { timeout: this.timeoutMs }); return true; } catch { return false; @@ -29,28 +30,25 @@ export class LmSensorsService implements TemperatureSensorProvider { } async read(): Promise { - // Read the config path from your new configuration structure const configPath = this.configService.get( 'api.temperature.sensors.lm_sensors.config_path' ); - // Build arguments: add '-c path' if configPath exists const args = ['-j']; if (configPath) { args.push('-c', configPath); } - const { stdout } = await execa('sensors', args); - const data = JSON.parse(stdout); + const { stdout } = await execa('sensors', args, { timeout: this.timeoutMs }); + const data = JSON.parse(stdout) as Record>; const sensors: RawTemperatureSensor[] = []; - for (const [chipName, chip] of Object.entries(data)) { - for (const [label, values] of Object.entries(chip)) { - if (label === 'Adapter') continue; - if (typeof values !== 'object') continue; + for (const [chipName, chip] of Object.entries(data)) { + for (const [label, values] of Object.entries(chip)) { + if (label === 'Adapter' || typeof values !== 'object' || values === null) continue; - for (const [key, value] of Object.entries(values)) { + for (const [key, value] of Object.entries(values as Record)) { if (!key.endsWith('_input') || typeof value !== 'number') continue; const name = `${chipName} ${label}`; From 60776ac98b1816c8ce7e4c4e513cb6241ce9e2d1 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 2 Feb 2026 01:05:56 +0000 Subject: [PATCH 65/86] make threshold configuration more robust --- .../temperature/temperature.service.ts | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index 7b6a044f9a..966a2eb0a7 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -130,13 +130,14 @@ export class TemperatureService implements OnModuleInit { this.configService.get('api.temperature.default_unit') || 'celsius'; const targetUnit = (TemperatureUnit as any)[configUnit.toUpperCase()] || TemperatureUnit.CELSIUS; + const thresholdConfig = this.configService.get('api.temperature.thresholds', {}); const sensors: TemperatureSensor[] = allRawSensors.map((r) => { const rawCurrent: TemperatureReading = { value: r.value, unit: r.unit, timestamp: new Date(), - status: this.computeStatus(r.value, r.unit, r.type), + status: this.computeStatus(r.value, r.unit, r.type, thresholdConfig, targetUnit), }; // Record in history (ALWAYS RAW) @@ -157,7 +158,7 @@ export class TemperatureService implements OnModuleInit { const minConverted = this.convertReading(min, targetUnit); const maxConverted = this.convertReading(max, targetUnit); - const rawThresholds = this.getThresholdsForType(r.type); + const rawThresholds = this.getThresholdsForType(r.type, thresholdConfig, targetUnit); const warning = this.convertValue( rawThresholds.warning, TemperatureUnit.CELSIUS, @@ -202,6 +203,7 @@ export class TemperatureService implements OnModuleInit { const configUnit = this.configService.get('api.temperature.default_unit') || 'celsius'; const targetUnit = (TemperatureUnit as any)[configUnit.toUpperCase()] || TemperatureUnit.CELSIUS; + const thresholdConfig = this.configService.get('api.temperature.thresholds', {}); const sensors = allSensorIds .map((sensorId): TemperatureSensor | null => { @@ -220,7 +222,11 @@ export class TemperatureService implements OnModuleInit { const minConverted = this.convertReading(min, targetUnit); const maxConverted = this.convertReading(max, targetUnit); - const rawThresholds = this.getThresholdsForType(metadata.type); + const rawThresholds = this.getThresholdsForType( + metadata.type, + thresholdConfig, + targetUnit + ); const warning = this.convertValue( rawThresholds.warning, TemperatureUnit.CELSIUS, @@ -313,23 +319,27 @@ export class TemperatureService implements OnModuleInit { } // Make status computation type-aware for future per-type thresholds - private computeStatus(value: number, unit: TemperatureUnit, type: SensorType): TemperatureStatus { + private computeStatus( + value: number, + unit: TemperatureUnit, + type: SensorType, + thresholdConfig: any, + sourceUnit: TemperatureUnit + ): TemperatureStatus { // We always compute status using Celsius thresholds const celsiusValue = this.convertValue(value, unit, TemperatureUnit.CELSIUS); - const thresholds = this.getThresholdsForType(type); + const thresholds = this.getThresholdsForType(type, thresholdConfig, sourceUnit); if (celsiusValue >= thresholds.critical) return TemperatureStatus.CRITICAL; if (celsiusValue >= thresholds.warning) return TemperatureStatus.WARNING; return TemperatureStatus.NORMAL; } - private getThresholdsForType(type: SensorType): { warning: number; critical: number } { - const thresholds = this.configService.get('api.temperature.thresholds', {}); - const configUnitStr = - this.configService.get('api.temperature.default_unit') || 'celsius'; - const sourceUnit = - (TemperatureUnit as any)[configUnitStr.toUpperCase()] || TemperatureUnit.CELSIUS; - + private getThresholdsForType( + type: SensorType, + thresholds: any, + sourceUnit: TemperatureUnit + ): { warning: number; critical: number } { const getVal = (val: number | undefined, defaultCelsius: number): number => { if (val === undefined || val === null) return defaultCelsius; return this.convertValue(val, sourceUnit, TemperatureUnit.CELSIUS); @@ -349,7 +359,10 @@ export class TemperatureService implements OnModuleInit { critical: getVal(thresholds.disk_critical, 60), }; default: - return { warning: 80, critical: 90 }; + return { + warning: getVal(thresholds.warning, 80), + critical: getVal(thresholds.critical, 90), + }; } } From 57529ea7c50d9ca445170c6db50076bc74c53685 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 2 Feb 2026 01:30:02 +0000 Subject: [PATCH 66/86] do some slightly safer handling of Record objects --- .../temperature/sensors/lm_sensors.service.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.ts index 6e6173a626..9a56225c03 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.ts @@ -40,15 +40,19 @@ export class LmSensorsService implements TemperatureSensorProvider { } const { stdout } = await execa('sensors', args, { timeout: this.timeoutMs }); - const data = JSON.parse(stdout) as Record>; + const data: unknown = JSON.parse(stdout); + + if (!this.isRecord(data)) return []; const sensors: RawTemperatureSensor[] = []; for (const [chipName, chip] of Object.entries(data)) { + if (!this.isRecord(chip)) continue; + for (const [label, values] of Object.entries(chip)) { - if (label === 'Adapter' || typeof values !== 'object' || values === null) continue; + if (label === 'Adapter' || !this.isRecord(values)) continue; - for (const [key, value] of Object.entries(values as Record)) { + for (const [key, value] of Object.entries(values)) { if (!key.endsWith('_input') || typeof value !== 'number') continue; const name = `${chipName} ${label}`; @@ -67,6 +71,10 @@ export class LmSensorsService implements TemperatureSensorProvider { return sensors; } + private isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; + } + private inferType(name: string): SensorType { const n = name.toLowerCase(); if (n.includes('package')) return SensorType.CPU_PACKAGE; From 1b747930e45737c1388ad9fad741fcb9c314b15c Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 2 Feb 2026 01:40:34 +0000 Subject: [PATCH 67/86] filter out NaN, Inf, and -Inf. Add tests for it. --- .../temperature/temperature.service.spec.ts | 28 +++++++++++++++++++ .../temperature/temperature.service.ts | 15 +++++++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts index b5384ae464..9531215a0d 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts @@ -485,7 +485,35 @@ describe('TemperatureService', () => { // Document expected behavior - should either filter out or handle gracefully // Current implementation would include it; you may want to filter + expect(metrics).toBeNull(); + }); + + it('should handle mix of valid and NaN temperature values', async () => { + await service.onModuleInit(); + + vi.mocked(lmSensors.read).mockResolvedValue([ + { + id: 'valid-sensor', + name: 'Good Sensor', + type: SensorType.CPU_CORE, + value: 45, + unit: TemperatureUnit.CELSIUS, + }, + { + id: 'nan-sensor', + name: 'Bad Sensor', + type: SensorType.CUSTOM, + value: NaN, + unit: TemperatureUnit.CELSIUS, + }, + ]); + + const metrics = await service.getMetrics(); + + expect(metrics).toBeDefined(); expect(metrics?.sensors).toHaveLength(1); + expect(metrics?.sensors[0].id).toBe('valid-sensor'); + expect(metrics?.summary.average).toBe(45); }); it('should handle all providers failing', async () => { diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index 966a2eb0a7..fb05291e01 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -121,8 +121,15 @@ export class TemperatureService implements OnModuleInit { } } - if (allRawSensors.length === 0) { - this.logger.debug('No temperature sensors detected'); + // Filter out NaN or infinite values + const validSensors = allRawSensors.filter((s) => Number.isFinite(s.value)); + + if (validSensors.length === 0) { + if (allRawSensors.length > 0) { + this.logger.warn('All temperature sensors returned non-finite values'); + } else { + this.logger.debug('No temperature sensors detected'); + } return null; } @@ -132,7 +139,7 @@ export class TemperatureService implements OnModuleInit { (TemperatureUnit as any)[configUnit.toUpperCase()] || TemperatureUnit.CELSIUS; const thresholdConfig = this.configService.get('api.temperature.thresholds', {}); - const sensors: TemperatureSensor[] = allRawSensors.map((r) => { + const sensors: TemperatureSensor[] = validSensors.map((r) => { const rawCurrent: TemperatureReading = { value: r.value, unit: r.unit, @@ -212,7 +219,7 @@ export class TemperatureService implements OnModuleInit { const rawCurrent = rawHistory[rawHistory.length - 1]; const metadata = this.history.getMetadata(sensorId)!; - if (!rawCurrent) return null; + if (!rawCurrent || !Number.isFinite(rawCurrent.value)) return null; // Convert for output const current = this.convertReading(rawCurrent, targetUnit) as TemperatureReading; From 3a65f63b07f519584ab31c7e0286d95bd4aed59d Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 2 Feb 2026 01:47:34 +0000 Subject: [PATCH 68/86] remove unused variable --- .../unraid-api/graph/resolvers/metrics/metrics.resolver.spec.ts | 1 - 1 file changed, 1 deletion(-) 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 3eff9c820e..16f383ecdd 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 @@ -16,7 +16,6 @@ describe('MetricsResolver', () => { let resolver: MetricsResolver; let cpuService: CpuService; let memoryService: MemoryService; - let temperatureService: TemperatureService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ From 00820c09fca2c8f48aa0e1192c0c5897e5a943ab Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 2 Feb 2026 01:49:11 +0000 Subject: [PATCH 69/86] remove unused variable --- .../resolvers/metrics/temperature/temperature.service.spec.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts index 9531215a0d..a83ffae24d 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts @@ -369,11 +369,7 @@ describe('TemperatureService', () => { () => new Promise((resolve) => setTimeout(() => resolve([]), 1000)) ); - // If you have timeout logic, test it here - // Otherwise, this documents expected behavior - const startTime = Date.now(); const metrics = await service.getMetrics(); - const elapsed = Date.now() - startTime; // Should either timeout or complete - document expected behavior expect(metrics).toBeDefined(); From cb6a1a90f022724ddbbc1ae8b11a74533f7086ad Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 2 Feb 2026 06:30:59 +0000 Subject: [PATCH 70/86] documentation update --- api/docs/developer/temperature.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/docs/developer/temperature.md b/api/docs/developer/temperature.md index f9e5608496..a375fa392f 100644 --- a/api/docs/developer/temperature.md +++ b/api/docs/developer/temperature.md @@ -13,9 +13,10 @@ Nominally the `api.json` file is found at | Key | Type | Default | Description | | :--- | :--- | :--- | :--- | | `enabled` | `boolean` | `true` | Globally enable or disable temperature monitoring. | -| `default_unit` | `string` | `"celsius"` | The unit to return values in. Options: `"celsius"`, `"fahrenheit"`. | +| `default_unit` | `string` | `"celsius"` | The unit to return values in. Options: `"celsius"`, `"fahrenheit"`, `"kelvin"`, `"rankine"`. | | `polling_interval` | `number` | `5000` | Polling interval in milliseconds for the subscription. | -| `history_size` | `number` | `100` | (Internal) Number of historical data points to keep in memory per sensor. | +| `history.max_readings` | `number` | `1000` | (Internal) Number of historical data points to keep in memory per sensor. | +| `history.retention_ms` | `number` | `86400000` | (Internal) Retention period for historical data in milliseconds. | ### `api.temperature.sensors` Object From 41b64323c4281bee77812b687a1a965f69978935 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 2 Feb 2026 06:43:30 +0000 Subject: [PATCH 71/86] Add checks for 0 deg C and update unit tests to reflect that. Also add some comments for luddites like myself --- .../resolvers/disks/disks.service.spec.ts | 49 +++++++++++++++++++ .../graph/resolvers/disks/disks.service.ts | 23 ++++++--- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts b/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts index e86c91491e..52e4b5eac3 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts @@ -632,5 +632,54 @@ describe('DisksService', () => { const temperature = await service.getTemperature('/dev/sda'); expect(temperature).toBeNull(); }); + + it('should return 0 when temperature is 0 degrees', async () => { + mockExeca.mockResolvedValue({ + stdout: JSON.stringify({ + temperature: { current: 0 }, + }), + stderr: '', + exitCode: 0, + failed: false, + command: '', + cwd: '', + isCanceled: false, + }); + + const temperature = await service.getTemperature('/dev/sda'); + expect(temperature).toBe(0); + }); + + it('should return null when temperature is null or undefined', async () => { + mockExeca.mockResolvedValue({ + stdout: JSON.stringify({ + temperature: { current: null }, + }), + stderr: '', + exitCode: 0, + failed: false, + command: '', + cwd: '', + isCanceled: false, + }); + + const temperature = await service.getTemperature('/dev/sda'); + expect(temperature).toBeNull(); + + mockExeca.mockResolvedValue({ + stdout: JSON.stringify({ + temperature: {}, + }), + stderr: '', + exitCode: 0, + failed: false, + command: '', + cwd: '', + isCanceled: false, + }); + + const temperature2 = await service.getTemperature('/dev/sda'); + expect(temperature2).toBeNull(); + }); }); }); diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.service.ts b/api/src/unraid-api/graph/resolvers/disks/disks.service.ts index b1c352220e..acdf2a4520 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.service.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.service.ts @@ -14,26 +14,37 @@ import { } from '@app/unraid-api/graph/resolvers/disks/disks.model.js'; import { batchProcess } from '@app/utils.js'; +interface SmartAttribute { + id: number; + raw: { + value: number; + }; +} + @Injectable() export class DisksService { constructor(private readonly configService: ConfigService) {} public async getTemperature(device: string): Promise { try { - // Use -j for JSON output const { stdout } = await execa('smartctl', ['-n', 'standby', '-A', '-j', device]); const data = JSON.parse(stdout); - // 1. Try standard temperature object - if (data.temperature?.current) { + if (data.temperature?.current !== undefined && data.temperature?.current !== null) { return data.temperature.current; } - // 2. Try Attribute 194 or 190 if (data.ata_smart_attributes?.table) { const tempAttr = data.ata_smart_attributes.table.find( - (a: any) => a.id === 194 || a.id === 190 + // Attribute 194: This is the standard SMART attribute ID + // for "Temperature_Celsius" on most hard drives and SSDs + // + // Attribute 190: This is an alternative temperature + // attribute ID used by some drive manufacturers (often + // called "Airflow_Temperature_Celsius" or just another + // temperature reading) + (a: SmartAttribute) => a.id === 194 || a.id === 190 ); - if (tempAttr?.raw?.value) { + if (tempAttr?.raw?.value !== undefined && tempAttr?.raw?.value !== null) { return tempAttr.raw.value; } } From 341754aa894c48d809f87341a4fe2baf31286831 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 2 Feb 2026 06:51:08 +0000 Subject: [PATCH 72/86] do not use any --- .../resolvers/metrics/metrics.resolver.integration.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f57bc8b23f..44a60ecfa5 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 @@ -39,7 +39,7 @@ describe('MetricsResolver Integration Tests', () => { { provide: ConfigService, useValue: { - get: vi.fn((key: string, defaultValue?: any) => defaultValue), + get: vi.fn((key: string, defaultValue?: unknown) => defaultValue), }, }, ], From 4c3de3d594b48324e422638328bad8fb720263ba Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 2 Feb 2026 06:59:25 +0000 Subject: [PATCH 73/86] do not use any in lm_sensors.service.spec.ts --- .../sensors/lm_sensors.service.spec.ts | 54 ++++++++++++------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.spec.ts index 61131f43a1..ba7d843a01 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.spec.ts @@ -23,9 +23,10 @@ describe('LmSensorsService', () => { let configService: ConfigService; beforeEach(() => { + // @ts-expect-error -- mocking partial ConfigService configService = { get: vi.fn(), - } as any; + }; service = new LmSensorsService(configService); vi.clearAllMocks(); @@ -33,7 +34,8 @@ describe('LmSensorsService', () => { describe('isAvailable', () => { it('should return true when sensors command exists', async () => { - vi.mocked(execa).mockResolvedValue({ stdout: 'sensors version 3.6.0' } as any); + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: 'sensors version 3.6.0' }); const available = await service.isAvailable(); @@ -58,7 +60,8 @@ describe('LmSensorsService', () => { it('should use default arguments when no config path is set', async () => { // Mock config returning undefined vi.mocked(configService.get).mockReturnValue(undefined); - vi.mocked(execa).mockResolvedValue({ stdout: '{}' } as any); + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: '{}' }); await service.read(); @@ -73,7 +76,8 @@ describe('LmSensorsService', () => { it('should add -c flag when config path is present', async () => { // Mock config returning a path vi.mocked(configService.get).mockReturnValue('/etc/my-sensors.conf'); - vi.mocked(execa).mockResolvedValue({ stdout: '{}' } as any); + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: '{}' }); await service.read(); @@ -101,7 +105,8 @@ describe('LmSensorsService', () => { }, }; - vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); const sensors = await service.read(); @@ -127,7 +132,8 @@ describe('LmSensorsService', () => { }, }; - vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); const sensors = await service.read(); @@ -142,7 +148,8 @@ describe('LmSensorsService', () => { }, }; - vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); const sensors = await service.read(); @@ -162,7 +169,8 @@ describe('LmSensorsService', () => { }, }; - vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); const sensors = await service.read(); @@ -171,13 +179,15 @@ describe('LmSensorsService', () => { }); it('should handle malformed JSON', async () => { - vi.mocked(execa).mockResolvedValue({ stdout: 'not valid json' } as any); + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: 'not valid json' }); await expect(service.read()).rejects.toThrow(); }); it('should handle empty output', async () => { - vi.mocked(execa).mockResolvedValue({ stdout: '{}' } as any); + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: '{}' }); const sensors = await service.read(); @@ -194,7 +204,8 @@ describe('LmSensorsService', () => { }, }; - vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); const sensors = await service.read(); @@ -215,7 +226,8 @@ describe('LmSensorsService', () => { }, }; - vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); const sensors = await service.read(); @@ -233,7 +245,8 @@ describe('LmSensorsService', () => { }, }; - vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); const sensors = await service.read(); @@ -248,7 +261,8 @@ describe('LmSensorsService', () => { }, }; - vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); const sensors = await service.read(); @@ -263,7 +277,8 @@ describe('LmSensorsService', () => { }, }; - vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); const sensors = await service.read(); @@ -278,7 +293,8 @@ describe('LmSensorsService', () => { }, }; - vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); const sensors = await service.read(); @@ -293,7 +309,8 @@ describe('LmSensorsService', () => { }, }; - vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); const sensors = await service.read(); @@ -308,7 +325,8 @@ describe('LmSensorsService', () => { }, }; - vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) } as any); + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); const sensors = await service.read(); From 7cae9631164e382e3acbc4da86a1e14210ba14b2 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 2 Feb 2026 07:20:13 +0000 Subject: [PATCH 74/86] I let AI try to correct other useages of any, but need to test it first --- .../resolvers/disks/disks.service.spec.ts | 12 ++++---- .../metrics.resolver.integration.spec.ts | 3 +- .../temperature-history.service.spec.ts | 12 ++++---- .../temperature/temperature.service.ts | 30 +++++++++++++++---- 4 files changed, 39 insertions(+), 18 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts b/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts index 52e4b5eac3..1df25bdd8d 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts @@ -32,10 +32,10 @@ vi.mock('@app/utils.js', () => ({ })); // Remove explicit type assertions for mocks -const mockExeca = execa as any; // Using 'any' for simplicity with complex mock setups -const mockBlockDevices = blockDevices as any; -const mockDiskLayout = diskLayout as any; -const mockBatchProcess = batchProcess as any; +const mockExeca = execa as unknown as vi.MockedFunction; +const mockBlockDevices = blockDevices as unknown as vi.MockedFunction; +const mockDiskLayout = diskLayout as unknown as vi.MockedFunction; +const mockBatchProcess = batchProcess as unknown as vi.MockedFunction; describe('DisksService', () => { let service: DisksService; @@ -303,7 +303,7 @@ describe('DisksService', () => { // Create mock ConfigService const mockConfigService = { - get: vi.fn().mockImplementation((key: string, defaultValue?: any) => { + get: vi.fn().mockImplementation((key: string, defaultValue?: unknown) => { if (key === 'store.emhttp.disks') { return mockArrayDisks; } @@ -383,7 +383,7 @@ describe('DisksService', () => { }); it('should handle empty state gracefully', async () => { - vi.mocked(configService.get).mockImplementation((key: string, defaultValue?: any) => { + vi.mocked(configService.get).mockImplementation((key: string, defaultValue?: unknown) => { if (key === 'store.emhttp.disks') { return []; } 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 44a60ecfa5..96b98ae492 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 @@ -8,6 +8,7 @@ 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 { MemoryMetrics } from '@app/unraid-api/graph/resolvers/info/memory/memory.model.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 { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; @@ -151,7 +152,7 @@ describe('MetricsResolver Integration Tests', () => { swapUsed: 0, swapFree: 0, percentSwapTotal: 0, - } as any; + } as MemoryMetrics; }); // Trigger polling by simulating subscription diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-history.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-history.service.spec.ts index b8ce5422c5..ec21622dec 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-history.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-history.service.spec.ts @@ -14,9 +14,10 @@ describe('TemperatureHistoryService', () => { let configService: ConfigService; beforeEach(() => { + // @ts-expect-error -- mocking partial ConfigService configService = { - get: (key: string, defaultValue?: any) => defaultValue, - } as any; + get: (key: string, defaultValue?: unknown) => defaultValue, + }; service = new TemperatureHistoryService(configService); }); @@ -96,12 +97,13 @@ describe('TemperatureHistoryService', () => { describe('retention and trimming', () => { it('should keep only max readings per sensor', () => { - const configServiceWithLimit = { - get: (key: string, defaultValue?: any) => { + // @ts-expect-error -- mocking partial ConfigService + const configServiceWithLimit: ConfigService = { + get: (key: string, defaultValue?: unknown) => { if (key === 'api.temperature.history.max_readings') return 3; return defaultValue; }, - } as any; + }; const limitedService = new TemperatureHistoryService(configServiceWithLimit); const metadata = { name: 'CPU', type: SensorType.CPU_CORE }; diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index fb05291e01..cdec5676da 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -18,6 +18,15 @@ import { TemperatureUnit, } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; +interface TemperatureThresholds { + cpu_warning?: number; + cpu_critical?: number; + disk_warning?: number; + disk_critical?: number; + warning?: number; + critical?: number; +} + // temperature.service.ts @Injectable() export class TemperatureService implements OnModuleInit { @@ -136,8 +145,12 @@ export class TemperatureService implements OnModuleInit { const configUnit = this.configService.get('api.temperature.default_unit') || 'celsius'; const targetUnit = - (TemperatureUnit as any)[configUnit.toUpperCase()] || TemperatureUnit.CELSIUS; - const thresholdConfig = this.configService.get('api.temperature.thresholds', {}); + TemperatureUnit[configUnit.toUpperCase() as keyof typeof TemperatureUnit] || + TemperatureUnit.CELSIUS; + const thresholdConfig = this.configService.get( + 'api.temperature.thresholds', + {} + ); const sensors: TemperatureSensor[] = validSensors.map((r) => { const rawCurrent: TemperatureReading = { @@ -209,8 +222,13 @@ export class TemperatureService implements OnModuleInit { } const configUnit = this.configService.get('api.temperature.default_unit') || 'celsius'; - const targetUnit = (TemperatureUnit as any)[configUnit.toUpperCase()] || TemperatureUnit.CELSIUS; - const thresholdConfig = this.configService.get('api.temperature.thresholds', {}); + const targetUnit = + TemperatureUnit[configUnit.toUpperCase() as keyof typeof TemperatureUnit] || + TemperatureUnit.CELSIUS; + const thresholdConfig = this.configService.get( + 'api.temperature.thresholds', + {} + ); const sensors = allSensorIds .map((sensorId): TemperatureSensor | null => { @@ -330,7 +348,7 @@ export class TemperatureService implements OnModuleInit { value: number, unit: TemperatureUnit, type: SensorType, - thresholdConfig: any, + thresholdConfig: TemperatureThresholds, sourceUnit: TemperatureUnit ): TemperatureStatus { // We always compute status using Celsius thresholds @@ -344,7 +362,7 @@ export class TemperatureService implements OnModuleInit { private getThresholdsForType( type: SensorType, - thresholds: any, + thresholds: TemperatureThresholds, sourceUnit: TemperatureUnit ): { warning: number; critical: number } { const getVal = (val: number | undefined, defaultCelsius: number): number => { From 7ac964190995db4c289a6d35f27468c0ac9f237b Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 2 Feb 2026 07:31:55 +0000 Subject: [PATCH 75/86] The great any purge from tests --- .../metrics/metrics.resolver.spec.ts | 8 +++--- .../sensors/disk_sensors.service.spec.ts | 27 ++++++++++++------- .../temperature.resolver.integration.spec.ts | 3 ++- 3 files changed, 24 insertions(+), 14 deletions(-) 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 16f383ecdd..618a78d1e2 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 @@ -187,7 +187,7 @@ describe('MetricsResolver', () => { } satisfies Pick; const configServiceMock = { - get: vi.fn((key: string, defaultValue?: any) => defaultValue), + get: vi.fn((key: string, defaultValue?: unknown) => defaultValue), }; const testModule = new MetricsResolver( @@ -195,9 +195,9 @@ describe('MetricsResolver', () => { cpuTopologyServiceMock as unknown as CpuTopologyService, memoryService, temperatureServiceMock as unknown as TemperatureService, - subscriptionTracker as any, - {} as any, - configServiceMock as any + subscriptionTracker as unknown as SubscriptionTrackerService, + {} as unknown as SubscriptionHelperService, + configServiceMock as unknown as ConfigService ); testModule.onModuleInit(); diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts index e654c01fee..26fd0abbf3 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts @@ -33,9 +33,10 @@ describe('DiskSensorsService', () => { describe('isAvailable', () => { it('should return true when disks exist', async () => { + // @ts-expect-error -- mocking partial Disk vi.mocked(disksService.getDisks).mockResolvedValue([ { id: 'disk1', device: '/dev/sda', name: 'Test Disk' }, - ] as any); + ]); const available = await service.isAvailable(); expect(available).toBe(true); @@ -58,10 +59,11 @@ describe('DiskSensorsService', () => { describe('read', () => { it('should return disk temperatures', async () => { + // @ts-expect-error -- mocking partial Disk vi.mocked(disksService.getDisks).mockResolvedValue([ { id: 'disk1', device: '/dev/sda', name: 'Seagate HDD', interfaceType: 'sata' }, { id: 'disk2', device: '/dev/nvme0n1', name: 'Samsung NVMe', interfaceType: 'nvme' }, - ] as any); + ]); vi.mocked(disksService.getTemperature).mockResolvedValueOnce(35).mockResolvedValueOnce(45); @@ -85,10 +87,11 @@ describe('DiskSensorsService', () => { }); it('should skip disks without temperature data', async () => { + // @ts-expect-error -- mocking partial Disk vi.mocked(disksService.getDisks).mockResolvedValue([ { id: 'disk1', device: '/dev/sda', name: 'Disk 1' }, { id: 'disk2', device: '/dev/sdb', name: 'Disk 2' }, - ] as any); + ]); vi.mocked(disksService.getTemperature).mockResolvedValueOnce(35).mockResolvedValueOnce(null); // No temp for disk2 @@ -99,10 +102,11 @@ describe('DiskSensorsService', () => { }); it('should handle getTemperature errors gracefully', async () => { + // @ts-expect-error -- mocking partial Disk vi.mocked(disksService.getDisks).mockResolvedValue([ { id: 'disk1', device: '/dev/sda', name: 'Disk 1' }, { id: 'disk2', device: '/dev/sdb', name: 'Disk 2' }, - ] as any); + ]); vi.mocked(disksService.getTemperature) .mockResolvedValueOnce(35) @@ -115,9 +119,10 @@ describe('DiskSensorsService', () => { }); it('should use device name as fallback when name is empty', async () => { + // @ts-expect-error -- mocking partial Disk vi.mocked(disksService.getDisks).mockResolvedValue([ { id: 'disk1', device: '/dev/sda', name: '' }, - ] as any); + ]); vi.mocked(disksService.getTemperature).mockResolvedValue(35); @@ -129,9 +134,10 @@ describe('DiskSensorsService', () => { describe('inferDiskType', () => { it('should return NVME for nvme interface', async () => { + // @ts-expect-error -- mocking partial Disk vi.mocked(disksService.getDisks).mockResolvedValue([ { id: 'disk1', device: '/dev/nvme0n1', name: 'NVMe', interfaceType: 'nvme' }, - ] as any); + ]); vi.mocked(disksService.getTemperature).mockResolvedValue(40); const sensors = await service.read(); @@ -139,9 +145,10 @@ describe('DiskSensorsService', () => { }); it('should return NVME for pcie interface', async () => { + // @ts-expect-error -- mocking partial Disk vi.mocked(disksService.getDisks).mockResolvedValue([ { id: 'disk1', device: '/dev/nvme0n1', name: 'NVMe', interfaceType: 'pcie' }, - ] as any); + ]); vi.mocked(disksService.getTemperature).mockResolvedValue(40); const sensors = await service.read(); @@ -149,9 +156,10 @@ describe('DiskSensorsService', () => { }); it('should return DISK for sata interface', async () => { + // @ts-expect-error -- mocking partial Disk vi.mocked(disksService.getDisks).mockResolvedValue([ { id: 'disk1', device: '/dev/sda', name: 'HDD', interfaceType: 'sata' }, - ] as any); + ]); vi.mocked(disksService.getTemperature).mockResolvedValue(35); const sensors = await service.read(); @@ -159,9 +167,10 @@ describe('DiskSensorsService', () => { }); it('should return DISK for undefined interface', async () => { + // @ts-expect-error -- mocking partial Disk vi.mocked(disksService.getDisks).mockResolvedValue([ { id: 'disk1', device: '/dev/sda', name: 'HDD' }, - ] as any); + ]); vi.mocked(disksService.getTemperature).mockResolvedValue(35); const sensors = await service.read(); diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts index 41b0704cd9..1dcaa9d3c7 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts @@ -203,7 +203,8 @@ describe('Temperature GraphQL Integration', () => { summary: mockTemperatureMetrics.summary, }; - vi.mocked(temperatureService.getMetrics).mockResolvedValue(multiSensorMetrics as any); + // @ts-expect-error -- mocking partial TemperatureMetrics + vi.mocked(temperatureService.getMetrics).mockResolvedValue(multiSensorMetrics); const result = await resolver.temperature(); From 1ad71c7dd780334b52702abc51b949e26e1d0dea Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 2 Feb 2026 07:43:30 +0000 Subject: [PATCH 76/86] Remove usage of any in temperature.service.spec.ts --- .../temperature/temperature.service.spec.ts | 53 +++++++++---------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts index a83ffae24d..c43767ee9b 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { DiskSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.js'; import { IpmiSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/ipmi_sensors.service.js'; import { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.js'; +import { TemperatureSensorProvider } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/sensor.interface.js'; import { TemperatureHistoryService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.js'; import { SensorType, @@ -15,9 +16,9 @@ import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temp describe('TemperatureService', () => { let service: TemperatureService; - let lmSensors: LmSensorsService; - let diskSensors: DiskSensorsService; - let ipmiSensors: IpmiSensorsService; + let lmSensors: Partial; + let diskSensors: Partial; + let ipmiSensors: Partial; let history: TemperatureHistoryService; let configService: ConfigService; @@ -34,27 +35,34 @@ describe('TemperatureService', () => { unit: TemperatureUnit.CELSIUS, }, ]), - } as any; + }; diskSensors = { id: 'disk-sensors', isAvailable: vi.fn().mockResolvedValue(true), read: vi.fn().mockResolvedValue([]), - } as any; + }; ipmiSensors = { id: 'ipmi-sensors', isAvailable: vi.fn().mockResolvedValue(false), // Default to unavailable read: vi.fn().mockResolvedValue([]), - } as any; + }; - configService = { - get: vi.fn((key: string, defaultValue?: any) => defaultValue), - } as any; + configService = new ConfigService(); + vi.spyOn(configService, 'get').mockImplementation( + (key: string, defaultValue?: unknown) => defaultValue + ); history = new TemperatureHistoryService(configService); - service = new TemperatureService(lmSensors, diskSensors, ipmiSensors, history, configService); + service = new TemperatureService( + lmSensors as LmSensorsService, + diskSensors as DiskSensorsService, + ipmiSensors as IpmiSensorsService, + history, + configService + ); }); describe('initialization', () => { @@ -124,23 +132,14 @@ describe('TemperatureService', () => { }); it('should use config thresholds when provided', async () => { - const customConfigService = { - get: vi.fn((key: string, defaultValue?: any) => { - if (key === 'api.temperature.thresholds') { - return { cpu_warning: 60, cpu_critical: 80 }; - } - return defaultValue; - }), - } as any; + vi.spyOn(configService, 'get').mockImplementation((key: string, defaultValue?: unknown) => { + if (key === 'api.temperature.thresholds') { + return { cpu_warning: 60, cpu_critical: 80 }; + } + return defaultValue; + }); - const customService = new TemperatureService( - lmSensors, - diskSensors, - ipmiSensors, - history, - customConfigService - ); - await customService.onModuleInit(); + await service.onModuleInit(); vi.mocked(lmSensors.read).mockResolvedValue([ { @@ -152,7 +151,7 @@ describe('TemperatureService', () => { }, ]); - const metrics = await customService.getMetrics(); + const metrics = await service.getMetrics(); expect(metrics?.sensors[0].current.status).toBe(TemperatureStatus.WARNING); }); From af73fa5ca85edb16aab6958922dffee7fb142aa1 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 2 Feb 2026 07:55:16 +0000 Subject: [PATCH 77/86] rip out more usage of any --- .../temperature/temperature.service.spec.ts | 116 ++++++------------ 1 file changed, 40 insertions(+), 76 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts index c43767ee9b..4ce22ed970 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts @@ -156,23 +156,14 @@ describe('TemperatureService', () => { }); it('should return temperature metrics in Kelvin when configured', async () => { - const customConfigService = { - get: vi.fn((key: string, defaultValue?: any) => { - if (key === 'api.temperature.default_unit') { - return 'kelvin'; - } - return defaultValue; - }), - } as any; - - const customService = new TemperatureService( - lmSensors, - diskSensors, - ipmiSensors, - history, - customConfigService - ); - await customService.onModuleInit(); + vi.spyOn(configService, 'get').mockImplementation((key: string, defaultValue?: unknown) => { + if (key === 'api.temperature.default_unit') { + return 'kelvin'; + } + return defaultValue; + }); + + await service.onModuleInit(); vi.mocked(lmSensors.read).mockResolvedValue([ { @@ -184,29 +175,20 @@ describe('TemperatureService', () => { }, ]); - const metrics = await customService.getMetrics(); + const metrics = await service.getMetrics(); expect(metrics?.sensors[0].current.value).toBe(273.15); expect(metrics?.sensors[0].current.unit).toBe(TemperatureUnit.KELVIN); }); it('should return temperature metrics in Rankine when configured', async () => { - const customConfigService = { - get: vi.fn((key: string, defaultValue?: any) => { - if (key === 'api.temperature.default_unit') { - return 'rankine'; - } - return defaultValue; - }), - } as any; - - const customService = new TemperatureService( - lmSensors, - diskSensors, - ipmiSensors, - history, - customConfigService - ); - await customService.onModuleInit(); + vi.spyOn(configService, 'get').mockImplementation((key: string, defaultValue?: unknown) => { + if (key === 'api.temperature.default_unit') { + return 'rankine'; + } + return defaultValue; + }); + + await service.onModuleInit(); vi.mocked(lmSensors.read).mockResolvedValue([ { @@ -218,30 +200,21 @@ describe('TemperatureService', () => { }, ]); - const metrics = await customService.getMetrics(); + const metrics = await service.getMetrics(); // (25 + 273.15) * 9/5 = 536.67 expect(metrics?.sensors[0].current.value).toBe(536.67); expect(metrics?.sensors[0].current.unit).toBe(TemperatureUnit.RANKINE); }); it('should return thresholds in the target unit', async () => { - const customConfigService = { - get: vi.fn((key: string, defaultValue?: any) => { - if (key === 'api.temperature.default_unit') { - return 'fahrenheit'; - } - return defaultValue; - }), - } as any; - - const customService = new TemperatureService( - lmSensors, - diskSensors, - ipmiSensors, - history, - customConfigService - ); - await customService.onModuleInit(); + vi.spyOn(configService, 'get').mockImplementation((key: string, defaultValue?: unknown) => { + if (key === 'api.temperature.default_unit') { + return 'fahrenheit'; + } + return defaultValue; + }); + + await service.onModuleInit(); vi.mocked(lmSensors.read).mockResolvedValue([ { @@ -253,7 +226,7 @@ describe('TemperatureService', () => { }, ]); - const metrics = await customService.getMetrics(); + const metrics = await service.getMetrics(); // Default CPU warning is 70C -> 158F // Default CPU critical is 85C -> 185F expect(metrics?.sensors[0].warning).toBe(158); @@ -261,27 +234,18 @@ describe('TemperatureService', () => { }); it('should interpret user-defined thresholds in the default unit', async () => { - const customConfigService = { - get: vi.fn((key: string, defaultValue?: any) => { - if (key === 'api.temperature.default_unit') { - return 'fahrenheit'; - } - if (key === 'api.temperature.thresholds') { - // User sets warning to 160F (approx 71.1C) - return { cpu_warning: 160 }; - } - return defaultValue; - }), - } as any; - - const customService = new TemperatureService( - lmSensors, - diskSensors, - ipmiSensors, - history, - customConfigService - ); - await customService.onModuleInit(); + vi.spyOn(configService, 'get').mockImplementation((key: string, defaultValue?: unknown) => { + if (key === 'api.temperature.default_unit') { + return 'fahrenheit'; + } + if (key === 'api.temperature.thresholds') { + // User sets warning to 160F (approx 71.1C) + return { cpu_warning: 160 }; + } + return defaultValue; + }); + + await service.onModuleInit(); vi.mocked(lmSensors.read).mockResolvedValue([ { @@ -293,7 +257,7 @@ describe('TemperatureService', () => { }, ]); - const metrics = await customService.getMetrics(); + const metrics = await service.getMetrics(); // Check status: 72C (161.6F) > 160F Warning -> Should be WARNING expect(metrics?.sensors[0].current.status).toBe(TemperatureStatus.WARNING); From 9cfd14d720b4a485bbe1d3fb2696b9000f51d4e5 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 2 Feb 2026 08:01:45 +0000 Subject: [PATCH 78/86] do not use non-null operator in this way --- .../resolvers/metrics/temperature/temperature.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index cdec5676da..456a35eb70 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -235,9 +235,9 @@ export class TemperatureService implements OnModuleInit { const { min, max } = this.history.getMinMax(sensorId); const rawHistory = this.history.getHistory(sensorId); const rawCurrent = rawHistory[rawHistory.length - 1]; - const metadata = this.history.getMetadata(sensorId)!; + const metadata = this.history.getMetadata(sensorId); - if (!rawCurrent || !Number.isFinite(rawCurrent.value)) return null; + if (!rawCurrent || !Number.isFinite(rawCurrent.value) || !metadata) return null; // Convert for output const current = this.convertReading(rawCurrent, targetUnit) as TemperatureReading; From 805020d98a29c481edab49a6633d7d38607ddb36 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 2 Feb 2026 08:05:01 +0000 Subject: [PATCH 79/86] get rid of more usages of any --- .../unraid-api/graph/resolvers/metrics/metrics.resolver.spec.ts | 2 +- .../temperature/temperature.resolver.integration.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 618a78d1e2..f0ec533e28 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 @@ -93,7 +93,7 @@ describe('MetricsResolver', () => { { provide: ConfigService, useValue: { - get: vi.fn((key: string, defaultValue?: any) => defaultValue), + get: vi.fn((key: string, defaultValue?: unknown) => defaultValue), }, }, ], diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts index 1dcaa9d3c7..7b3f2abf2c 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts @@ -131,7 +131,7 @@ describe('Temperature GraphQL Integration', () => { { provide: ConfigService, useValue: { - get: vi.fn((key: string, defaultValue?: any) => defaultValue), + get: vi.fn((key: string, defaultValue?: unknown) => defaultValue), }, }, ], From 0fe051d2465fb2f36ffc5feaa1cede90430bb32e Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 2 Feb 2026 08:12:09 +0000 Subject: [PATCH 80/86] copy timeout pattern for ipmi (again, I have not been able to actually test this) --- .../metrics/temperature/sensors/ipmi_sensors.service.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/ipmi_sensors.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/ipmi_sensors.service.ts index 0560b50fd1..122ac897a5 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/ipmi_sensors.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/ipmi_sensors.service.ts @@ -16,12 +16,13 @@ import { export class IpmiSensorsService implements TemperatureSensorProvider { readonly id = 'ipmi-sensors'; private readonly logger = new Logger(IpmiSensorsService.name); + private readonly timeoutMs = 3000; constructor(private readonly configService: ConfigService) {} async isAvailable(): Promise { try { - await execa('ipmitool', ['-V']); + await execa('ipmitool', ['-V'], { timeout: this.timeoutMs }); return true; } catch { return false; @@ -34,7 +35,9 @@ export class IpmiSensorsService implements TemperatureSensorProvider { try { // 'sdr type temperature' returns sensors specifically for temperature - const { stdout } = await execa('ipmitool', ['sdr', 'type', 'temperature']); + const { stdout } = await execa('ipmitool', ['sdr', 'type', 'temperature'], { + timeout: this.timeoutMs, + }); return this.parseIpmiOutput(stdout); } catch (err) { From 27a04afdbceadf23f3431fd67b5c42aa4a873058 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 2 Feb 2026 08:31:43 +0000 Subject: [PATCH 81/86] I let gemini churn on the type errors in the unit tests and it did update all the typings consistently --- .../resolvers/disks/disks.service.spec.ts | 35 +++++------ .../metrics.resolver.integration.spec.ts | 4 +- .../sensors/disk_sensors.service.spec.ts | 59 +++++++++++------- .../temperature.resolver.integration.spec.ts | 6 +- .../temperature/temperature.service.spec.ts | 60 +++++++++---------- 5 files changed, 89 insertions(+), 75 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts b/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts index 1df25bdd8d..f1ee5109c9 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts @@ -2,9 +2,9 @@ import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import type { Systeminformation } from 'systeminformation'; +import type { MockedFunction } from 'vitest'; import { execa } from 'execa'; import { blockDevices, diskLayout } from 'systeminformation'; -// Vitest imports import { beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -21,6 +21,8 @@ import { import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.service.js'; import { batchProcess } from '@app/utils.js'; +// Vitest imports + // Mock the external dependencies using vi vi.mock('execa'); vi.mock('systeminformation'); @@ -32,10 +34,10 @@ vi.mock('@app/utils.js', () => ({ })); // Remove explicit type assertions for mocks -const mockExeca = execa as unknown as vi.MockedFunction; -const mockBlockDevices = blockDevices as unknown as vi.MockedFunction; -const mockDiskLayout = diskLayout as unknown as vi.MockedFunction; -const mockBatchProcess = batchProcess as unknown as vi.MockedFunction; +const mockExeca = execa as unknown as MockedFunction; +const mockBlockDevices = blockDevices as unknown as MockedFunction; +const mockDiskLayout = diskLayout as unknown as MockedFunction; +const mockBatchProcess = batchProcess as unknown as MockedFunction; describe('DisksService', () => { let service: DisksService; @@ -335,7 +337,7 @@ describe('DisksService', () => { command: '', cwd: '', isCanceled: false, - }); // Default successful execa + } as unknown as Awaited>); // Default successful execa }); it('should be defined', () => { @@ -523,8 +525,7 @@ describe('DisksService', () => { command: '', cwd: '', isCanceled: false, - }); - + } as unknown as Awaited>); const temperature = await service.getTemperature('/dev/sda'); expect(temperature).toBe(42); expect(mockExeca).toHaveBeenCalledWith('smartctl', [ @@ -560,8 +561,7 @@ describe('DisksService', () => { command: '', cwd: '', isCanceled: false, - }); - + } as unknown as Awaited>); const temperature = await service.getTemperature('/dev/sda'); expect(temperature).toBeNull(); }); @@ -590,8 +590,7 @@ describe('DisksService', () => { command: '', cwd: '', isCanceled: false, - }); - + } as unknown as Awaited>); const temperature = await service.getTemperature('/dev/sda'); expect(temperature).toBe(30); }); @@ -620,8 +619,7 @@ describe('DisksService', () => { command: '', cwd: '', isCanceled: false, - }); - + } as unknown as Awaited>); const temperature = await service.getTemperature('/dev/sda'); expect(temperature).toBe(35); }); @@ -644,8 +642,7 @@ describe('DisksService', () => { command: '', cwd: '', isCanceled: false, - }); - + } as unknown as Awaited>); const temperature = await service.getTemperature('/dev/sda'); expect(temperature).toBe(0); }); @@ -661,8 +658,7 @@ describe('DisksService', () => { command: '', cwd: '', isCanceled: false, - }); - + } as unknown as Awaited>); const temperature = await service.getTemperature('/dev/sda'); expect(temperature).toBeNull(); @@ -676,8 +672,7 @@ describe('DisksService', () => { command: '', cwd: '', isCanceled: false, - }); - + } as unknown as Awaited>); const temperature2 = await service.getTemperature('/dev/sda'); expect(temperature2).toBeNull(); }); 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 96b98ae492..7f8e380325 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 @@ -8,7 +8,7 @@ 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 { MemoryMetrics } from '@app/unraid-api/graph/resolvers/info/memory/memory.model.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'; import { MetricsResolver } from '@app/unraid-api/graph/resolvers/metrics/metrics.resolver.js'; import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; @@ -152,7 +152,7 @@ describe('MetricsResolver Integration Tests', () => { swapUsed: 0, swapFree: 0, percentSwapTotal: 0, - } as MemoryMetrics; + } as MemoryUtilization; }); // Trigger polling by simulating subscription diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts index 26fd0abbf3..04945e7f25 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Disk, DiskInterfaceType } from '@app/unraid-api/graph/resolvers/disks/disks.model.js'; import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.service.js'; import { DiskSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.js'; import { @@ -33,9 +34,8 @@ describe('DiskSensorsService', () => { describe('isAvailable', () => { it('should return true when disks exist', async () => { - // @ts-expect-error -- mocking partial Disk vi.mocked(disksService.getDisks).mockResolvedValue([ - { id: 'disk1', device: '/dev/sda', name: 'Test Disk' }, + { id: 'disk1', device: '/dev/sda', name: 'Test Disk' } as unknown as Disk, ]); const available = await service.isAvailable(); @@ -59,10 +59,19 @@ describe('DiskSensorsService', () => { describe('read', () => { it('should return disk temperatures', async () => { - // @ts-expect-error -- mocking partial Disk vi.mocked(disksService.getDisks).mockResolvedValue([ - { id: 'disk1', device: '/dev/sda', name: 'Seagate HDD', interfaceType: 'sata' }, - { id: 'disk2', device: '/dev/nvme0n1', name: 'Samsung NVMe', interfaceType: 'nvme' }, + { + id: 'disk1', + device: '/dev/sda', + name: 'Seagate HDD', + interfaceType: DiskInterfaceType.SATA, + } as unknown as Disk, + { + id: 'disk2', + device: '/dev/nvme0n1', + name: 'Samsung NVMe', + interfaceType: DiskInterfaceType.PCIE, + } as unknown as Disk, ]); vi.mocked(disksService.getTemperature).mockResolvedValueOnce(35).mockResolvedValueOnce(45); @@ -87,10 +96,9 @@ describe('DiskSensorsService', () => { }); it('should skip disks without temperature data', async () => { - // @ts-expect-error -- mocking partial Disk vi.mocked(disksService.getDisks).mockResolvedValue([ - { id: 'disk1', device: '/dev/sda', name: 'Disk 1' }, - { id: 'disk2', device: '/dev/sdb', name: 'Disk 2' }, + { id: 'disk1', device: '/dev/sda', name: 'Disk 1' } as unknown as Disk, + { id: 'disk2', device: '/dev/sdb', name: 'Disk 2' } as unknown as Disk, ]); vi.mocked(disksService.getTemperature).mockResolvedValueOnce(35).mockResolvedValueOnce(null); // No temp for disk2 @@ -102,10 +110,9 @@ describe('DiskSensorsService', () => { }); it('should handle getTemperature errors gracefully', async () => { - // @ts-expect-error -- mocking partial Disk vi.mocked(disksService.getDisks).mockResolvedValue([ - { id: 'disk1', device: '/dev/sda', name: 'Disk 1' }, - { id: 'disk2', device: '/dev/sdb', name: 'Disk 2' }, + { id: 'disk1', device: '/dev/sda', name: 'Disk 1' } as unknown as Disk, + { id: 'disk2', device: '/dev/sdb', name: 'Disk 2' } as unknown as Disk, ]); vi.mocked(disksService.getTemperature) @@ -119,9 +126,8 @@ describe('DiskSensorsService', () => { }); it('should use device name as fallback when name is empty', async () => { - // @ts-expect-error -- mocking partial Disk vi.mocked(disksService.getDisks).mockResolvedValue([ - { id: 'disk1', device: '/dev/sda', name: '' }, + { id: 'disk1', device: '/dev/sda', name: '' } as unknown as Disk, ]); vi.mocked(disksService.getTemperature).mockResolvedValue(35); @@ -134,9 +140,13 @@ describe('DiskSensorsService', () => { describe('inferDiskType', () => { it('should return NVME for nvme interface', async () => { - // @ts-expect-error -- mocking partial Disk vi.mocked(disksService.getDisks).mockResolvedValue([ - { id: 'disk1', device: '/dev/nvme0n1', name: 'NVMe', interfaceType: 'nvme' }, + { + id: 'disk1', + device: '/dev/nvme0n1', + name: 'NVMe', + interfaceType: DiskInterfaceType.PCIE, + } as unknown as Disk, ]); vi.mocked(disksService.getTemperature).mockResolvedValue(40); @@ -145,9 +155,13 @@ describe('DiskSensorsService', () => { }); it('should return NVME for pcie interface', async () => { - // @ts-expect-error -- mocking partial Disk vi.mocked(disksService.getDisks).mockResolvedValue([ - { id: 'disk1', device: '/dev/nvme0n1', name: 'NVMe', interfaceType: 'pcie' }, + { + id: 'disk1', + device: '/dev/nvme0n1', + name: 'NVMe', + interfaceType: DiskInterfaceType.PCIE, + } as unknown as Disk, ]); vi.mocked(disksService.getTemperature).mockResolvedValue(40); @@ -156,9 +170,13 @@ describe('DiskSensorsService', () => { }); it('should return DISK for sata interface', async () => { - // @ts-expect-error -- mocking partial Disk vi.mocked(disksService.getDisks).mockResolvedValue([ - { id: 'disk1', device: '/dev/sda', name: 'HDD', interfaceType: 'sata' }, + { + id: 'disk1', + device: '/dev/sda', + name: 'HDD', + interfaceType: DiskInterfaceType.SATA, + } as unknown as Disk, ]); vi.mocked(disksService.getTemperature).mockResolvedValue(35); @@ -167,9 +185,8 @@ describe('DiskSensorsService', () => { }); it('should return DISK for undefined interface', async () => { - // @ts-expect-error -- mocking partial Disk vi.mocked(disksService.getDisks).mockResolvedValue([ - { id: 'disk1', device: '/dev/sda', name: 'HDD' }, + { id: 'disk1', device: '/dev/sda', name: 'HDD' } as unknown as Disk, ]); vi.mocked(disksService.getTemperature).mockResolvedValue(35); diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts index 7b3f2abf2c..444e69ff93 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts @@ -9,6 +9,7 @@ import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memor import { MetricsResolver } from '@app/unraid-api/graph/resolvers/metrics/metrics.resolver.js'; import { SensorType, + TemperatureMetrics, TemperatureStatus, TemperatureUnit, } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; @@ -203,8 +204,9 @@ describe('Temperature GraphQL Integration', () => { summary: mockTemperatureMetrics.summary, }; - // @ts-expect-error -- mocking partial TemperatureMetrics - vi.mocked(temperatureService.getMetrics).mockResolvedValue(multiSensorMetrics); + vi.mocked(temperatureService.getMetrics).mockResolvedValue( + multiSensorMetrics as unknown as TemperatureMetrics + ); const result = await resolver.temperature(); diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts index 4ce22ed970..1ef1e6eeaa 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts @@ -57,9 +57,9 @@ describe('TemperatureService', () => { history = new TemperatureHistoryService(configService); service = new TemperatureService( - lmSensors as LmSensorsService, - diskSensors as DiskSensorsService, - ipmiSensors as IpmiSensorsService, + lmSensors as unknown as LmSensorsService, + diskSensors as unknown as DiskSensorsService, + ipmiSensors as unknown as IpmiSensorsService, history, configService ); @@ -74,7 +74,7 @@ describe('TemperatureService', () => { }); it('should handle provider initialization errors gracefully', async () => { - vi.mocked(lmSensors.isAvailable).mockRejectedValue(new Error('Failed')); + vi.mocked(lmSensors.isAvailable!).mockRejectedValue(new Error('Failed')); await service.onModuleInit(); @@ -99,14 +99,14 @@ describe('TemperatureService', () => { }); it('should return null when no providers available', async () => { - vi.mocked(lmSensors.isAvailable).mockResolvedValue(false); - vi.mocked(diskSensors.isAvailable).mockResolvedValue(false); - vi.mocked(ipmiSensors.isAvailable).mockResolvedValue(false); + vi.mocked(lmSensors.isAvailable!).mockResolvedValue(false); + vi.mocked(diskSensors.isAvailable!).mockResolvedValue(false); + vi.mocked(ipmiSensors.isAvailable!).mockResolvedValue(false); const emptyService = new TemperatureService( - lmSensors, - diskSensors, - ipmiSensors, + lmSensors as unknown as LmSensorsService, + diskSensors as unknown as DiskSensorsService, + ipmiSensors as unknown as IpmiSensorsService, history, configService ); @@ -117,7 +117,7 @@ describe('TemperatureService', () => { }); it('should compute correct status based on thresholds', async () => { - vi.mocked(lmSensors.read).mockResolvedValue([ + vi.mocked(lmSensors.read!).mockResolvedValue([ { id: 'cpu:hot', name: 'Hot CPU', @@ -141,7 +141,7 @@ describe('TemperatureService', () => { await service.onModuleInit(); - vi.mocked(lmSensors.read).mockResolvedValue([ + vi.mocked(lmSensors.read!).mockResolvedValue([ { id: 'cpu:warm', name: 'Warm CPU', @@ -165,7 +165,7 @@ describe('TemperatureService', () => { await service.onModuleInit(); - vi.mocked(lmSensors.read).mockResolvedValue([ + vi.mocked(lmSensors.read!).mockResolvedValue([ { id: 'cpu:package', name: 'CPU Package', @@ -190,7 +190,7 @@ describe('TemperatureService', () => { await service.onModuleInit(); - vi.mocked(lmSensors.read).mockResolvedValue([ + vi.mocked(lmSensors.read!).mockResolvedValue([ { id: 'cpu:package', name: 'CPU Package', @@ -216,7 +216,7 @@ describe('TemperatureService', () => { await service.onModuleInit(); - vi.mocked(lmSensors.read).mockResolvedValue([ + vi.mocked(lmSensors.read!).mockResolvedValue([ { id: 'cpu:package', name: 'CPU Package', @@ -247,7 +247,7 @@ describe('TemperatureService', () => { await service.onModuleInit(); - vi.mocked(lmSensors.read).mockResolvedValue([ + vi.mocked(lmSensors.read!).mockResolvedValue([ { id: 'cpu:package', name: 'CPU Package', @@ -271,7 +271,7 @@ describe('TemperatureService', () => { describe('buildSummary', () => { it('should calculate correct average', async () => { await service.onModuleInit(); - vi.mocked(lmSensors.read).mockResolvedValue([ + vi.mocked(lmSensors.read!).mockResolvedValue([ { id: 'sensor1', name: 'Sensor 1', @@ -294,7 +294,7 @@ describe('TemperatureService', () => { it('should identify hottest and coolest sensors', async () => { await service.onModuleInit(); - vi.mocked(lmSensors.read).mockResolvedValue([ + vi.mocked(lmSensors.read!).mockResolvedValue([ { id: 's1', name: 'Cool', @@ -328,7 +328,7 @@ describe('TemperatureService', () => { await service.onModuleInit(); // Simulate a slow/hanging provider - vi.mocked(lmSensors.read).mockImplementation( + vi.mocked(lmSensors.read!).mockImplementation( () => new Promise((resolve) => setTimeout(() => resolve([]), 1000)) ); @@ -342,7 +342,7 @@ describe('TemperatureService', () => { await service.onModuleInit(); // Both providers return a sensor with the same ID - vi.mocked(lmSensors.read).mockResolvedValue([ + vi.mocked(lmSensors.read!).mockResolvedValue([ { id: 'duplicate-sensor', name: 'Sensor from lm-sensors', @@ -352,7 +352,7 @@ describe('TemperatureService', () => { }, ]); - vi.mocked(diskSensors.read).mockResolvedValue([ + vi.mocked(diskSensors.read!).mockResolvedValue([ { id: 'duplicate-sensor', name: 'Sensor from disk', @@ -372,7 +372,7 @@ describe('TemperatureService', () => { it('should handle empty sensor name', async () => { await service.onModuleInit(); - vi.mocked(lmSensors.read).mockResolvedValue([ + vi.mocked(lmSensors.read!).mockResolvedValue([ { id: 'sensor-no-name', name: '', @@ -392,7 +392,7 @@ describe('TemperatureService', () => { it('should handle negative temperature values', async () => { await service.onModuleInit(); - vi.mocked(lmSensors.read).mockResolvedValue([ + vi.mocked(lmSensors.read!).mockResolvedValue([ { id: 'cold-sensor', name: 'Freezer Sensor', @@ -411,7 +411,7 @@ describe('TemperatureService', () => { it('should handle extremely high temperature values', async () => { await service.onModuleInit(); - vi.mocked(lmSensors.read).mockResolvedValue([ + vi.mocked(lmSensors.read!).mockResolvedValue([ { id: 'hot-sensor', name: 'Very Hot Sensor', @@ -430,7 +430,7 @@ describe('TemperatureService', () => { it('should handle NaN temperature values', async () => { await service.onModuleInit(); - vi.mocked(lmSensors.read).mockResolvedValue([ + vi.mocked(lmSensors.read!).mockResolvedValue([ { id: 'nan-sensor', name: 'Bad Sensor', @@ -450,7 +450,7 @@ describe('TemperatureService', () => { it('should handle mix of valid and NaN temperature values', async () => { await service.onModuleInit(); - vi.mocked(lmSensors.read).mockResolvedValue([ + vi.mocked(lmSensors.read!).mockResolvedValue([ { id: 'valid-sensor', name: 'Good Sensor', @@ -478,8 +478,8 @@ describe('TemperatureService', () => { it('should handle all providers failing', async () => { await service.onModuleInit(); - vi.mocked(lmSensors.read).mockRejectedValue(new Error('lm-sensors failed')); - vi.mocked(diskSensors.read).mockRejectedValue(new Error('disk sensors failed')); + vi.mocked(lmSensors.read!).mockRejectedValue(new Error('lm-sensors failed')); + vi.mocked(diskSensors.read!).mockRejectedValue(new Error('disk sensors failed')); const metrics = await service.getMetrics(); @@ -489,8 +489,8 @@ describe('TemperatureService', () => { it('should handle partial provider failures', async () => { await service.onModuleInit(); - vi.mocked(lmSensors.read).mockRejectedValue(new Error('lm-sensors failed')); - vi.mocked(diskSensors.read).mockResolvedValue([ + vi.mocked(lmSensors.read!).mockRejectedValue(new Error('lm-sensors failed')); + vi.mocked(diskSensors.read!).mockResolvedValue([ { id: 'disk:sda', name: 'HDD', From 749d9c8acc59a4ecb90bad006b7a551c155f98b6 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 2 Feb 2026 08:35:55 +0000 Subject: [PATCH 82/86] add unit tests for impi sensors --- .../sensors/ipmi_sensors.service.spec.ts | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/ipmi_sensors.service.spec.ts diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/ipmi_sensors.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/ipmi_sensors.service.spec.ts new file mode 100644 index 0000000000..c6ae55cdc1 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/ipmi_sensors.service.spec.ts @@ -0,0 +1,178 @@ +import { ConfigService } from '@nestjs/config'; + +import { execa } from 'execa'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { IpmiSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/ipmi_sensors.service.js'; +import { + SensorType, + TemperatureUnit, +} from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; + +// Mock execa +vi.mock('execa', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + execa: vi.fn(), + }; +}); + +describe('IpmiSensorsService', () => { + let service: IpmiSensorsService; + let configService: ConfigService; + + beforeEach(() => { + // @ts-expect-error -- mocking partial ConfigService + configService = { + get: vi.fn(), + }; + + service = new IpmiSensorsService(configService); + vi.clearAllMocks(); + }); + + describe('isAvailable', () => { + it('should return true when ipmitool command exists', async () => { + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: 'ipmitool version 1.8.18' }); + + const available = await service.isAvailable(); + + expect(available).toBe(true); + expect(execa).toHaveBeenCalledWith( + 'ipmitool', + ['-V'], + expect.objectContaining({ timeout: 3000 }) + ); + }); + + it('should return false when ipmitool command fails', async () => { + vi.mocked(execa).mockRejectedValue(new Error('Command not found')); + + const available = await service.isAvailable(); + + expect(available).toBe(false); + }); + }); + + describe('read', () => { + it('should parse IPMI output correctly', async () => { + const mockOutput = + 'CPU Temp | 40 degrees C | ok\n' + + 'System Temp | 35 degrees C | ok\n' + + 'Ambient Temp | 25 degrees C | ok'; + + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: mockOutput }); + + const sensors = await service.read(); + + expect(sensors).toHaveLength(3); + + expect(sensors[0]).toEqual({ + id: 'ipmi:cpu_temp', + name: 'CPU Temp', + type: SensorType.CPU_PACKAGE, + value: 40.0, + unit: TemperatureUnit.CELSIUS, + }); + + expect(sensors[1]).toEqual({ + id: 'ipmi:system_temp', + name: 'System Temp', + type: SensorType.MOTHERBOARD, + value: 35.0, + unit: TemperatureUnit.CELSIUS, + }); + expect(sensors[2]).toEqual({ + id: 'ipmi:ambient_temp', + name: 'Ambient Temp', + type: SensorType.MOTHERBOARD, + value: 25.0, + unit: TemperatureUnit.CELSIUS, + }); + }); + + it('should handle Fahrenheit units', async () => { + const mockOutput = 'CPU Temp | 104 degrees F | ok'; + + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: mockOutput }); + + const sensors = await service.read(); + + expect(sensors).toHaveLength(1); + expect(sensors[0].unit).toBe(TemperatureUnit.FAHRENHEIT); + expect(sensors[0].value).toBe(104.0); + }); + + it('should handle malformed lines', async () => { + const mockOutput = + 'CPU Temp | 40 degrees C | ok\n' + 'Invalid Line Here\n' + 'Another | Invalid'; + + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: mockOutput }); + + const sensors = await service.read(); + + expect(sensors).toHaveLength(1); + expect(sensors[0].name).toBe('CPU Temp'); + }); + + it('should skip non-numeric values', async () => { + const mockOutput = + 'CPU Temp | 40 degrees C | ok\n' + + 'Bad Sensor | No Reading | ns'; + + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: mockOutput }); + + const sensors = await service.read(); + + expect(sensors).toHaveLength(1); + expect(sensors[0].name).toBe('CPU Temp'); + }); + + it('should return empty array on execution error', async () => { + vi.mocked(execa).mockRejectedValue(new Error('ipmitool failed')); + + const sensors = await service.read(); + + expect(sensors).toEqual([]); + }); + + it('should handle empty output', async () => { + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: '' }); + + const sensors = await service.read(); + + expect(sensors).toEqual([]); + }); + }); + + describe('inferType', () => { + it('should return CUSTOM for unknown sensor types', async () => { + const mockOutput = 'Unknown Sensor | 30 degrees C | ok'; + + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: mockOutput }); + + const sensors = await service.read(); + + expect(sensors[0].type).toBe(SensorType.CUSTOM); + }); + + it('should return MOTHERBOARD for system sensors', async () => { + const mockOutput = 'System Temp | 30 degrees C | ok'; + + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: mockOutput }); + + const sensors = await service.read(); + + expect(sensors[0].type).toBe(SensorType.MOTHERBOARD); + }); + }); +}); From ab13466632306bb88f707422a32dd1a43f0b19cb Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 2 Feb 2026 08:51:04 +0000 Subject: [PATCH 83/86] Handle null-able payload and update unit tests --- .../metrics/metrics.resolver.spec.ts | 97 +++++++++++++++++++ .../resolvers/metrics/metrics.resolver.ts | 9 +- 2 files changed, 103 insertions(+), 3 deletions(-) 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 f0ec533e28..db2e735c89 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 @@ -4,14 +4,30 @@ import { Test } from '@nestjs/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { pubsub } 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'; +import { + TemperatureMetrics, + TemperatureSummary, +} from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; import { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js'; import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js'; +vi.mock('@app/core/pubsub.js', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + pubsub: { + publish: vi.fn(), + asyncIterableIterator: vi.fn(), + }, + }; +}); + describe('MetricsResolver', () => { let resolver: MetricsResolver; let cpuService: CpuService; @@ -214,5 +230,86 @@ describe('MetricsResolver', () => { 2000 ); }); + + it('should skip publishing temperature metrics when payload is null', async () => { + const registerTopicMock = vi.fn(); + const subscriptionTracker = { + registerTopic: registerTopicMock, + } as unknown as SubscriptionTrackerService; + + const temperatureServiceMock = { + getMetrics: vi.fn().mockResolvedValue(null), + } as unknown as TemperatureService; + + const configServiceMock = { + get: vi.fn().mockReturnValue(true), // Enabled + } as unknown as ConfigService; + + const testModule = new MetricsResolver( + {} as CpuService, + {} as CpuTopologyService, + {} as MemoryService, + temperatureServiceMock, + subscriptionTracker, + {} as SubscriptionHelperService, + configServiceMock + ); + + testModule.onModuleInit(); + + // Find the temperature callback + const call = registerTopicMock.mock.calls.find((c) => c[0] === 'TEMPERATURE_METRICS'); + expect(call).toBeDefined(); + const callback = call[1]; + + // Execute callback + await callback(); + + expect(pubsub.publish).not.toHaveBeenCalledWith('TEMPERATURE_METRICS', expect.anything()); + }); + + it('should publish temperature metrics when payload is present', async () => { + const registerTopicMock = vi.fn(); + const subscriptionTracker = { + registerTopic: registerTopicMock, + } as unknown as SubscriptionTrackerService; + + const payload = { + id: 'temp-metrics', + sensors: [], + summary: {} as unknown as TemperatureSummary, + } as TemperatureMetrics; + const temperatureServiceMock = { + getMetrics: vi.fn().mockResolvedValue(payload), + } as unknown as TemperatureService; + + const configServiceMock = { + get: vi.fn().mockReturnValue(true), // Enabled + } as unknown as ConfigService; + + const testModule = new MetricsResolver( + {} as CpuService, + {} as CpuTopologyService, + {} as MemoryService, + temperatureServiceMock, + subscriptionTracker, + {} as SubscriptionHelperService, + configServiceMock + ); + + testModule.onModuleInit(); + + // Find the temperature callback + const call = registerTopicMock.mock.calls.find((c) => c[0] === 'TEMPERATURE_METRICS'); + expect(call).toBeDefined(); + const callback = call[1]; + + // Execute callback + await callback(); + + expect(pubsub.publish).toHaveBeenCalledWith('TEMPERATURE_METRICS', { + systemMetricsTemperature: payload, + }); + }); }); }); 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 162115efca..14aef6b3a4 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts @@ -91,9 +91,11 @@ export class MetricsResolver implements OnModuleInit { PUBSUB_CHANNEL.TEMPERATURE_METRICS, async () => { const payload = await this.temperatureService.getMetrics(); - pubsub.publish(PUBSUB_CHANNEL.TEMPERATURE_METRICS, { - systemMetricsTemperature: payload, - }); + if (payload) { + pubsub.publish(PUBSUB_CHANNEL.TEMPERATURE_METRICS, { + systemMetricsTemperature: payload, + }); + } }, pollingInterval ); @@ -164,6 +166,7 @@ export class MetricsResolver implements OnModuleInit { @Subscription(() => TemperatureMetrics, { name: 'systemMetricsTemperature', resolve: (value) => value.systemMetricsTemperature, + nullable: true, }) @UsePermissions({ action: AuthAction.READ_ANY, From 6feddff26d99bc0d3bb51197129cf140b0525eb2 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 2 Feb 2026 08:53:51 +0000 Subject: [PATCH 84/86] removed misleading comment --- .../resolvers/metrics/temperature/temperature.service.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts index 1ef1e6eeaa..0774268a25 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts @@ -443,7 +443,6 @@ describe('TemperatureService', () => { const metrics = await service.getMetrics(); // Document expected behavior - should either filter out or handle gracefully - // Current implementation would include it; you may want to filter expect(metrics).toBeNull(); }); From ce9d0cb52638bc8cb6ab1a5e06d6b48bef5aaee3 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 2 Feb 2026 08:55:35 +0000 Subject: [PATCH 85/86] remove extra comments --- .../metrics/temperature/temperature.service.spec.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts index 0774268a25..f9ecd74719 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts @@ -334,7 +334,6 @@ describe('TemperatureService', () => { const metrics = await service.getMetrics(); - // Should either timeout or complete - document expected behavior expect(metrics).toBeDefined(); }, 10000); @@ -364,8 +363,6 @@ describe('TemperatureService', () => { const metrics = await service.getMetrics(); - // Document expected behavior - currently allows duplicates - // If you want to dedupe, add logic and update this test expect(metrics?.sensors.filter((s) => s.id === 'duplicate-sensor')).toHaveLength(2); }); @@ -385,8 +382,6 @@ describe('TemperatureService', () => { const metrics = await service.getMetrics(); expect(metrics?.sensors[0].name).toBe(''); - // Or if you want to enforce non-empty names: - // expect(metrics?.sensors[0].name).toBe('Unknown Sensor'); }); it('should handle negative temperature values', async () => { @@ -442,7 +437,6 @@ describe('TemperatureService', () => { const metrics = await service.getMetrics(); - // Document expected behavior - should either filter out or handle gracefully expect(metrics).toBeNull(); }); From f8ecc23a42624db09a0797026a34e278265a3ed3 Mon Sep 17 00:00:00 2001 From: Mitchell Thompkins Date: Mon, 2 Feb 2026 09:11:36 +0000 Subject: [PATCH 86/86] fix some minor typing issues. I think non-null is fine here --- .../graph/resolvers/metrics/metrics.resolver.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 db2e735c89..909c27c80c 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 @@ -260,7 +260,7 @@ describe('MetricsResolver', () => { // Find the temperature callback const call = registerTopicMock.mock.calls.find((c) => c[0] === 'TEMPERATURE_METRICS'); expect(call).toBeDefined(); - const callback = call[1]; + const callback = call![1]; // Execute callback await callback(); @@ -302,7 +302,7 @@ describe('MetricsResolver', () => { // Find the temperature callback const call = registerTopicMock.mock.calls.find((c) => c[0] === 'TEMPERATURE_METRICS'); expect(call).toBeDefined(); - const callback = call[1]; + const callback = call![1]; // Execute callback await callback();