diff --git a/api/README.md b/api/README.md index 886558175e..7f8a2f9890 100644 --- a/api/README.md +++ b/api/README.md @@ -84,6 +84,7 @@ 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 diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index d44a115dd2..5b8eaf9997 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -5,5 +5,31 @@ "ssoSubIds": [], "plugins": [ "unraid-api-plugin-connect" - ] -} \ No newline at end of file + ], + "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 + } + } + } +} diff --git a/api/docs/developer/temperature.md b/api/docs/developer/temperature.md new file mode 100644 index 0000000000..a375fa392f --- /dev/null +++ b/api/docs/developer/temperature.md @@ -0,0 +1,141 @@ +# 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`. +Nominally the `api.json` file is found at +`/boot/config/plugins/dynamix.my.servers/configs/`. + +### `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"`, `"kelvin"`, `"rankine"`. | +| `polling_interval` | `number` | `5000` | Polling interval in milliseconds for the subscription. | +| `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 + +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. | + +### 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` + +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 + } + } + } +} +``` 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/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 {} 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..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 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 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; @@ -303,7 +305,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; } @@ -335,7 +337,7 @@ describe('DisksService', () => { command: '', cwd: '', isCanceled: false, - }); // Default successful execa + } as unknown as Awaited>); // Default successful execa }); it('should be defined', () => { @@ -383,7 +385,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 []; } @@ -500,64 +502,124 @@ 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, command: '', cwd: '', isCanceled: false, - }); - + } as unknown as Awaited>); 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 - + } as unknown as Awaited>); const temperature = await service.getTemperature('/dev/sda'); expect(temperature).toBeNull(); }); 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, command: '', cwd: '', isCanceled: false, - }); - + } as unknown as Awaited>); const temperature = await service.getTemperature('/dev/sda'); expect(temperature).toBe(30); }); 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, command: '', cwd: '', isCanceled: false, - }); - + } as unknown as Awaited>); const temperature = await service.getTemperature('/dev/sda'); expect(temperature).toBe(35); }); @@ -568,5 +630,51 @@ 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, + } as unknown as Awaited>); + 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, + } as unknown as Awaited>); + 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, + } as unknown as Awaited>); + 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 f1387ca23c..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,30 +14,42 @@ 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 { - 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') - ); + const { stdout } = await execa('smartctl', ['-n', 'standby', '-A', '-j', device]); + const data = JSON.parse(stdout); - if (!field) { - return null; + if (data.temperature?.current !== undefined && data.temperature?.current !== null) { + return data.temperature.current; } - if (field.includes('Min/Max')) { - return Number.parseInt(field.split(' - ')[1].trim().split(' ')[0], 10); + if (data.ata_smart_attributes?.table) { + const tempAttr = data.ata_smart_attributes.table.find( + // 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 !== undefined && tempAttr?.raw?.value !== null) { + return tempAttr.raw.value; + } } - const line = field.split(' '); - return Number.parseInt(line[line.length - 1], 10); + return null; } catch (error: unknown) { return null; } 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 f6e195087e..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,10 +3,11 @@ 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 { 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], + imports: [ServicesModule, CpuModule, TemperatureModule], providers: [MetricsResolver, MemoryService], exports: [MetricsResolver], }) 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..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 @@ -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'; @@ -7,8 +8,10 @@ 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 { 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'; 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,9 +28,21 @@ describe('MetricsResolver Integration Tests', () => { CpuService, CpuTopologyService, MemoryService, + { + provide: TemperatureService, + useValue: { + getMetrics: vi.fn().mockResolvedValue(null), + }, + }, SubscriptionTrackerService, SubscriptionHelperService, SubscriptionManagerService, + { + provide: ConfigService, + useValue: { + get: vi.fn((key: string, defaultValue?: unknown) => defaultValue), + }, + }, ], }).compile(); @@ -137,7 +152,7 @@ describe('MetricsResolver Integration Tests', () => { swapUsed: 0, swapFree: 0, percentSwapTotal: 0, - } as any; + } as MemoryUtilization; }); // Trigger polling by simulating subscription 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..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 @@ -1,15 +1,33 @@ import type { TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; 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; @@ -82,6 +100,18 @@ describe('MetricsResolver', () => { createTrackedSubscription: vi.fn(), }, }, + { + provide: TemperatureService, + useValue: { + getMetrics: vi.fn().mockResolvedValue(null), + }, + }, + { + provide: ConfigService, + useValue: { + get: vi.fn((key: string, defaultValue?: unknown) => defaultValue), + }, + }, ], }).compile(); @@ -168,17 +198,27 @@ 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 configServiceMock = { + get: vi.fn((key: string, defaultValue?: unknown) => defaultValue), + }; + const testModule = new MetricsResolver( cpuService, cpuTopologyServiceMock as unknown as CpuTopologyService, memoryService, - subscriptionTracker as any, - {} as any + temperatureServiceMock as unknown as TemperatureService, + subscriptionTracker as unknown as SubscriptionTrackerService, + {} as unknown as SubscriptionHelperService, + configServiceMock as unknown as ConfigService ); testModule.onModuleInit(); - expect(subscriptionTracker.registerTopic).toHaveBeenCalledTimes(3); + expect(subscriptionTracker.registerTopic).toHaveBeenCalledTimes(4); expect(subscriptionTracker.registerTopic).toHaveBeenCalledWith( 'CPU_UTILIZATION', expect.any(Function), @@ -190,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 cbd47e86ba..14aef6b3a4 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'; @@ -11,6 +12,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.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'; @@ -21,8 +24,10 @@ 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 + private readonly subscriptionHelper: SubscriptionHelperService, + private readonly configService: ConfigService ) {} onModuleInit() { @@ -77,6 +82,24 @@ export class MetricsResolver implements OnModuleInit { }, 2000 ); + + const isTemperatureEnabled = this.configService.get('api.temperature.enabled', true); + const pollingInterval = this.configService.get('api.temperature.polling_interval', 5000); + + if (isTemperatureEnabled) { + this.subscriptionTracker.registerTopic( + PUBSUB_CHANNEL.TEMPERATURE_METRICS, + async () => { + const payload = await this.temperatureService.getMetrics(); + if (payload) { + pubsub.publish(PUBSUB_CHANNEL.TEMPERATURE_METRICS, { + systemMetricsTemperature: payload, + }); + } + }, + pollingInterval + ); + } } @Query(() => Metrics) @@ -135,4 +158,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, + nullable: true, + }) + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) + public async systemMetricsTemperatureSubscription() { + return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.TEMPERATURE_METRICS); + } } 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..04945e7f25 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts @@ -0,0 +1,197 @@ +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 { + 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 unknown as Disk, + ]); + + 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: 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); + + 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' } 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 + + 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' } as unknown as Disk, + { id: 'disk2', device: '/dev/sdb', name: 'Disk 2' } as unknown as Disk, + ]); + + 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 unknown as Disk, + ]); + + 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: DiskInterfaceType.PCIE, + } as unknown as Disk, + ]); + 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: DiskInterfaceType.PCIE, + } as unknown as Disk, + ]); + 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: DiskInterfaceType.SATA, + } as unknown as Disk, + ]); + 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 unknown as Disk, + ]); + 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/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/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); + }); + }); +}); 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..122ac897a5 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/ipmi_sensors.service.ts @@ -0,0 +1,95 @@ +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); + private readonly timeoutMs = 3000; + + constructor(private readonly configService: ConfigService) {} + + async isAvailable(): Promise { + try { + await execa('ipmitool', ['-V'], { timeout: this.timeoutMs }); + 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'], { + timeout: this.timeoutMs, + }); + + 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; + } +} 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..ba7d843a01 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.spec.ts @@ -0,0 +1,344 @@ +import { ConfigService } from '@nestjs/config'; + +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', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + execa: vi.fn(), + }; +}); + +describe('LmSensorsService', () => { + let service: LmSensorsService; + let configService: ConfigService; + + beforeEach(() => { + // @ts-expect-error -- mocking partial ConfigService + configService = { + get: vi.fn(), + }; + + service = new LmSensorsService(configService); + vi.clearAllMocks(); + }); + + describe('isAvailable', () => { + it('should return true when sensors command exists', async () => { + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: 'sensors version 3.6.0' }); + + const available = await service.isAvailable(); + + expect(available).toBe(true); + expect(execa).toHaveBeenCalledWith( + 'sensors', + ['--version'], + expect.objectContaining({ timeout: 3000 }) + ); + }); + + 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 use default arguments when no config path is set', async () => { + // Mock config returning undefined + vi.mocked(configService.get).mockReturnValue(undefined); + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: '{}' }); + + await service.read(); + + // Verify called with defaults + expect(execa).toHaveBeenCalledWith( + 'sensors', + ['-j'], + expect.objectContaining({ timeout: 3000 }) + ); + }); + + 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'); + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: '{}' }); + + await service.read(); + + // Verify called with extra args + expect(execa).toHaveBeenCalledWith( + 'sensors', + ['-j', '-c', '/etc/my-sensors.conf'], + expect.objectContaining({ timeout: 3000 }) + ); + }); + + 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, + }, + }, + }; + + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); + + 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 }, + }, + }; + + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); + + 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 }, + }, + }; + + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); + + 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, + }, + }, + }; + + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); + + const sensors = await service.read(); + + expect(sensors).toHaveLength(1); + expect(sensors[0].value).toBe(50.0); + }); + + it('should handle malformed JSON', async () => { + // @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 () => { + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: '{}' }); + + 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 }, + }, + }; + + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); + + 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, + }, + }, + }; + + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); + + 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 }, + }, + }; + + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); + + 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 }, + }, + }; + + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); + + 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 }, + }, + }; + + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); + + 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 }, + }, + }; + + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); + + 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 }, + }, + }; + + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); + + 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 }, + }, + }; + + // @ts-expect-error -- mocking partial execa result + vi.mocked(execa).mockResolvedValue({ stdout: JSON.stringify(mockOutput) }); + + 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/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..9a56225c03 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.ts @@ -0,0 +1,87 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; // Import ConfigService + +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); + private readonly timeoutMs = 3000; + + constructor(private readonly configService: ConfigService) {} + + async isAvailable(): Promise { + try { + await execa('sensors', ['--version'], { timeout: this.timeoutMs }); + return true; + } catch { + return false; + } + } + + async read(): Promise { + const configPath = this.configService.get( + 'api.temperature.sensors.lm_sensors.config_path' + ); + + const args = ['-j']; + if (configPath) { + args.push('-c', configPath); + } + + const { stdout } = await execa('sensors', args, { timeout: this.timeoutMs }); + 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' || !this.isRecord(values)) 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 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; + 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; +} 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..ec21622dec --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-history.service.spec.ts @@ -0,0 +1,239 @@ +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(() => { + // @ts-expect-error -- mocking partial ConfigService + configService = { + get: (key: string, defaultValue?: unknown) => defaultValue, + }; + + 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', () => { + // @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; + }, + }; + + 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.model.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts new file mode 100644 index 0000000000..36128b7179 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.model.ts @@ -0,0 +1,138 @@ +// 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'; + +export enum TemperatureUnit { + CELSIUS = 'CELSIUS', + FAHRENHEIT = 'FAHRENHEIT', + KELVIN = 'KELVIN', + RANKINE = 'RANKINE', +} + +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 TemperatureReading { + @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(() => TemperatureReading, { description: 'Current temperature' }) + current!: TemperatureReading; + + @Field(() => TemperatureReading, { nullable: true, description: 'Minimum recorded' }) + @IsOptional() + min?: TemperatureReading; + + @Field(() => TemperatureReading, { nullable: true, description: 'Maximum recorded' }) + @IsOptional() + max?: TemperatureReading; + + @Field(() => Float, { nullable: true, description: 'Warning threshold' }) + @IsOptional() + @IsNumber() + warning?: number; + + @Field(() => Float, { nullable: true, description: 'Critical threshold' }) + @IsOptional() + @IsNumber() + critical?: number; + + @Field(() => [TemperatureReading], { + nullable: true, + description: 'Historical readings for this sensor', + }) + @IsOptional() + history?: TemperatureReading[]; +} + +@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; +} 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..9c72226c3b --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts @@ -0,0 +1,24 @@ +// 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 { 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'; + +@Module({ + imports: [DisksModule], + providers: [ + TemperatureService, + LmSensorsService, + DiskSensorsService, + IpmiSensorsService, + // (@mitchellthompkins) Add other services here + // GpuSensorsService, + TemperatureHistoryService, + ], + exports: [TemperatureService], +}) +export class TemperatureModule {} 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..444e69ff93 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts @@ -0,0 +1,228 @@ +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js'; +import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js'; +import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js'; +import { MetricsResolver } from '@app/unraid-api/graph/resolvers/metrics/metrics.resolver.js'; +import { + SensorType, + TemperatureMetrics, + 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'; +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 module: TestingModule; + let resolver: MetricsResolver; + 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, + }, + 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, + }, + }; + + beforeEach(async () => { + 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), + }, + }, + { + 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?: unknown) => defaultValue), + }, + }, + ], + }).compile(); + + resolver = module.get(MetricsResolver); + temperatureService = module.get(TemperatureService); + }); + + afterEach(async () => { + await module.close(); + }); + + 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, + }, + }; + + 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 unknown as TemperatureMetrics + ); + + 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); + }); + }); + + describe('error handling', () => { + it('should handle service errors gracefully', async () => { + vi.mocked(temperatureService.getMetrics).mockRejectedValue( + new Error('Failed to read sensors') + ); + + 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 new file mode 100644 index 0000000000..f9ecd74719 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts @@ -0,0 +1,503 @@ +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 { 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, + 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: Partial; + let diskSensors: Partial; + let ipmiSensors: Partial; + 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, + }, + ]), + }; + + diskSensors = { + id: 'disk-sensors', + isAvailable: vi.fn().mockResolvedValue(true), + read: vi.fn().mockResolvedValue([]), + }; + + ipmiSensors = { + id: 'ipmi-sensors', + isAvailable: vi.fn().mockResolvedValue(false), // Default to unavailable + read: vi.fn().mockResolvedValue([]), + }; + + configService = new ConfigService(); + vi.spyOn(configService, 'get').mockImplementation( + (key: string, defaultValue?: unknown) => defaultValue + ); + + history = new TemperatureHistoryService(configService); + + service = new TemperatureService( + lmSensors as unknown as LmSensorsService, + diskSensors as unknown as DiskSensorsService, + ipmiSensors as unknown as IpmiSensorsService, + 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); + vi.mocked(ipmiSensors.isAvailable!).mockResolvedValue(false); + + const emptyService = new TemperatureService( + lmSensors as unknown as LmSensorsService, + diskSensors as unknown as DiskSensorsService, + ipmiSensors as unknown as IpmiSensorsService, + 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: 75, + 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 () => { + vi.spyOn(configService, 'get').mockImplementation((key: string, defaultValue?: unknown) => { + if (key === 'api.temperature.thresholds') { + return { cpu_warning: 60, cpu_critical: 80 }; + } + return defaultValue; + }); + + await service.onModuleInit(); + + vi.mocked(lmSensors.read!).mockResolvedValue([ + { + id: 'cpu:warm', + name: 'Warm CPU', + type: SensorType.CPU_CORE, + value: 65, + unit: TemperatureUnit.CELSIUS, + }, + ]); + + const metrics = await service.getMetrics(); + expect(metrics?.sensors[0].current.status).toBe(TemperatureStatus.WARNING); + }); + + it('should return temperature metrics in Kelvin when configured', async () => { + 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([ + { + id: 'cpu:package', + name: 'CPU Package', + type: SensorType.CPU_PACKAGE, + value: 0, + unit: TemperatureUnit.CELSIUS, + }, + ]); + + 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 () => { + 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([ + { + id: 'cpu:package', + name: 'CPU Package', + type: SensorType.CPU_PACKAGE, + value: 25, + unit: TemperatureUnit.CELSIUS, + }, + ]); + + 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 () => { + 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([ + { + id: 'cpu:package', + name: 'CPU Package', + type: SensorType.CPU_PACKAGE, + value: 20, + unit: TemperatureUnit.CELSIUS, + }, + ]); + + const metrics = await service.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); + }); + + it('should interpret user-defined thresholds in the default unit', async () => { + 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([ + { + 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 service.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', () => { + it('should calculate correct average', async () => { + await service.onModuleInit(); + 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 () => { + await service.onModuleInit(); + 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'); + }); + }); + 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([]), 1000)) + ); + + const metrics = await service.getMetrics(); + + expect(metrics).toBeDefined(); + }, 10000); + + 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(); + + 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(''); + }); + + 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(); + + 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 () => { + 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 new file mode 100644 index 0000000000..456a35eb70 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -0,0 +1,412 @@ +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, + 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, + TemperatureReading, + TemperatureSensor, + TemperatureStatus, + 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 { + private readonly logger = new Logger(TemperatureService.name); + 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, + private readonly diskSensors: DiskSensorsService, + private readonly ipmiSensors: IpmiSensorsService, + + // Future: private readonly gpuSensors: GpuSensorsService, + private readonly history: TemperatureHistoryService, + private readonly configService: ConfigService + ) {} + + async onModuleInit() { + // Initialize all providers and check availability + await this.initializeProviders(); + } + + 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'); + 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 + const potentialProviders = [ + { + service: this.lmSensors, + enabled: lmSensorsConfig?.enabled ?? true, + }, + { + service: this.diskSensors, + enabled: smartctlConfig?.enabled ?? true, + }, + { + service: this.ipmiSensors, + enabled: ipmiConfig?.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.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.service.id}`); + } + } catch (err) { + this.logger.warn(`Failed to check provider ${provider.service.id}`, err); + } + } + + if (this.availableProviders.length === 0) { + this.logger.warn('No temperature providers available'); + } + } + + async getMetrics(): Promise { + // 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 { + 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); + } + } + + // 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; + } + + const configUnit = + this.configService.get('api.temperature.default_unit') || 'celsius'; + const targetUnit = + 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 = { + value: r.value, + unit: r.unit, + timestamp: new Date(), + status: this.computeStatus(r.value, r.unit, r.type, thresholdConfig, targetUnit), + }; + + // Record in history (ALWAYS RAW) + this.history.record(r.id, rawCurrent, { + name: r.name, + type: r.type, + }); + + // Get historical data (RAW) + const { min, max } = this.history.getMinMax(r.id); + const rawHistory = this.history.getHistory(r.id); + + // Convert for output + const current = this.convertReading(rawCurrent, targetUnit) as TemperatureReading; + const history = rawHistory + .map((h) => this.convertReading(h, targetUnit)) + .filter((h): h is TemperatureReading => h !== undefined); + const minConverted = this.convertReading(min, targetUnit); + const maxConverted = this.convertReading(max, targetUnit); + + const rawThresholds = this.getThresholdsForType(r.type, thresholdConfig, targetUnit); + 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, + type: r.type, + current, + min: minConverted, + max: maxConverted, + history, + warning, + critical, + }; + }); + + return { + id: 'temperature-metrics', + sensors, + summary: this.buildSummary(sensors), + }; + } 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 configUnit = this.configService.get('api.temperature.default_unit') || 'celsius'; + 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 => { + 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 || !Number.isFinite(rawCurrent.value) || !metadata) return null; + + // Convert for output + const current = this.convertReading(rawCurrent, targetUnit) as TemperatureReading; + const history = rawHistory + .map((h) => this.convertReading(h, targetUnit)) + .filter((h): h is TemperatureReading => h !== undefined); + const minConverted = this.convertReading(min, targetUnit); + const maxConverted = this.convertReading(max, targetUnit); + + const rawThresholds = this.getThresholdsForType( + metadata.type, + thresholdConfig, + targetUnit + ); + const warning = this.convertValue( + rawThresholds.warning, + TemperatureUnit.CELSIUS, + targetUnit + ); + const critical = this.convertValue( + rawThresholds.critical, + TemperatureUnit.CELSIUS, + targetUnit + ); + + return { + id: sensorId, + name: metadata.name, + type: metadata.type, + current, + min: minConverted, + max: maxConverted, + history, + warning, + critical, + }; + }) + .filter((s): s is TemperatureSensor => s !== null); + + return { + id: 'temperature-metrics', + sensors, + summary: this.buildSummary(sensors), + }; + } + + private convertReading( + reading: TemperatureReading | undefined, + targetUnit: TemperatureUnit + ): 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 (fromUnit) { + case TemperatureUnit.CELSIUS: + celsius = value; + break; + case TemperatureUnit.FAHRENHEIT: + celsius = ((value - 32) * 5) / 9; + break; + case TemperatureUnit.KELVIN: + celsius = value - 273.15; + break; + case TemperatureUnit.RANKINE: + celsius = ((value - 491.67) * 5) / 9; + break; + default: + celsius = value; + } + + let targetValue: number; + + // Convert Celsius to target + switch (toUnit) { + 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 Number(targetValue.toFixed(2)); + } + + // Make status computation type-aware for future per-type thresholds + private computeStatus( + value: number, + unit: TemperatureUnit, + type: SensorType, + thresholdConfig: TemperatureThresholds, + sourceUnit: TemperatureUnit + ): TemperatureStatus { + // We always compute status using Celsius thresholds + const celsiusValue = this.convertValue(value, unit, TemperatureUnit.CELSIUS); + 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, + thresholds: TemperatureThresholds, + 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); + }; + + switch (type) { + case SensorType.CPU_PACKAGE: + case SensorType.CPU_CORE: + return { + warning: getVal(thresholds.cpu_warning, 70), + critical: getVal(thresholds.cpu_critical, 85), + }; + case SensorType.DISK: + case SensorType.NVME: + return { + warning: getVal(thresholds.disk_warning, 50), + critical: getVal(thresholds.disk_critical, 60), + }; + default: + return { + warning: getVal(thresholds.warning, 80), + critical: getVal(thresholds.critical, 90), + }; + } + } + + private buildSummary(sensors: TemperatureSensor[]) { + 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 { + average, + hottest, + coolest, + warningCount: sensors.filter((s) => s.current.status === TemperatureStatus.WARNING).length, + criticalCount: sensors.filter((s) => s.current.status === TemperatureStatus.CRITICAL).length, + }; + } +} 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..a6801c9d8d --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.ts @@ -0,0 +1,205 @@ +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) { + this.maxReadingsPerSensor = this.configService.get( + 'api.temperature.history.max_readings', + 1000 + ); + + 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` + ); + } + + /** + * 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; + } +} 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", diff --git a/packages/unraid-shared/src/services/api-config.ts b/packages/unraid-shared/src/services/api-config.ts index d9179fcc37..3c679b6af0 100644 --- a/packages/unraid-shared/src/services/api-config.ts +++ b/packages/unraid-shared/src/services/api-config.ts @@ -1,5 +1,128 @@ -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"; + +@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 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 IpmiConfig { + @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; + + @Field(() => IpmiConfig, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => IpmiConfig) + ipmi?: IpmiConfig; +} + +@ObjectType() +export class TemperatureConfig { + @Field({ nullable: true }) + @IsOptional() + @IsBoolean() + enabled?: boolean; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + default_unit?: string; + + @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; + + @Field(() => SensorsConfig, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => SensorsConfig) + sensors?: SensorsConfig; +} @ObjectType() export class ApiConfig { @@ -26,4 +149,11 @@ export class ApiConfig { @IsArray() @IsString({ each: true }) plugins!: string[]; + + @Field(() => TemperatureConfig, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => TemperatureConfig) + temperature?: TemperatureConfig; } + 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 )