mirror of
https://github.com/unraid/api.git
synced 2025-12-31 13:39:52 -06:00
feat: add ups monitoring to graphql api (#1526)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced UPS management capabilities, including queries and mutations for UPS device status, configuration, and live updates via GraphQL. * Added support for configuring UPS parameters such as service state, cable type, communication protocol, shutdown thresholds, and power control options. * Provided detailed UPS device information including battery, power, and operational status. * **Tests** * Added comprehensive tests for UPS resolver and service logic, covering configuration, event publishing, killpower functionality, and error handling. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
This commit is contained in:
@@ -27,6 +27,7 @@ import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.modu
|
||||
import { RegistrationResolver } from '@app/unraid-api/graph/resolvers/registration/registration.resolver.js';
|
||||
import { ServerResolver } from '@app/unraid-api/graph/resolvers/servers/server.resolver.js';
|
||||
import { SettingsModule } from '@app/unraid-api/graph/resolvers/settings/settings.module.js';
|
||||
import { UPSModule } from '@app/unraid-api/graph/resolvers/ups/ups.module.js';
|
||||
import { VarsResolver } from '@app/unraid-api/graph/resolvers/vars/vars.resolver.js';
|
||||
import { VmMutationsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.mutations.resolver.js';
|
||||
import { VmsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.resolver.js';
|
||||
@@ -46,6 +47,7 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js';
|
||||
FlashBackupModule,
|
||||
RCloneModule,
|
||||
SettingsModule,
|
||||
UPSModule,
|
||||
],
|
||||
providers: [
|
||||
ConfigResolver,
|
||||
|
||||
138
api/src/unraid-api/graph/resolvers/ups/ups.inputs.ts
Normal file
138
api/src/unraid-api/graph/resolvers/ups/ups.inputs.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Field, InputType, Int, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { Max, Min } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Service state for UPS daemon
|
||||
*/
|
||||
export enum UPSServiceState {
|
||||
ENABLE = 'enable',
|
||||
DISABLE = 'disable',
|
||||
}
|
||||
|
||||
/**
|
||||
* UPS cable types
|
||||
*/
|
||||
export enum UPSCableType {
|
||||
USB = 'usb',
|
||||
SIMPLE = 'simple',
|
||||
SMART = 'smart',
|
||||
ETHER = 'ether',
|
||||
CUSTOM = 'custom',
|
||||
}
|
||||
|
||||
/**
|
||||
* UPS communication types
|
||||
*/
|
||||
export enum UPSType {
|
||||
USB = 'usb',
|
||||
APCSMART = 'apcsmart',
|
||||
NET = 'net',
|
||||
SNMP = 'snmp',
|
||||
DUMB = 'dumb',
|
||||
PCNET = 'pcnet',
|
||||
MODBUS = 'modbus',
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill UPS power after shutdown option
|
||||
*/
|
||||
export enum UPSKillPower {
|
||||
YES = 'yes',
|
||||
NO = 'no',
|
||||
}
|
||||
|
||||
// Register enums with GraphQL
|
||||
registerEnumType(UPSServiceState, {
|
||||
name: 'UPSServiceState',
|
||||
description: 'Service state for UPS daemon',
|
||||
});
|
||||
|
||||
registerEnumType(UPSCableType, {
|
||||
name: 'UPSCableType',
|
||||
description: 'UPS cable connection types',
|
||||
});
|
||||
|
||||
registerEnumType(UPSType, {
|
||||
name: 'UPSType',
|
||||
description: 'UPS communication protocols',
|
||||
});
|
||||
|
||||
registerEnumType(UPSKillPower, {
|
||||
name: 'UPSKillPower',
|
||||
description: 'Kill UPS power after shutdown option',
|
||||
});
|
||||
|
||||
@InputType()
|
||||
export class UPSConfigInput {
|
||||
@Field(() => UPSServiceState, {
|
||||
nullable: true,
|
||||
description: 'Enable or disable the UPS monitoring service',
|
||||
})
|
||||
service?: UPSServiceState;
|
||||
|
||||
@Field(() => UPSCableType, {
|
||||
nullable: true,
|
||||
description: 'Type of cable connecting the UPS to the server',
|
||||
})
|
||||
upsCable?: UPSCableType;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description:
|
||||
'Custom cable configuration (only used when upsCable is CUSTOM). Format depends on specific UPS model',
|
||||
})
|
||||
customUpsCable?: string;
|
||||
|
||||
@Field(() => UPSType, {
|
||||
nullable: true,
|
||||
description: 'UPS communication protocol',
|
||||
})
|
||||
upsType?: UPSType;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description:
|
||||
"Device path or network address for UPS connection. Examples: '/dev/ttyUSB0' for USB, '192.168.1.100:3551' for network",
|
||||
})
|
||||
device?: string;
|
||||
|
||||
@Field(() => Int, {
|
||||
nullable: true,
|
||||
description:
|
||||
'Override UPS capacity for runtime calculations. Unit: watts (W). Leave unset to use UPS-reported capacity',
|
||||
})
|
||||
@Min(0, { message: 'Override UPS capacity must be a positive number' })
|
||||
overrideUpsCapacity?: number;
|
||||
|
||||
@Field(() => Int, {
|
||||
nullable: true,
|
||||
description:
|
||||
'Battery level percentage to initiate shutdown. Unit: percent (%) - Valid range: 0-100',
|
||||
})
|
||||
@Min(0, { message: 'Battery level must be between 0 and 100' })
|
||||
@Max(100, { message: 'Battery level must be between 0 and 100' })
|
||||
batteryLevel?: number;
|
||||
|
||||
@Field(() => Int, {
|
||||
nullable: true,
|
||||
description: 'Runtime left in minutes to initiate shutdown. Unit: minutes',
|
||||
})
|
||||
@Min(0, { message: 'Minutes must be 0 or greater' })
|
||||
minutes?: number;
|
||||
|
||||
@Field(() => Int, {
|
||||
nullable: true,
|
||||
description:
|
||||
'Time on battery before shutdown. Unit: seconds. Set to 0 to disable timeout-based shutdown',
|
||||
})
|
||||
@Min(0, { message: 'Timeout must be 0 or greater (0 disables timeout-based shutdown)' })
|
||||
timeout?: number;
|
||||
|
||||
@Field(() => UPSKillPower, {
|
||||
nullable: true,
|
||||
description:
|
||||
'Turn off UPS power after system shutdown. Useful for ensuring complete power cycle',
|
||||
})
|
||||
killUps?: UPSKillPower;
|
||||
}
|
||||
171
api/src/unraid-api/graph/resolvers/ups/ups.model.ts
Normal file
171
api/src/unraid-api/graph/resolvers/ups/ups.model.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Field, Float, ID, Int, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class UPSBattery {
|
||||
@Field(() => Int, {
|
||||
description:
|
||||
'Battery charge level as a percentage (0-100). Unit: percent (%). Example: 100 means battery is fully charged',
|
||||
})
|
||||
chargeLevel!: number;
|
||||
|
||||
@Field(() => Int, {
|
||||
description:
|
||||
'Estimated runtime remaining on battery power. Unit: seconds. Example: 3600 means 1 hour of runtime remaining',
|
||||
})
|
||||
estimatedRuntime!: number;
|
||||
|
||||
@Field({
|
||||
description:
|
||||
"Battery health status. Possible values: 'Good', 'Replace', 'Unknown'. Indicates if the battery needs replacement",
|
||||
})
|
||||
health!: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class UPSPower {
|
||||
@Field(() => Float, {
|
||||
description:
|
||||
'Input voltage from the wall outlet/mains power. Unit: volts (V). Example: 120.5 for typical US household voltage',
|
||||
})
|
||||
inputVoltage!: number;
|
||||
|
||||
@Field(() => Float, {
|
||||
description:
|
||||
'Output voltage being delivered to connected devices. Unit: volts (V). Example: 120.5 - should match input voltage when on mains power',
|
||||
})
|
||||
outputVoltage!: number;
|
||||
|
||||
@Field(() => Int, {
|
||||
description:
|
||||
'Current load on the UPS as a percentage of its capacity. Unit: percent (%). Example: 25 means UPS is loaded at 25% of its maximum capacity',
|
||||
})
|
||||
loadPercentage!: number;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class UPSDevice {
|
||||
@Field(() => ID, {
|
||||
description:
|
||||
'Unique identifier for the UPS device. Usually based on the model name or a generated ID',
|
||||
})
|
||||
id!: string;
|
||||
|
||||
@Field({ description: 'Display name for the UPS device. Can be customized by the user' })
|
||||
name!: string;
|
||||
|
||||
@Field({ description: "UPS model name/number. Example: 'APC Back-UPS Pro 1500'" })
|
||||
model!: string;
|
||||
|
||||
@Field({
|
||||
description:
|
||||
"Current operational status of the UPS. Common values: 'Online', 'On Battery', 'Low Battery', 'Replace Battery', 'Overload', 'Offline'. 'Online' means running on mains power, 'On Battery' means running on battery backup",
|
||||
})
|
||||
status!: string;
|
||||
|
||||
@Field(() => UPSBattery, { description: 'Battery-related information' })
|
||||
battery!: UPSBattery;
|
||||
|
||||
@Field(() => UPSPower, { description: 'Power-related information' })
|
||||
power!: UPSPower;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class UPSConfiguration {
|
||||
@Field({
|
||||
nullable: true,
|
||||
description:
|
||||
"UPS service state. Values: 'enable' or 'disable'. Controls whether the UPS monitoring service is running",
|
||||
})
|
||||
service?: string;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description:
|
||||
"Type of cable connecting the UPS to the server. Common values: 'usb', 'smart', 'ether', 'custom'. Determines communication protocol",
|
||||
})
|
||||
upsCable?: string;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description:
|
||||
"Custom cable configuration string. Only used when upsCable is set to 'custom'. Format depends on specific UPS model",
|
||||
})
|
||||
customUpsCable?: string;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description:
|
||||
"UPS communication type. Common values: 'usb', 'net', 'snmp', 'dumb', 'pcnet', 'modbus'. Defines how the server communicates with the UPS",
|
||||
})
|
||||
upsType?: string;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description:
|
||||
"Device path or network address for UPS connection. Examples: '/dev/ttyUSB0' for USB, '192.168.1.100:3551' for network. Depends on upsType setting",
|
||||
})
|
||||
device?: string;
|
||||
|
||||
@Field(() => Int, {
|
||||
nullable: true,
|
||||
description:
|
||||
'Override UPS capacity for runtime calculations. Unit: volt-amperes (VA). Example: 1500 for a 1500VA UPS. Leave unset to use UPS-reported capacity',
|
||||
})
|
||||
overrideUpsCapacity?: number;
|
||||
|
||||
@Field(() => Int, {
|
||||
nullable: true,
|
||||
description:
|
||||
'Battery level threshold for shutdown. Unit: percent (%). Example: 10 means shutdown when battery reaches 10%. System will shutdown when battery drops to this level',
|
||||
})
|
||||
batteryLevel?: number;
|
||||
|
||||
@Field(() => Int, {
|
||||
nullable: true,
|
||||
description:
|
||||
'Runtime threshold for shutdown. Unit: minutes. Example: 5 means shutdown when 5 minutes runtime remaining. System will shutdown when estimated runtime drops below this',
|
||||
})
|
||||
minutes?: number;
|
||||
|
||||
@Field(() => Int, {
|
||||
nullable: true,
|
||||
description:
|
||||
'Timeout for UPS communications. Unit: seconds. Example: 0 means no timeout. Time to wait for UPS response before considering it offline',
|
||||
})
|
||||
timeout?: number;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description:
|
||||
"Kill UPS power after shutdown. Values: 'yes' or 'no'. If 'yes', tells UPS to cut power after system shutdown. Useful for ensuring complete power cycle",
|
||||
})
|
||||
killUps?: string;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description:
|
||||
"Network Information Server (NIS) IP address. Default: '0.0.0.0' (listen on all interfaces). IP address for apcupsd network information server",
|
||||
})
|
||||
nisIp?: string;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description:
|
||||
"Network server mode. Values: 'on' or 'off'. Enable to allow network clients to monitor this UPS",
|
||||
})
|
||||
netServer?: string;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description:
|
||||
"UPS name for network monitoring. Used to identify this UPS on the network. Example: 'SERVER_UPS'",
|
||||
})
|
||||
upsName?: string;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description:
|
||||
'Override UPS model name. Used for display purposes. Leave unset to use UPS-reported model',
|
||||
})
|
||||
modelName?: string;
|
||||
}
|
||||
11
api/src/unraid-api/graph/resolvers/ups/ups.module.ts
Normal file
11
api/src/unraid-api/graph/resolvers/ups/ups.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PubSub } from 'graphql-subscriptions';
|
||||
|
||||
import { UPSResolver } from '@app/unraid-api/graph/resolvers/ups/ups.resolver.js';
|
||||
import { UPSService } from '@app/unraid-api/graph/resolvers/ups/ups.service.js';
|
||||
|
||||
@Module({
|
||||
providers: [UPSResolver, UPSService, { provide: PubSub, useValue: new PubSub() }],
|
||||
})
|
||||
export class UPSModule {}
|
||||
114
api/src/unraid-api/graph/resolvers/ups/ups.resolver.spec.ts
Normal file
114
api/src/unraid-api/graph/resolvers/ups/ups.resolver.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { PubSub } from 'graphql-subscriptions';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
UPSCableType,
|
||||
UPSConfigInput,
|
||||
UPSKillPower,
|
||||
UPSServiceState,
|
||||
UPSType,
|
||||
} from '@app/unraid-api/graph/resolvers/ups/ups.inputs.js';
|
||||
import { UPSResolver } from '@app/unraid-api/graph/resolvers/ups/ups.resolver.js';
|
||||
import { UPSData, UPSService } from '@app/unraid-api/graph/resolvers/ups/ups.service.js';
|
||||
|
||||
describe('UPSResolver', () => {
|
||||
let resolver: UPSResolver;
|
||||
let service: UPSService;
|
||||
let pubSub: PubSub;
|
||||
|
||||
const mockUPSData: UPSData = {
|
||||
MODEL: 'Test UPS',
|
||||
STATUS: 'Online',
|
||||
BCHARGE: '100',
|
||||
TIMELEFT: '3600',
|
||||
LINEV: '120.5',
|
||||
OUTPUTV: '120.5',
|
||||
LOADPCT: '25',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
UPSResolver,
|
||||
{
|
||||
provide: UPSService,
|
||||
useValue: {
|
||||
getUPSData: vi.fn().mockResolvedValue(mockUPSData),
|
||||
configureUPS: vi.fn().mockResolvedValue(undefined),
|
||||
getCurrentConfig: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PubSub,
|
||||
useValue: {
|
||||
publish: vi.fn(),
|
||||
asyncIterableIterator: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
resolver = module.get<UPSResolver>(UPSResolver);
|
||||
service = module.get<UPSService>(UPSService);
|
||||
pubSub = module.get<PubSub>(PubSub);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(resolver).toBeDefined();
|
||||
});
|
||||
|
||||
describe('upsDevices', () => {
|
||||
it('should return an array of UPS devices', async () => {
|
||||
const result = await resolver.upsDevices();
|
||||
expect(result).toBeInstanceOf(Array);
|
||||
expect(result[0].model).toBe('Test UPS');
|
||||
expect(service.getUPSData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('configureUps', () => {
|
||||
it('should call the configureUPS service method with full config and return true', async () => {
|
||||
const config: UPSConfigInput = {
|
||||
service: UPSServiceState.ENABLE,
|
||||
upsCable: UPSCableType.USB,
|
||||
upsType: UPSType.USB,
|
||||
batteryLevel: 10,
|
||||
minutes: 5,
|
||||
timeout: 0,
|
||||
killUps: UPSKillPower.NO,
|
||||
};
|
||||
const result = await resolver.configureUps(config);
|
||||
expect(result).toBe(true);
|
||||
expect(service.configureUPS).toHaveBeenCalledWith(config);
|
||||
expect(pubSub.publish).toHaveBeenCalledWith('upsUpdates', expect.any(Object));
|
||||
});
|
||||
|
||||
it('should handle partial config updates', async () => {
|
||||
const partialConfig: UPSConfigInput = {
|
||||
batteryLevel: 15,
|
||||
minutes: 10,
|
||||
};
|
||||
const result = await resolver.configureUps(partialConfig);
|
||||
expect(result).toBe(true);
|
||||
expect(service.configureUPS).toHaveBeenCalledWith(partialConfig);
|
||||
expect(pubSub.publish).toHaveBeenCalledWith('upsUpdates', expect.any(Object));
|
||||
});
|
||||
|
||||
it('should handle empty config updates', async () => {
|
||||
const emptyConfig: UPSConfigInput = {};
|
||||
const result = await resolver.configureUps(emptyConfig);
|
||||
expect(result).toBe(true);
|
||||
expect(service.configureUPS).toHaveBeenCalledWith(emptyConfig);
|
||||
expect(pubSub.publish).toHaveBeenCalledWith('upsUpdates', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsUpdates', () => {
|
||||
it('should return an async iterator', () => {
|
||||
resolver.upsUpdates();
|
||||
expect(pubSub.asyncIterableIterator).toHaveBeenCalledWith('upsUpdates');
|
||||
});
|
||||
});
|
||||
});
|
||||
86
api/src/unraid-api/graph/resolvers/ups/ups.resolver.ts
Normal file
86
api/src/unraid-api/graph/resolvers/ups/ups.resolver.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Args, Mutation, Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { PubSub } from 'graphql-subscriptions';
|
||||
|
||||
import { UPSConfigInput } from '@app/unraid-api/graph/resolvers/ups/ups.inputs.js';
|
||||
import { UPSConfiguration, UPSDevice } from '@app/unraid-api/graph/resolvers/ups/ups.model.js';
|
||||
import { UPSData, UPSService } from '@app/unraid-api/graph/resolvers/ups/ups.service.js';
|
||||
|
||||
@Resolver(() => UPSDevice)
|
||||
export class UPSResolver {
|
||||
constructor(
|
||||
private readonly upsService: UPSService,
|
||||
private readonly pubSub: PubSub
|
||||
) {}
|
||||
|
||||
private createUPSDevice(upsData: UPSData, id: string): UPSDevice {
|
||||
return {
|
||||
id,
|
||||
name: upsData.MODEL || 'My UPS',
|
||||
model: upsData.MODEL || 'APC Back-UPS Pro 1500',
|
||||
status: upsData.STATUS || 'Online',
|
||||
battery: {
|
||||
chargeLevel: parseInt(upsData.BCHARGE || '100', 10),
|
||||
estimatedRuntime: parseInt(upsData.TIMELEFT || '3600', 10),
|
||||
health: 'Good',
|
||||
},
|
||||
power: {
|
||||
inputVoltage: parseFloat(upsData.LINEV || '120.5'),
|
||||
outputVoltage: parseFloat(upsData.OUTPUTV || '120.5'),
|
||||
loadPercentage: parseInt(upsData.LOADPCT || '25', 10),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Query(() => [UPSDevice])
|
||||
async upsDevices(): Promise<UPSDevice[]> {
|
||||
const upsData = await this.upsService.getUPSData();
|
||||
// Assuming single UPS for now, but this could be expanded to support multiple devices
|
||||
return [this.createUPSDevice(upsData, upsData.MODEL || 'ups1')];
|
||||
}
|
||||
|
||||
@Query(() => UPSDevice, { nullable: true })
|
||||
async upsDeviceById(@Args('id') id: string): Promise<UPSDevice | null> {
|
||||
const upsData = await this.upsService.getUPSData();
|
||||
const deviceId = upsData.MODEL || 'ups1';
|
||||
if (id === deviceId) {
|
||||
return this.createUPSDevice(upsData, id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Query(() => UPSConfiguration)
|
||||
async upsConfiguration(): Promise<UPSConfiguration> {
|
||||
const config = await this.upsService.getCurrentConfig();
|
||||
return {
|
||||
service: config.SERVICE,
|
||||
upsCable: config.UPSCABLE,
|
||||
customUpsCable: config.CUSTOMUPSCABLE,
|
||||
upsType: config.UPSTYPE,
|
||||
device: config.DEVICE,
|
||||
overrideUpsCapacity: config.OVERRIDE_UPS_CAPACITY,
|
||||
batteryLevel: config.BATTERYLEVEL,
|
||||
minutes: config.MINUTES,
|
||||
timeout: config.TIMEOUT,
|
||||
killUps: config.KILLUPS,
|
||||
nisIp: config.NISIP,
|
||||
netServer: config.NETSERVER,
|
||||
upsName: config.UPSNAME,
|
||||
modelName: config.MODELNAME,
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async configureUps(@Args('config') config: UPSConfigInput): Promise<boolean> {
|
||||
await this.upsService.configureUPS(config);
|
||||
const updatedData = await this.upsService.getUPSData();
|
||||
const newDevice = this.createUPSDevice(updatedData, updatedData.MODEL || 'ups1');
|
||||
this.pubSub.publish('upsUpdates', { upsUpdates: newDevice });
|
||||
return true;
|
||||
}
|
||||
|
||||
@Subscription(() => UPSDevice)
|
||||
upsUpdates() {
|
||||
return this.pubSub.asyncIterableIterator('upsUpdates');
|
||||
}
|
||||
}
|
||||
462
api/src/unraid-api/graph/resolvers/ups/ups.service.spec.ts
Normal file
462
api/src/unraid-api/graph/resolvers/ups/ups.service.spec.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { mkdtemp, readFile, rm, unlink, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
UPSCableType,
|
||||
UPSConfigInput,
|
||||
UPSKillPower,
|
||||
UPSServiceState,
|
||||
UPSType,
|
||||
} from '@app/unraid-api/graph/resolvers/ups/ups.inputs.js';
|
||||
import { UPSService } from '@app/unraid-api/graph/resolvers/ups/ups.service.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('execa');
|
||||
vi.mock('@app/core/utils/files/file-exists.js');
|
||||
|
||||
const mockExeca = vi.mocked((await import('execa')).execa);
|
||||
const mockFileExists = vi.mocked((await import('@app/core/utils/files/file-exists.js')).fileExists);
|
||||
|
||||
describe('UPSService', () => {
|
||||
let service: UPSService;
|
||||
let tempDir: string;
|
||||
let configPath: string;
|
||||
let backupPath: string;
|
||||
|
||||
const mockCurrentConfig = {
|
||||
SERVICE: 'disable',
|
||||
UPSCABLE: 'usb',
|
||||
UPSTYPE: 'usb',
|
||||
DEVICE: '/dev/ttyUSB0',
|
||||
BATTERYLEVEL: 20,
|
||||
MINUTES: 15,
|
||||
TIMEOUT: 30,
|
||||
KILLUPS: 'no',
|
||||
NISIP: '0.0.0.0',
|
||||
NETSERVER: 'off',
|
||||
UPSNAME: 'MyUPS',
|
||||
MODELNAME: 'APC UPS',
|
||||
};
|
||||
|
||||
// Helper to create config file content
|
||||
const createConfigContent = (config: Record<string, any>): string => {
|
||||
const lines = ['# APC UPS Configuration File'];
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (value !== undefined) {
|
||||
lines.push(
|
||||
`${key} ${typeof value === 'string' && value.includes(' ') ? `"${value}"` : value}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return lines.join('\n') + '\n';
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create temporary directory for test files
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ups-test-'));
|
||||
configPath = join(tempDir, 'apcupsd.conf');
|
||||
backupPath = `${configPath}.backup`;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [UPSService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<UPSService>(UPSService);
|
||||
|
||||
// Override the config path to use our temp directory
|
||||
Object.defineProperty(service, 'configPath', {
|
||||
value: configPath,
|
||||
writable: false,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Mock logger methods on the service instance
|
||||
vi.spyOn(service['logger'], 'debug').mockImplementation(() => {});
|
||||
vi.spyOn(service['logger'], 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(service['logger'], 'error').mockImplementation(() => {});
|
||||
|
||||
// Default mocks
|
||||
mockFileExists.mockImplementation(async (path) => {
|
||||
if (path === configPath) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as any);
|
||||
|
||||
// Create initial config file
|
||||
await writeFile(configPath, createConfigContent(mockCurrentConfig));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up temp directory
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('configureUPS', () => {
|
||||
it('should merge partial config with existing values', async () => {
|
||||
const partialConfig: UPSConfigInput = {
|
||||
batteryLevel: 25,
|
||||
minutes: 10,
|
||||
};
|
||||
|
||||
await service.configureUPS(partialConfig);
|
||||
|
||||
// Read the written config file
|
||||
const writtenConfig = await readFile(configPath, 'utf-8');
|
||||
|
||||
// Should preserve existing values for fields not provided
|
||||
expect(writtenConfig).toContain('SERVICE disable'); // preserved from existing
|
||||
expect(writtenConfig).toContain('UPSTYPE usb'); // preserved from existing
|
||||
expect(writtenConfig).toContain('BATTERYLEVEL 25'); // updated value
|
||||
expect(writtenConfig).toContain('MINUTES 10'); // updated value
|
||||
expect(writtenConfig).toContain('UPSNAME MyUPS'); // preserved
|
||||
expect(writtenConfig).toContain('DEVICE /dev/ttyUSB0'); // preserved
|
||||
});
|
||||
|
||||
it('should use default values when neither input nor existing config provide values', async () => {
|
||||
// Write empty config file
|
||||
await writeFile(configPath, '# Empty config\n');
|
||||
|
||||
const partialConfig: UPSConfigInput = {
|
||||
service: UPSServiceState.ENABLE,
|
||||
};
|
||||
|
||||
await service.configureUPS(partialConfig);
|
||||
|
||||
const writtenConfig = await readFile(configPath, 'utf-8');
|
||||
|
||||
expect(writtenConfig).toContain('SERVICE enable'); // provided value
|
||||
expect(writtenConfig).toContain('UPSTYPE usb'); // default value
|
||||
expect(writtenConfig).toContain('BATTERYLEVEL 10'); // default value
|
||||
expect(writtenConfig).toContain('MINUTES 5'); // default value
|
||||
});
|
||||
|
||||
it('should handle custom cable configuration', async () => {
|
||||
const config: UPSConfigInput = {
|
||||
upsCable: UPSCableType.CUSTOM,
|
||||
customUpsCable: 'custom-config-string',
|
||||
};
|
||||
|
||||
await service.configureUPS(config);
|
||||
|
||||
const writtenConfig = await readFile(configPath, 'utf-8');
|
||||
expect(writtenConfig).toContain('UPSCABLE custom-config-string');
|
||||
});
|
||||
|
||||
it('should validate required fields after merging', async () => {
|
||||
// Write config without device field
|
||||
await writeFile(
|
||||
configPath,
|
||||
createConfigContent({
|
||||
SERVICE: 'disable',
|
||||
UPSTYPE: 'usb',
|
||||
})
|
||||
);
|
||||
|
||||
const config: UPSConfigInput = {
|
||||
upsType: UPSType.NET, // requires device
|
||||
// device not provided and not in existing config
|
||||
};
|
||||
|
||||
await expect(service.configureUPS(config)).rejects.toThrow(
|
||||
'device is required for non-USB UPS types'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle killpower configuration for enable + yes', async () => {
|
||||
// Create a mock rc.6 file for this test
|
||||
const mockRc6Path = join(tempDir, 'mock-rc.6');
|
||||
await writeFile(mockRc6Path, '/sbin/poweroff', 'utf-8');
|
||||
service['rc6Path'] = mockRc6Path;
|
||||
|
||||
// Update mock to indicate rc6 file exists
|
||||
mockFileExists.mockImplementation(async (path) => {
|
||||
if (path === configPath || path === mockRc6Path) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const config: UPSConfigInput = {
|
||||
service: UPSServiceState.ENABLE,
|
||||
killUps: UPSKillPower.YES,
|
||||
};
|
||||
|
||||
await service.configureUPS(config);
|
||||
|
||||
// Should have modified the rc.6 file
|
||||
const rc6Content = await readFile(mockRc6Path, 'utf-8');
|
||||
expect(rc6Content).toContain('/etc/apcupsd/apccontrol killpower; /sbin/poweroff');
|
||||
|
||||
expect(mockExeca).toHaveBeenCalledWith('/etc/rc.d/rc.apcupsd', ['start'], {
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle killpower configuration for disable case', async () => {
|
||||
// Create a mock rc.6 file with killpower already enabled
|
||||
const mockRc6Path = join(tempDir, 'mock-rc.6-2');
|
||||
await writeFile(mockRc6Path, '/etc/apcupsd/apccontrol killpower; /sbin/poweroff', 'utf-8');
|
||||
service['rc6Path'] = mockRc6Path;
|
||||
|
||||
const config: UPSConfigInput = {
|
||||
service: UPSServiceState.DISABLE,
|
||||
killUps: UPSKillPower.YES, // should be ignored since service is disabled
|
||||
};
|
||||
|
||||
await service.configureUPS(config);
|
||||
|
||||
// Should NOT have modified the rc.6 file since service is disabled
|
||||
const rc6Content = await readFile(mockRc6Path, 'utf-8');
|
||||
expect(rc6Content).toContain('/etc/apcupsd/apccontrol killpower; /sbin/poweroff');
|
||||
});
|
||||
|
||||
it('should start service when enabled', async () => {
|
||||
const config: UPSConfigInput = {
|
||||
service: UPSServiceState.ENABLE,
|
||||
};
|
||||
|
||||
await service.configureUPS(config);
|
||||
|
||||
expect(mockExeca).toHaveBeenCalledWith('/etc/rc.d/rc.apcupsd', ['start'], {
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not start service when disabled', async () => {
|
||||
const config: UPSConfigInput = {
|
||||
service: UPSServiceState.DISABLE,
|
||||
};
|
||||
|
||||
await service.configureUPS(config);
|
||||
|
||||
// Should not call start
|
||||
expect(mockExeca).not.toHaveBeenCalledWith(
|
||||
'/etc/rc.d/rc.apcupsd',
|
||||
['start'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve existing config values not provided in input', async () => {
|
||||
const config: UPSConfigInput = {
|
||||
batteryLevel: 50, // only update battery level
|
||||
};
|
||||
|
||||
await service.configureUPS(config);
|
||||
|
||||
const configContent = await readFile(configPath, 'utf-8');
|
||||
|
||||
// Should preserve existing values in the generated format
|
||||
expect(configContent).toContain('UPSNAME MyUPS');
|
||||
expect(configContent).toContain('UPSCABLE usb');
|
||||
expect(configContent).toContain('UPSTYPE usb');
|
||||
expect(configContent).toContain('DEVICE /dev/ttyUSB0');
|
||||
expect(configContent).toContain('MINUTES 15'); // from existing config
|
||||
expect(configContent).toContain('TIMEOUT 30'); // from existing config
|
||||
expect(configContent).toContain('NETSERVER off');
|
||||
expect(configContent).toContain('NISIP 0.0.0.0');
|
||||
expect(configContent).toContain('MODELNAME "APC UPS"'); // Values with spaces get quoted
|
||||
|
||||
// Should update provided value
|
||||
expect(configContent).toContain('BATTERYLEVEL 50');
|
||||
|
||||
// Should preserve other values not in ordered list
|
||||
expect(configContent).toContain('SERVICE disable');
|
||||
expect(configContent).toContain('KILLUPS no');
|
||||
});
|
||||
|
||||
it('should create backup before writing new config', async () => {
|
||||
const originalContent = await readFile(configPath, 'utf-8');
|
||||
|
||||
const config: UPSConfigInput = {
|
||||
batteryLevel: 30,
|
||||
};
|
||||
|
||||
await service.configureUPS(config);
|
||||
|
||||
// Should create backup with original content
|
||||
const backupContent = await readFile(backupPath, 'utf-8');
|
||||
expect(backupContent).toBe(originalContent);
|
||||
|
||||
// Should write new config
|
||||
const newContent = await readFile(configPath, 'utf-8');
|
||||
expect(newContent).toContain('BATTERYLEVEL 30');
|
||||
expect(newContent).not.toBe(originalContent);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully and restore backup', async () => {
|
||||
const originalContent = await readFile(configPath, 'utf-8');
|
||||
|
||||
const config: UPSConfigInput = {
|
||||
batteryLevel: 30,
|
||||
};
|
||||
|
||||
// Temporarily override generateApcupsdConfig to throw error
|
||||
const originalGenerate = service['generateApcupsdConfig'].bind(service);
|
||||
service['generateApcupsdConfig'] = vi.fn().mockImplementation(() => {
|
||||
throw new Error('Generation failed');
|
||||
});
|
||||
|
||||
// Since we can't easily mock fs operations with real files,
|
||||
// we'll test a different error path
|
||||
await expect(service.configureUPS(config)).rejects.toThrow(
|
||||
'Failed to configure UPS: Generation failed'
|
||||
);
|
||||
|
||||
// Restore original method
|
||||
service['generateApcupsdConfig'] = originalGenerate;
|
||||
|
||||
// Config should remain unchanged
|
||||
const currentContent = await readFile(configPath, 'utf-8');
|
||||
expect(currentContent).toBe(originalContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('killpower functionality', () => {
|
||||
let tempRc6Path: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a temporary rc.6 file for testing
|
||||
tempRc6Path = join(tempDir, 'rc.6');
|
||||
|
||||
// Create a mock rc.6 content
|
||||
const mockRc6Content = `#!/bin/sh
|
||||
# Shutdown script
|
||||
echo "Shutting down..."
|
||||
/sbin/poweroff
|
||||
exit 0
|
||||
`;
|
||||
await writeFile(tempRc6Path, mockRc6Content, 'utf-8');
|
||||
|
||||
// Override the rc6Path in the service (we'll need to make it configurable)
|
||||
service['rc6Path'] = tempRc6Path;
|
||||
|
||||
// Update mock to indicate rc6 file exists
|
||||
mockFileExists.mockImplementation(async (path) => {
|
||||
if (path === configPath || path === tempRc6Path) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
it('should enable killpower when killUps=yes and service=enable', async () => {
|
||||
const config: UPSConfigInput = {
|
||||
killUps: UPSKillPower.YES,
|
||||
service: UPSServiceState.ENABLE,
|
||||
upsType: UPSType.USB,
|
||||
};
|
||||
|
||||
await service.configureUPS(config);
|
||||
|
||||
const rc6Content = await readFile(tempRc6Path, 'utf-8');
|
||||
expect(rc6Content).toContain('/etc/apcupsd/apccontrol killpower; /sbin/poweroff');
|
||||
// The file still contains "exit 0" on a separate line
|
||||
expect(rc6Content).toContain('exit 0');
|
||||
});
|
||||
|
||||
it('should disable killpower when killUps=no', async () => {
|
||||
// First enable killpower
|
||||
const enableConfig: UPSConfigInput = {
|
||||
killUps: UPSKillPower.YES,
|
||||
service: UPSServiceState.ENABLE,
|
||||
upsType: UPSType.USB,
|
||||
};
|
||||
await service.configureUPS(enableConfig);
|
||||
|
||||
// Then disable it
|
||||
const disableConfig: UPSConfigInput = {
|
||||
killUps: UPSKillPower.NO,
|
||||
service: UPSServiceState.ENABLE,
|
||||
upsType: UPSType.USB,
|
||||
};
|
||||
await service.configureUPS(disableConfig);
|
||||
|
||||
const rc6Content = await readFile(tempRc6Path, 'utf-8');
|
||||
expect(rc6Content).not.toContain('apccontrol killpower');
|
||||
expect(rc6Content).toContain('/sbin/poweroff\nexit 0'); // Should be restored
|
||||
});
|
||||
|
||||
it('should not enable killpower when service=disable', async () => {
|
||||
const config: UPSConfigInput = {
|
||||
killUps: UPSKillPower.YES,
|
||||
service: UPSServiceState.DISABLE, // Service is disabled
|
||||
upsType: UPSType.USB,
|
||||
};
|
||||
|
||||
await service.configureUPS(config);
|
||||
|
||||
const rc6Content = await readFile(tempRc6Path, 'utf-8');
|
||||
expect(rc6Content).not.toContain('apccontrol killpower');
|
||||
});
|
||||
|
||||
it('should handle missing rc.6 file gracefully', async () => {
|
||||
// Remove the file
|
||||
await unlink(tempRc6Path);
|
||||
|
||||
// Update mock to indicate rc6 file does NOT exist
|
||||
mockFileExists.mockImplementation(async (path) => {
|
||||
if (path === configPath) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const config: UPSConfigInput = {
|
||||
killUps: UPSKillPower.YES,
|
||||
service: UPSServiceState.ENABLE,
|
||||
upsType: UPSType.USB,
|
||||
};
|
||||
|
||||
// Should not throw - just skip killpower configuration
|
||||
await expect(service.configureUPS(config)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should be idempotent - enabling killpower multiple times', async () => {
|
||||
const config: UPSConfigInput = {
|
||||
killUps: UPSKillPower.YES,
|
||||
service: UPSServiceState.ENABLE,
|
||||
upsType: UPSType.USB,
|
||||
};
|
||||
|
||||
// Enable killpower twice
|
||||
await service.configureUPS(config);
|
||||
const firstContent = await readFile(tempRc6Path, 'utf-8');
|
||||
|
||||
await service.configureUPS(config);
|
||||
const secondContent = await readFile(tempRc6Path, 'utf-8');
|
||||
|
||||
// Content should be the same after second run
|
||||
expect(firstContent).toBe(secondContent);
|
||||
// Should only have one instance of killpower
|
||||
expect((secondContent.match(/apccontrol killpower/g) || []).length).toBe(1);
|
||||
});
|
||||
|
||||
it('should be idempotent - disabling killpower multiple times', async () => {
|
||||
const config: UPSConfigInput = {
|
||||
killUps: UPSKillPower.NO,
|
||||
service: UPSServiceState.ENABLE,
|
||||
upsType: UPSType.USB,
|
||||
};
|
||||
|
||||
// Disable killpower twice (when it's not enabled)
|
||||
await service.configureUPS(config);
|
||||
const firstContent = await readFile(tempRc6Path, 'utf-8');
|
||||
|
||||
await service.configureUPS(config);
|
||||
const secondContent = await readFile(tempRc6Path, 'utf-8');
|
||||
|
||||
// Content should be the same after second run
|
||||
expect(firstContent).toBe(secondContent);
|
||||
expect(secondContent).not.toContain('apccontrol killpower');
|
||||
});
|
||||
});
|
||||
});
|
||||
387
api/src/unraid-api/graph/resolvers/ups/ups.service.ts
Normal file
387
api/src/unraid-api/graph/resolvers/ups/ups.service.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
|
||||
import { execa } from 'execa';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { fileExists } from '@app/core/utils/files/file-exists.js';
|
||||
import { UPSConfigInput } from '@app/unraid-api/graph/resolvers/ups/ups.inputs.js';
|
||||
|
||||
const UPSSchema = z.object({
|
||||
MODEL: z.string().optional(),
|
||||
STATUS: z.string().optional(),
|
||||
BCHARGE: z.string().optional(),
|
||||
TIMELEFT: z.string().optional(),
|
||||
LINEV: z.string().optional(),
|
||||
OUTPUTV: z.string().optional(),
|
||||
LOADPCT: z.string().optional(),
|
||||
});
|
||||
|
||||
export type UPSData = z.infer<typeof UPSSchema>;
|
||||
|
||||
const UPSConfigSchema = z.object({
|
||||
SERVICE: z.string().optional(),
|
||||
UPSCABLE: z.string().optional(),
|
||||
CUSTOMUPSCABLE: z.string().optional(),
|
||||
UPSTYPE: z.string().optional(),
|
||||
DEVICE: z.string().optional(),
|
||||
OVERRIDE_UPS_CAPACITY: z.number().optional(),
|
||||
BATTERYLEVEL: z.number().optional(),
|
||||
MINUTES: z.number().optional(),
|
||||
TIMEOUT: z.number().optional(),
|
||||
KILLUPS: z.string().optional(),
|
||||
NISIP: z.string().optional(),
|
||||
NETSERVER: z.string().optional(),
|
||||
UPSNAME: z.string().optional(),
|
||||
MODELNAME: z.string().optional(),
|
||||
});
|
||||
|
||||
export type UPSConfig = z.infer<typeof UPSConfigSchema>;
|
||||
|
||||
@Injectable()
|
||||
export class UPSService {
|
||||
private readonly logger = new Logger(UPSService.name);
|
||||
private readonly configPath = '/etc/apcupsd/apcupsd.conf';
|
||||
private rc6Path = '/etc/rc.d/rc.6'; // Made non-readonly for testing
|
||||
|
||||
async getUPSData(): Promise<UPSData> {
|
||||
try {
|
||||
const { stdout } = await execa('/sbin/apcaccess', [], {
|
||||
timeout: 10000,
|
||||
reject: false, // Handle errors manually
|
||||
});
|
||||
if (!stdout || stdout.trim().length === 0) {
|
||||
throw new Error('No UPS data returned from apcaccess');
|
||||
}
|
||||
const parsedData = this.parseUPSData(stdout);
|
||||
return UPSSchema.parse(parsedData);
|
||||
} catch (error) {
|
||||
this.logger.error('Error getting UPS data:', error);
|
||||
throw new Error(
|
||||
`Failed to get UPS data: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async configureUPS(config: UPSConfigInput): Promise<void> {
|
||||
try {
|
||||
const currentConfig = await this.getCurrentConfig();
|
||||
const mergedConfig = this.mergeConfigurations(config, currentConfig);
|
||||
|
||||
this.validateConfiguration(mergedConfig);
|
||||
|
||||
await this.stopUPSService();
|
||||
|
||||
const newConfig = this.prepareConfigObject(mergedConfig, currentConfig);
|
||||
|
||||
await this.writeConfigurationWithBackup(newConfig, currentConfig);
|
||||
|
||||
await this.configureKillPower(mergedConfig);
|
||||
|
||||
if (mergedConfig.service === 'enable') {
|
||||
await this.startUPSService();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Error configuring UPS:', error);
|
||||
throw new Error(
|
||||
`Failed to configure UPS: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private mergeConfigurations(config: UPSConfigInput, currentConfig: UPSConfig) {
|
||||
return {
|
||||
service: config.service ?? currentConfig.SERVICE ?? 'disable',
|
||||
upsCable: config.upsCable ?? currentConfig.UPSCABLE ?? 'usb',
|
||||
customUpsCable: config.customUpsCable ?? currentConfig.CUSTOMUPSCABLE,
|
||||
upsType: config.upsType ?? currentConfig.UPSTYPE ?? 'usb',
|
||||
device: config.device ?? currentConfig.DEVICE ?? '',
|
||||
overrideUpsCapacity: config.overrideUpsCapacity ?? currentConfig.OVERRIDE_UPS_CAPACITY,
|
||||
batteryLevel: config.batteryLevel ?? currentConfig.BATTERYLEVEL ?? 10,
|
||||
minutes: config.minutes ?? currentConfig.MINUTES ?? 5,
|
||||
timeout: config.timeout ?? currentConfig.TIMEOUT ?? 0,
|
||||
killUps: config.killUps ?? currentConfig.KILLUPS ?? 'no',
|
||||
};
|
||||
}
|
||||
|
||||
private validateConfiguration(config: ReturnType<typeof this.mergeConfigurations>): void {
|
||||
if (!config.upsType) {
|
||||
throw new Error('upsType is required');
|
||||
}
|
||||
if (!config.device && config.upsType !== 'usb') {
|
||||
throw new Error('device is required for non-USB UPS types');
|
||||
}
|
||||
}
|
||||
|
||||
private async stopUPSService(): Promise<void> {
|
||||
try {
|
||||
await execa('/etc/rc.d/rc.apcupsd', ['stop'], { timeout: 10000 });
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to stop apcupsd service (may not be running):', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async startUPSService(): Promise<void> {
|
||||
try {
|
||||
await execa('/etc/rc.d/rc.apcupsd', ['start'], { timeout: 10000 });
|
||||
this.logger.debug('Successfully started apcupsd service');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to start apcupsd service:', error);
|
||||
throw new Error(
|
||||
`Configuration written successfully, but failed to start service: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private prepareConfigObject(
|
||||
mergedConfig: ReturnType<typeof this.mergeConfigurations>,
|
||||
currentConfig: UPSConfig
|
||||
): Partial<UPSConfig> {
|
||||
const cable =
|
||||
mergedConfig.upsCable === 'custom' ? mergedConfig.customUpsCable : mergedConfig.upsCable;
|
||||
|
||||
const newConfig: Partial<UPSConfig> = {
|
||||
NISIP: currentConfig.NISIP || '0.0.0.0',
|
||||
SERVICE: mergedConfig.service,
|
||||
UPSTYPE: mergedConfig.upsType,
|
||||
DEVICE: mergedConfig.device || '',
|
||||
BATTERYLEVEL: mergedConfig.batteryLevel,
|
||||
MINUTES: mergedConfig.minutes,
|
||||
TIMEOUT: mergedConfig.timeout,
|
||||
UPSCABLE: cable,
|
||||
KILLUPS: mergedConfig.killUps,
|
||||
NETSERVER: currentConfig.NETSERVER,
|
||||
UPSNAME: currentConfig.UPSNAME,
|
||||
MODELNAME: currentConfig.MODELNAME,
|
||||
};
|
||||
|
||||
if (
|
||||
mergedConfig.overrideUpsCapacity !== undefined &&
|
||||
mergedConfig.overrideUpsCapacity !== null
|
||||
) {
|
||||
newConfig.OVERRIDE_UPS_CAPACITY = mergedConfig.overrideUpsCapacity;
|
||||
}
|
||||
|
||||
return newConfig;
|
||||
}
|
||||
|
||||
private async writeConfigurationWithBackup(
|
||||
newConfig: Partial<UPSConfig>,
|
||||
currentConfig: UPSConfig
|
||||
): Promise<void> {
|
||||
const backupPath = `${this.configPath}.backup`;
|
||||
|
||||
await this.createBackup(backupPath);
|
||||
|
||||
const configContent = this.generateApcupsdConfig(newConfig, currentConfig);
|
||||
|
||||
try {
|
||||
await writeFile(this.configPath, configContent, 'utf-8');
|
||||
this.logger.debug('Successfully wrote new UPS configuration');
|
||||
} catch (error) {
|
||||
await this.restoreBackup(backupPath);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async createBackup(backupPath: string): Promise<void> {
|
||||
try {
|
||||
if (await fileExists(this.configPath)) {
|
||||
const currentContent = await readFile(this.configPath, 'utf-8');
|
||||
await writeFile(backupPath, currentContent, 'utf-8');
|
||||
this.logger.debug(`Backed up current config to ${backupPath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to create config backup:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async restoreBackup(backupPath: string): Promise<void> {
|
||||
try {
|
||||
if (await fileExists(backupPath)) {
|
||||
const backupContent = await readFile(backupPath, 'utf-8');
|
||||
await writeFile(this.configPath, backupContent, 'utf-8');
|
||||
this.logger.warn('Restored config from backup after write failure');
|
||||
}
|
||||
} catch (restoreError) {
|
||||
this.logger.error('Failed to restore config backup:', restoreError);
|
||||
}
|
||||
}
|
||||
|
||||
private async configureKillPower(
|
||||
config: ReturnType<typeof this.mergeConfigurations>
|
||||
): Promise<void> {
|
||||
// Only configure killpower if service is enabled
|
||||
if (config.service !== 'enable') {
|
||||
this.logger.debug('Skipping killpower configuration: service is not enabled');
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldEnableKillPower = config.killUps === 'yes';
|
||||
|
||||
try {
|
||||
await this.modifyRc6File(shouldEnableKillPower);
|
||||
} catch (error) {
|
||||
// If file doesn't exist, just skip (e.g., in tests)
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
this.logger.debug(`Skipping killpower configuration: ${this.rc6Path} not found`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async modifyRc6File(enableKillPower: boolean): Promise<void> {
|
||||
const content = await this.readFileIfExists(this.rc6Path);
|
||||
if (!content) {
|
||||
throw new Error(`${this.rc6Path} not found`);
|
||||
}
|
||||
|
||||
const killPowerCommand = '/etc/apcupsd/apccontrol killpower; /sbin/poweroff';
|
||||
const normalPoweroff = '/sbin/poweroff';
|
||||
const hasKillPower = content.includes('apccontrol killpower');
|
||||
|
||||
// Check if modification is needed
|
||||
if (enableKillPower && hasKillPower) {
|
||||
this.logger.debug('Killpower already enabled in rc.6');
|
||||
return;
|
||||
}
|
||||
if (!enableKillPower && !hasKillPower) {
|
||||
this.logger.debug('Killpower already disabled in rc.6');
|
||||
return;
|
||||
}
|
||||
|
||||
// Modify content
|
||||
const modifiedContent = enableKillPower
|
||||
? content.replace(normalPoweroff, killPowerCommand)
|
||||
: content.replace(killPowerCommand, normalPoweroff);
|
||||
|
||||
// Write the modified content
|
||||
try {
|
||||
await writeFile(this.rc6Path, modifiedContent, 'utf-8');
|
||||
this.logger.debug(
|
||||
enableKillPower ? 'Added killpower to rc.6' : 'Removed killpower from rc.6'
|
||||
);
|
||||
} catch (error) {
|
||||
const action = enableKillPower ? 'enable' : 'disable';
|
||||
this.logger.error(`Failed to update rc.6 for killpower ${action}:`, error);
|
||||
throw new Error(
|
||||
`Failed to ${action} killpower in ${this.rc6Path}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async readFileIfExists(path: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(path, 'utf-8');
|
||||
} catch (error) {
|
||||
// File doesn't exist or can't be read
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private parseUPSData(data: string): any {
|
||||
return data
|
||||
.split('\n')
|
||||
.map((line) => line.split(': '))
|
||||
.filter((parts) => parts.length === 2 && parts[0] && parts[1])
|
||||
.reduce(
|
||||
(upsData, [key, value]) => {
|
||||
upsData[key.trim()] = value.trim();
|
||||
return upsData;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
}
|
||||
|
||||
async getCurrentConfig(): Promise<UPSConfig> {
|
||||
try {
|
||||
const configContent = await this.readFileIfExists(this.configPath);
|
||||
if (!configContent) {
|
||||
this.logger.warn(`UPS config file not found at ${this.configPath}`);
|
||||
return {};
|
||||
}
|
||||
|
||||
const config = this.parseApcupsdConfig(configContent);
|
||||
return UPSConfigSchema.parse(config);
|
||||
} catch (error) {
|
||||
this.logger.error('Error reading UPS config:', error);
|
||||
throw new Error(
|
||||
`Failed to read UPS config: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private parseApcupsdConfig(content: string): Record<string, any> {
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith('#'))
|
||||
.map((line) => line.match(/^([A-Z_]+)\s+(.+)$/))
|
||||
.filter((match): match is RegExpMatchArray => match !== null)
|
||||
.reduce(
|
||||
(config, match) => {
|
||||
const [, directive, value] = match;
|
||||
let parsedValue = value.trim();
|
||||
|
||||
// Remove quotes if present
|
||||
if (parsedValue.startsWith('"') && parsedValue.endsWith('"')) {
|
||||
parsedValue = parsedValue.slice(1, -1);
|
||||
}
|
||||
|
||||
// Convert numeric values
|
||||
config[directive] = /^\d+$/.test(parsedValue)
|
||||
? parseInt(parsedValue, 10)
|
||||
: parsedValue;
|
||||
|
||||
return config;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
}
|
||||
|
||||
private generateApcupsdConfig(config: Partial<UPSConfig>, existingConfig: UPSConfig): string {
|
||||
// Merge with existing config, new values override existing ones
|
||||
const mergedConfig = { ...existingConfig, ...config };
|
||||
|
||||
// Define the order of directives
|
||||
const orderedDirectives = [
|
||||
'UPSNAME',
|
||||
'UPSCABLE',
|
||||
'UPSTYPE',
|
||||
'DEVICE',
|
||||
'BATTERYLEVEL',
|
||||
'MINUTES',
|
||||
'TIMEOUT',
|
||||
'NETSERVER',
|
||||
'NISIP',
|
||||
'MODELNAME',
|
||||
];
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('# APC UPS Configuration File');
|
||||
lines.push('# Generated by Unraid API');
|
||||
lines.push('');
|
||||
|
||||
// Add ordered directives first
|
||||
for (const directive of orderedDirectives) {
|
||||
if (mergedConfig[directive] !== undefined) {
|
||||
const value = mergedConfig[directive];
|
||||
lines.push(
|
||||
`${directive} ${typeof value === 'string' && value.includes(' ') ? `"${value}"` : value}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining directives not in the ordered list
|
||||
for (const [directive, value] of Object.entries(mergedConfig)) {
|
||||
if (!orderedDirectives.includes(directive as any) && value !== undefined) {
|
||||
lines.push(
|
||||
`${directive} ${typeof value === 'string' && value.includes(' ') ? `"${value}"` : value}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user