feat: add cpu power query & subscription (#1745)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Per-package CPU power and temperature displayed in hardware info
(total and per-package values).
* CPU package topology (cores/threads per package) included in CPU info.
* Real-time per-package CPU telemetry exposed via a new system metrics
subscription.

* **Chores**
* Added an automated deployment script and npm deploy script for the
shared package.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Signed-off-by: Simon Fairweather <simon.n.fairweather@gmail.com>
Co-authored-by: Simon Fairweather <simon.n.fairweather@gmail.com>
Co-authored-by: SimonFair <39065407+SimonFair@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Pujit Mehrotra
2025-11-14 14:27:49 -05:00
committed by GitHub
parent 854b403fbd
commit d7aca81c60
15 changed files with 491 additions and 13 deletions

View File

@@ -1391,6 +1391,19 @@ type CpuLoad {
percentSteal: Float!
}
type CpuPackages implements Node {
id: PrefixedID!
"""Total CPU package power draw (W)"""
totalPower: Float!
"""Power draw per package (W)"""
power: [Float!]!
"""Temperature per package (°C)"""
temp: [Float!]!
}
type CpuUtilization implements Node {
id: PrefixedID!
@@ -1454,6 +1467,12 @@ type InfoCpu implements Node {
"""CPU feature flags"""
flags: [String!]
"""
Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]]
"""
topology: [[[Int!]!]!]!
packages: CpuPackages!
}
type MemoryLayout implements Node {
@@ -2642,6 +2661,7 @@ type Subscription {
arraySubscription: UnraidArray!
logFile(path: String!): LogFileContent!
systemMetricsCpu: CpuUtilization!
systemMetricsCpuTelemetry: CpuPackages!
systemMetricsMemory: MemoryUtilization!
upsUpdates: UPSDevice!
}

View File

@@ -0,0 +1,233 @@
import { Injectable, Logger } from '@nestjs/common';
import { constants as fsConstants } from 'node:fs';
import { access, readdir, readFile } from 'node:fs/promises';
import { join } from 'path';
@Injectable()
export class CpuTopologyService {
private readonly logger = new Logger(CpuTopologyService.name);
// -----------------------------------------------------------------
// Read static CPU topology, per-package core thread pairs
// -----------------------------------------------------------------
async generateTopology(): Promise<number[][][]> {
const packages: Record<number, number[][]> = {};
let cpuDirs: string[];
try {
cpuDirs = await readdir('/sys/devices/system/cpu');
} catch (err) {
this.logger.warn('CPU topology unavailable, /sys/devices/system/cpu not accessible');
return [];
}
for (const dir of cpuDirs) {
if (!/^cpu\d+$/.test(dir)) continue;
const basePath = join('/sys/devices/system/cpu', dir, 'topology');
const pkgFile = join(basePath, 'physical_package_id');
const siblingsFile = join(basePath, 'thread_siblings_list');
try {
const [pkgIdStr, siblingsStrRaw] = await Promise.all([
readFile(pkgFile, 'utf8'),
readFile(siblingsFile, 'utf8'),
]);
const pkgId = parseInt(pkgIdStr.trim(), 10);
// expand ranges
const siblings = siblingsStrRaw
.trim()
.replace(/(\d+)-(\d+)/g, (_, start, end) =>
Array.from(
{ length: parseInt(end) - parseInt(start) + 1 },
(_, i) => parseInt(start) + i
).join(',')
)
.split(',')
.map((n) => parseInt(n, 10));
if (!packages[pkgId]) packages[pkgId] = [];
if (!packages[pkgId].some((arr) => arr.join(',') === siblings.join(','))) {
packages[pkgId].push(siblings);
}
} catch (err) {
this.logger.warn(err, `Topology read error for ${dir}`);
}
}
// Sort cores within each package, and packages by their lowest core index
const result = Object.entries(packages)
.sort((a, b) => a[1][0][0] - b[1][0][0]) // sort packages by first CPU ID
.map(
([pkgId, cores]) => cores.sort((a, b) => a[0] - b[0]) // sort cores within package
);
return result;
}
// -----------------------------------------------------------------
// Dynamic telemetry (power + temperature)
// -----------------------------------------------------------------
private async getPackageTemps(): Promise<number[]> {
const temps: number[] = [];
try {
const hwmons = await readdir('/sys/class/hwmon');
for (const hwmon of hwmons) {
const path = join('/sys/class/hwmon', hwmon);
try {
const label = (await readFile(join(path, 'name'), 'utf8')).trim();
if (/coretemp|k10temp|zenpower/i.test(label)) {
const files = await readdir(path);
for (const f of files) {
if (f.startsWith('temp') && f.endsWith('_label')) {
const lbl = (await readFile(join(path, f), 'utf8')).trim().toLowerCase();
if (
lbl.includes('package id') ||
lbl.includes('tctl') ||
lbl.includes('tdie')
) {
const inputFile = join(path, f.replace('_label', '_input'));
try {
const raw = await readFile(inputFile, 'utf8');
const parsed = parseInt(raw.trim(), 10);
if (Number.isFinite(parsed)) {
temps.push(parsed / 1000);
} else {
this.logger.warn(`Invalid temperature value: ${raw.trim()}`);
}
} catch (err) {
this.logger.warn('Failed to read file', err);
}
}
}
}
}
} catch (err) {
this.logger.warn('Failed to read file', err);
}
}
} catch (err) {
this.logger.warn('Failed to read file', err);
}
return temps;
}
private async getPackagePower(): Promise<Record<number, Record<string, number>>> {
const basePath = '/sys/class/powercap';
const prefixes = ['intel-rapl', 'intel-rapl-mmio', 'amd-rapl'];
const raplPaths: string[] = [];
try {
const entries = await readdir(basePath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isSymbolicLink() && prefixes.some((p) => entry.name.startsWith(p))) {
if (/:\d+:\d+/.test(entry.name)) continue;
raplPaths.push(join(basePath, entry.name));
}
}
} catch {
return {};
}
if (!raplPaths.length) return {};
const readEnergy = async (p: string): Promise<number | null> => {
try {
await access(join(p, 'energy_uj'), fsConstants.R_OK);
const raw = await readFile(join(p, 'energy_uj'), 'utf8');
const parsed = parseInt(raw.trim(), 10);
return Number.isFinite(parsed) ? parsed : null;
} catch {
return null;
}
};
const prevE = new Map<string, number>();
const prevT = new Map<string, bigint>();
for (const p of raplPaths) {
const val = await readEnergy(p);
if (val !== null) {
prevE.set(p, val);
prevT.set(p, process.hrtime.bigint());
}
}
await new Promise((res) => setTimeout(res, 100));
const results: Record<number, Record<string, number>> = {};
for (const p of raplPaths) {
const now = await readEnergy(p);
if (now === null) continue;
const prevVal = prevE.get(p);
const prevTime = prevT.get(p);
if (prevVal === undefined || prevTime === undefined) continue;
const diffE = now - prevVal;
const diffT = Number(process.hrtime.bigint() - prevTime);
if (!Number.isFinite(diffE) || !Number.isFinite(diffT)) {
this.logger.warn(`Non-finite energy/time diff for ${p}`);
continue;
}
if (diffT <= 0 || diffE < 0) continue;
const watts = (diffE * 1e-6) / (diffT * 1e-9);
const powerW = Math.round(watts * 100) / 100;
if (!Number.isFinite(powerW)) {
this.logger.warn(`Non-finite power value for ${p}: ${watts}`);
continue;
}
const nameFile = join(p, 'name');
let label = 'package';
try {
label = (await readFile(nameFile, 'utf8')).trim();
} catch (err) {
this.logger.warn('Failed to read file', err);
}
const pkgMatch = label.match(/package-(\d+)/i);
const pkgId = pkgMatch ? Number(pkgMatch[1]) : 0;
if (!results[pkgId]) results[pkgId] = {};
results[pkgId][label] = powerW;
}
for (const domains of Object.values(results)) {
const total = Object.values(domains).reduce((a, b) => a + b, 0);
domains['total'] = Math.round(total * 100) / 100;
}
return results;
}
async generateTelemetry(): Promise<{ id: number; power: number; temp: number }[]> {
const temps = await this.getPackageTemps();
const powerData = await this.getPackagePower();
const maxPkg = Math.max(temps.length - 1, ...Object.keys(powerData).map(Number), 0);
const result: {
id: number;
power: number;
temp: number;
}[] = [];
for (let pkgId = 0; pkgId <= maxPkg; pkgId++) {
const entry = powerData[pkgId] ?? {};
result.push({
id: pkgId,
power: entry.total ?? -1,
temp: temps[pkgId] ?? -1,
});
}
return result;
}
}

View File

@@ -39,6 +39,18 @@ export class CpuLoad {
percentSteal!: number;
}
@ObjectType({ implements: () => Node })
export class CpuPackages extends Node {
@Field(() => Float, { description: 'Total CPU package power draw (W)' })
totalPower!: number;
@Field(() => [Float], { description: 'Power draw per package (W)' })
power!: number[];
@Field(() => [Float], { description: 'Temperature per package (°C)' })
temp!: number[];
}
@ObjectType({ implements: () => Node })
export class CpuUtilization extends Node {
@Field(() => Float, { description: 'Total CPU load in percent' })
@@ -100,4 +112,12 @@ export class InfoCpu extends Node {
@Field(() => [String], { nullable: true, description: 'CPU feature flags' })
flags?: string[];
@Field(() => [[[Int]]], {
description: 'Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]]',
})
topology!: number[][][];
@Field(() => CpuPackages)
packages!: CpuPackages;
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js';
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
@Module({
providers: [CpuService, CpuTopologyService],
exports: [CpuService, CpuTopologyService],
})
export class CpuModule {}

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js';
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
vi.mock('systeminformation', () => ({
@@ -88,9 +89,27 @@ vi.mock('systeminformation', () => ({
describe('CpuService', () => {
let service: CpuService;
let cpuTopologyService: CpuTopologyService;
beforeEach(() => {
service = new CpuService();
cpuTopologyService = {
generateTopology: vi.fn().mockResolvedValue([
[
[0, 1],
[2, 3],
],
[
[4, 5],
[6, 7],
],
]),
generateTelemetry: vi.fn().mockResolvedValue([
{ power: 32.5, temp: 45.0 },
{ power: 33.0, temp: 46.0 },
]),
} as unknown as CpuTopologyService;
service = new CpuService(cpuTopologyService);
});
describe('generateCpu', () => {
@@ -121,6 +140,22 @@ describe('CpuService', () => {
l3: 12582912,
},
flags: ['fpu', 'vme', 'de', 'pse', 'tsc', 'msr', 'pae', 'mce', 'cx8'],
packages: {
id: 'info/cpu/packages',
totalPower: 65.5,
power: [32.5, 33.0],
temp: [45.0, 46.0],
},
topology: [
[
[0, 1],
[2, 3],
],
[
[4, 5],
[6, 7],
],
],
});
});

View File

@@ -2,25 +2,56 @@ import { Injectable } from '@nestjs/common';
import { cpu, cpuFlags, currentLoad } from 'systeminformation';
import { CpuUtilization, InfoCpu } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js';
import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js';
import {
CpuPackages,
CpuUtilization,
InfoCpu,
} from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js';
@Injectable()
export class CpuService {
constructor(private readonly cpuTopologyService: CpuTopologyService) {}
async generateCpu(): Promise<InfoCpu> {
const { cores, physicalCores, speedMin, speedMax, stepping, ...rest } = await cpu();
const { cores, physicalCores, speedMin, speedMax, stepping, processors, ...rest } = await cpu();
const flags = await cpuFlags()
.then((flags) => flags.split(' '))
.then((f) => f.split(' '))
.catch(() => []);
// Gather telemetry
const packageList = await this.cpuTopologyService.generateTelemetry();
const topology = await this.cpuTopologyService.generateTopology();
// Compute total power (2 decimals)
const totalPower = Number(
packageList
.map((pkg) => pkg.power)
.filter((power) => power >= 0)
.reduce((sum, power) => sum + power, 0)
.toFixed(2)
);
// Build CpuPackages object
const packages: CpuPackages = {
id: 'info/cpu/packages',
totalPower,
power: packageList.map((pkg) => pkg.power ?? -1),
temp: packageList.map((pkg) => pkg.temp ?? -1),
};
return {
id: 'info/cpu',
...rest,
cores: physicalCores,
threads: cores,
processors,
flags,
stepping: Number(stepping),
speedmin: speedMin || -1,
speedmax: speedMax || -1,
packages,
topology,
};
}

View File

@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { CpuModule } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.module.js';
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
import { DevicesResolver } from '@app/unraid-api/graph/resolvers/info/devices/devices.resolver.js';
import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices/devices.service.js';
@@ -14,7 +15,7 @@ import { VersionsService } from '@app/unraid-api/graph/resolvers/info/versions/v
import { ServicesModule } from '@app/unraid-api/graph/services/services.module.js';
@Module({
imports: [ConfigModule, ServicesModule],
imports: [ConfigModule, ServicesModule, CpuModule],
providers: [
// Main resolver
InfoResolver,
@@ -25,7 +26,6 @@ import { ServicesModule } from '@app/unraid-api/graph/services/services.module.j
CoreVersionsResolver,
// Services
CpuService,
MemoryService,
DevicesService,
OsService,

View File

@@ -6,6 +6,7 @@ import { Test } from '@nestjs/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js';
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
import { DevicesResolver } from '@app/unraid-api/graph/resolvers/info/devices/devices.resolver.js';
import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices/devices.service.js';
@@ -28,6 +29,7 @@ describe('InfoResolver Integration Tests', () => {
InfoResolver,
DevicesResolver,
CpuService,
CpuTopologyService,
MemoryService,
DevicesService,
OsService,

View File

@@ -1,13 +1,13 @@
import { Module } from '@nestjs/common';
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
import { CpuModule } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.module.js';
import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js';
import { MetricsResolver } from '@app/unraid-api/graph/resolvers/metrics/metrics.resolver.js';
import { ServicesModule } from '@app/unraid-api/graph/services/services.module.js';
@Module({
imports: [ServicesModule],
providers: [MetricsResolver, CpuService, MemoryService],
imports: [ServicesModule, CpuModule],
providers: [MetricsResolver, MemoryService],
exports: [MetricsResolver],
})
export class MetricsModule {}

View File

@@ -5,6 +5,7 @@ import { Test } from '@nestjs/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js';
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js';
import { MetricsResolver } from '@app/unraid-api/graph/resolvers/metrics/metrics.resolver.js';
@@ -22,6 +23,7 @@ describe('MetricsResolver Integration Tests', () => {
providers: [
MetricsResolver,
CpuService,
CpuTopologyService,
MemoryService,
SubscriptionTrackerService,
SubscriptionHelperService,

View File

@@ -3,6 +3,7 @@ import { Test } from '@nestjs/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js';
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js';
import { MetricsResolver } from '@app/unraid-api/graph/resolvers/metrics/metrics.resolver.js';
@@ -18,6 +19,7 @@ describe('MetricsResolver', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
MetricsResolver,
CpuTopologyService,
{
provide: CpuService,
useValue: {
@@ -161,8 +163,14 @@ describe('MetricsResolver', () => {
registerTopic: vi.fn(),
};
const cpuTopologyServiceMock = {
generateTopology: vi.fn(),
generateTelemetry: vi.fn().mockResolvedValue([{ id: 0, power: 42.5, temp: 68.3 }]),
} satisfies Pick<CpuTopologyService, 'generateTopology' | 'generateTelemetry'>;
const testModule = new MetricsResolver(
cpuService,
cpuTopologyServiceMock as unknown as CpuTopologyService,
memoryService,
subscriptionTracker as any,
{} as any
@@ -170,7 +178,7 @@ describe('MetricsResolver', () => {
testModule.onModuleInit();
expect(subscriptionTracker.registerTopic).toHaveBeenCalledTimes(2);
expect(subscriptionTracker.registerTopic).toHaveBeenCalledTimes(3);
expect(subscriptionTracker.registerTopic).toHaveBeenCalledWith(
'CPU_UTILIZATION',
expect.any(Function),

View File

@@ -1,11 +1,12 @@
import { OnModuleInit } from '@nestjs/common';
import { Logger, OnModuleInit } from '@nestjs/common';
import { Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { CpuUtilization } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js';
import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js';
import { CpuPackages, CpuUtilization } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js';
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
import { MemoryUtilization } from '@app/unraid-api/graph/resolvers/info/memory/memory.model.js';
import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js';
@@ -15,8 +16,10 @@ import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subsc
@Resolver(() => Metrics)
export class MetricsResolver implements OnModuleInit {
private readonly logger = new Logger(MetricsResolver.name);
constructor(
private readonly cpuService: CpuService,
private readonly cpuTopologyService: CpuTopologyService,
private readonly memoryService: MemoryService,
private readonly subscriptionTracker: SubscriptionTrackerService,
private readonly subscriptionHelper: SubscriptionHelperService
@@ -33,6 +36,38 @@ export class MetricsResolver implements OnModuleInit {
1000
);
this.subscriptionTracker.registerTopic(
PUBSUB_CHANNEL.CPU_TELEMETRY,
async () => {
const packageList = (await this.cpuTopologyService.generateTelemetry()) ?? [];
// Compute total power with 2 decimals
const totalPower = Number(
packageList
.map((pkg) => pkg.power)
.filter((power) => power >= 0)
.reduce((sum, power) => sum + power, 0)
.toFixed(2)
);
const packages: CpuPackages = {
id: 'metrics/cpu/packages',
totalPower,
power: packageList.map((pkg) => pkg.power ?? -1),
temp: packageList.map((pkg) => pkg.temp ?? -1),
};
this.logger.debug(`CPU_TELEMETRY payload: ${JSON.stringify(packages)}`);
// Publish the payload
pubsub.publish(PUBSUB_CHANNEL.CPU_TELEMETRY, {
systemMetricsCpuTelemetry: packages,
});
this.logger.debug(`CPU_TELEMETRY payload2: ${JSON.stringify(packages)}`);
},
5000
);
// Register memory polling with 2 second interval
this.subscriptionTracker.registerTopic(
PUBSUB_CHANNEL.MEMORY_UTILIZATION,
@@ -77,6 +112,18 @@ export class MetricsResolver implements OnModuleInit {
return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION);
}
@Subscription(() => CpuPackages, {
name: 'systemMetricsCpuTelemetry',
resolve: (value) => value.systemMetricsCpuTelemetry,
})
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.INFO,
})
public async systemMetricsCpuTelemetrySubscription() {
return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.CPU_TELEMETRY);
}
@Subscription(() => MemoryUtilization, {
name: 'systemMetricsMemory',
resolve: (value) => value.systemMetricsMemory,

View File

@@ -0,0 +1,68 @@
#!/bin/bash
# Arguments
# $1: SSH server name (required)
# Check if the server name is provided
if [[ -z "$1" ]]; then
echo "Error: SSH server name is required."
echo "Usage: $0 <server_name>"
exit 1
fi
# Set server name from command-line argument
server_name="$1"
# Build the package
echo "Building unraid-shared package..."
pnpm build
if [ $? -ne 0 ]; then
echo "Build failed!"
exit 1
fi
# Source directory path
source_directory="./dist"
# Check if dist directory exists
if [ ! -d "$source_directory" ]; then
echo "The dist directory does not exist after build!"
exit 1
fi
# Destination directory path - deploy to node_modules/@unraid/shared/dist
destination_directory="/usr/local/unraid-api/node_modules/@unraid/shared"
# Create destination directory on remote server
ssh root@"${server_name}" "mkdir -p $destination_directory"
# Replace the value inside the rsync command with the user's input
rsync_command="rsync -avz --delete --progress --stats -e ssh \"$source_directory/\" \"root@${server_name}:$destination_directory/\""
echo "Executing the following command:"
echo "$rsync_command"
# Execute the rsync command and capture the exit code
eval "$rsync_command"
exit_code=$?
# Chown the directory
ssh root@"${server_name}" "chown -R root:root /usr/local/unraid-api/node_modules/@unraid/"
# Run unraid-api restart on remote host
ssh root@"${server_name}" 'INTROSPECTION=true LOG_LEVEL=trace unraid-api restart'
# Play built-in sound based on the operating system
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
afplay /System/Library/Sounds/Glass.aiff
elif [[ "$OSTYPE" == "linux-gnu" ]]; then
# Linux
paplay /usr/share/sounds/freedesktop/stereo/complete.oga
elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" ]]; then
# Windows
powershell.exe -c "(New-Object Media.SoundPlayer 'C:\Windows\Media\Windows Default.wav').PlaySync()"
fi
# Exit with the rsync command's exit code
exit $exit_code

View File

@@ -21,7 +21,8 @@
"build": "rimraf dist && tsc --project tsconfig.build.json",
"prepare": "npm run build",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"unraid:deploy": "./deploy.sh"
},
"keywords": [],
"author": "Lime Technology, Inc. <unraid.net>",

View File

@@ -5,6 +5,7 @@ export const GRAPHQL_PUBSUB_TOKEN = "GRAPHQL_PUBSUB";
export enum GRAPHQL_PUBSUB_CHANNEL {
ARRAY = "ARRAY",
CPU_UTILIZATION = "CPU_UTILIZATION",
CPU_TELEMETRY = "CPU_TELEMETRY",
DASHBOARD = "DASHBOARD",
DISPLAY = "DISPLAY",
INFO = "INFO",